@caspian-vega/nano-rune-binding
@caspian-vega/nano-rune-binding
Two way binding between a nanostore and a Svelte 5 rune, with every write back traceable through a
named @nanostores/logger action.
Part of caspian-vega-astro-libs.
Read only interop
If you ONLY reading a store inside a component, use the official
@nanostores/svelte-runes package:
pnpm add @nanostores/svelte-runes
<script lang="ts">
import { useStore } from '@nanostores/svelte-runes';
import { postCount } from './post.store.ts';
const count = useStore(postCount);
</script>
<p>{count.current} posts</p>
This rune binding is read-only, by default, but ships code you may actually never need.
Install
npm install @caspian-vega/nano-rune-binding
# or
pnpm add @caspian-vega/nano-rune-binding
Peer dependencies: svelte 5, nanostores. @nanostores/logger is optional and only needed for
bindLogging.
Usage
<script lang="ts">
import { toRune } from '@caspian-vega/nano-rune-binding';
import { filterStore } from './post.store.ts';
// Edits to `filter` are pushed back into the store, each one logged as
// the action `RuneProxy_post-filters`.
const filter = toRune(filterStore, true, 'post-filters').rune;
</script>
<input bind:value={filter.search} />
Traceable write back
A bind: on a rune is otherwise an anonymous mutation: the store changes and nothing says which
component did it. Passing a label to bindLogging wraps the write back in a @nanostores/logger
action named RuneProxy_<label>, so every store change coming from a binding is attributed to that
label in the logger output, and a change without such an entry came from somewhere else.
sequenceDiagram
participant U as User input
participant R as Rune proxy
participant A as Action RuneProxy_label
participant S as Store
participant L as Logger
U->>R: bind: writes a nested value
R->>R: Snapshot differs from last synced value
R->>A: Call with the cloned snapshot
A->>S: set(value)
A-->>L: action RuneProxy_label
S-->>R: Emit, marked as synced, no echo back
Give each binding its own label. Two bindings sharing one label are indistinguishable in the log.
Why the package depends on peer .svelte.js
The entry is a rune module. It is published uncompiled, so the runes are compiled by the consumer's
vite-plugin-svelte and share that app's reactivity. The package therefore declares a svelte export
condition, and any bundler must run .svelte.js files from node_modules through the Svelte compiler.
Astro projects using @astrojs/svelte get this by default.
API
toRune(store, bidirectional?, bindLogging?)
Subscribes to store and mirrors its value into a $state proxy of the same shape.
store(StoreContract<T>, required): anything withsubscribe, andsetfor write back.bidirectional(boolean, defaultfalse): push rune edits back into the store. Requiresstore.set.bindLogging(false | string, defaultfalse): wrap the write back in a@nanostores/loggeraction namedRuneProxy_<value>. Needs@nanostores/loggerinstalled and a logger attached to the store.- Returns:
RuneProxy<T>with aruneproperty holding the proxy and adestroy()method. - Registers
onDestroy, so it must be called during component initialization.
const proxy = toRune(draftStore, true, 'draft-editor');
proxy.rune.title = 'new title';
Write back is guarded, which is what keeps a store update from bouncing back as a fresh object:
flowchart TD
S[Store emits value] --> D[deepAssign into rune proxy]
D --> M[Mark value as synced]
E[Rune mutated in a component] --> F{Snapshot equals synced value?}
F -->|yes| G[Skip, the store already holds it]
F -->|no| H[Clone, mark synced, store.set]
M --> F
Types
StoreContract
subscribe(run: (value: T) => void, invalidate?: () => void): () => void(required)set(value: T): void(optional): required forbidirectional
Constraints
- The store value must be an object or an array.
toRunemirrors into a proxy of that shape. - The shape should stay stable. Values are merged with a deep assign that preserves proxy identity, arrays sync by index and are truncated to the source length.
- Write back clones with
structuredClone, so values must be structured cloneable. toRuneusesonDestroyand belongs in component initialization, not in a store or a plain module.
License
MIT