/*{ "parent": "utilities", "description": "share() synchronizes tosijs state across browser tabs and windows using BroadcastChannel and IndexedDB." }*/ /*# # share `share()` synchronizes state across browser tabs and windows. Pass it boxed proxies from `tosi()` and those paths will be kept in sync via `BroadcastChannel` and persisted to `IndexedDB`. ``` import { tosi, share } from 'tosijs' const { app } = tosi({ app: { user: null, settings: { theme: 'light' } } }) const { restored } = await share(app.user, app.settings) if (restored.length > 0) { // another tab had already stored state — we inherited it } ``` > `restored` contains the arguments whose values were overwritten from the > store. Compare by **path**, not by proxy identity — boxed proxies are > created per access, so `restored.includes(app.user)` is `false` even when > `app.user` *was* restored. Use `share('app.user')` with string paths, or > `restored.map(tosiPath)`, if you need to know *which* ones. The first tab to call `share()` seeds the store. Subsequent tabs inherit that data, overwriting their `tosi()` defaults. After setup, changes in any tab propagate to all others in real-time. ## What to share Share **small, session-level state**: user identity, preferences, auth tokens, UI mode, active selections, cache-invalidation keys. Don't share large datasets directly. Instead, share query metadata (URLs, cache keys, timestamps) and let each tab fetch or cache the data independently. This keeps the sync layer fast and avoids hitting `BroadcastChannel` or `IndexedDB` with multi-megabyte writes. ## How it works - Changes are sent as **deltas** via `BroadcastChannel` — only the changed path and its value are transmitted, not the entire root object. - **Persistence** uses `IndexedDB` (async, no size limit). Full root-path snapshots are written on a debounced schedule for cold-start recovery. ## API share(...proxiesOrPaths: (BoxedProxy | string)[]): Promise<{ restored: (BoxedProxy | string)[] }> - Accepts boxed proxies (from `tosi()`) or string paths - Returns `{ restored }` — the subset of arguments whose values were overwritten from pre-existing stored data - Idempotent: sharing the same path twice is a no-op - Throws if an argument is neither a proxy nor a string To clear shared state (e.g. on logout), set the values to their empty/default state. The change will propagate to all tabs and persist. ## Live Demo Drag the squares around, then click **New Window** to open a second copy. Drag in either window and watch the other update in real-time. ```html
``` ```css .preview { touch-action: none; min-height: 200px; } .draggable { position: absolute; width: 50px; height: 50px; cursor: move; border-radius: 6px; } .draggable[data-key="red"] { background: #f008; } .draggable[data-key="green"] { background: #0f08; } .draggable[data-key="blue"] { background: #00f8; } .spawn { position: absolute; bottom: 8px; right: 8px; } ``` ```js import { tosi, share, xin } from 'tosijs' import { trackDrag } from 'tosijs-ui' const { squares } = tosi({ squares: { red: { x: 20, y: 20 }, green: { x: 120, y: 20 }, blue: { x: 220, y: 20 }, } }) await share(squares) const draggables = [...preview.querySelectorAll('.draggable')] function render() { for (const el of draggables) { const key = el.dataset.key el.style.left = xin.squares[key].x + 'px' el.style.top = xin.squares[key].y + 'px' } } render() squares.observe(render) function dragItem(event) { const el = event.target.closest('.draggable') if (!el) return const key = el.dataset.key const start = { ...xin.squares[key] } trackDrag(event, (dx, dy, event) => { xin.squares[key] = { x: start.x + dx, y: start.y + dy } render() return event.type === 'mouseup' }) } preview.addEventListener('mousedown', dragItem) preview.addEventListener('touchstart', dragItem, { passive: true }) preview.querySelector('.spawn').addEventListener('click', () => { window.open(location.href) }) ``` */ import { registry } from './registry' import { getByPath, setByPath } from './by-path' import { touch, observe, updates } from './path-listener' import { tosiPath } from './metadata' import { debounce } from './throttle' const CHANNEL_NAME = 'tosijs-share' const DB_NAME = 'tosijs-share' const STORE_NAME = 'shared' const DB_VERSION = 1 interface ShareMessage { type: 'tosijs-share' path: string value: any origin: string } // Module-level state const sharedPaths = new Set() const inboundPaths = new Set() // Paths whose stored snapshot is being read during share() setup. If a live // delta arrives (via the channel) while we're awaiting the store read, it is // fresher than the snapshot — record it so the stale snapshot doesn't clobber // it on restore. const restoringPaths = new Set() const freshDuringRestore = new Set() const debouncedSaves = new Map void>() let channel: BroadcastChannel | null = null let origin = '' let db: IDBDatabase | null = null /** * Pluggable store interface for testing. * Default implementation uses IndexedDB. Tests can replace with an in-memory store. */ export interface ShareStore { get(key: string): Promise set(key: string, value: any): Promise } let store: ShareStore | null = null /** Replace the default IndexedDB store (mainly for testing). */ export function setShareStore(s: ShareStore): void { store = s } // --- IndexedDB helpers --- function openDB(): Promise { if (db != null) return Promise.resolve(db) return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) req.onupgradeneeded = () => { req.result.createObjectStore(STORE_NAME) } req.onsuccess = () => { db = req.result resolve(db) } req.onerror = () => reject(req.error) }) } const idbStore: ShareStore = { async get(key: string): Promise { const database = await openDB() return new Promise((resolve, reject) => { const tx = database.transaction(STORE_NAME, 'readonly') const req = tx.objectStore(STORE_NAME).get(key) req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) }, async set(key: string, value: any): Promise { const database = await openDB() return new Promise((resolve, reject) => { const tx = database.transaction(STORE_NAME, 'readwrite') tx.objectStore(STORE_NAME).put(value, key) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) }, } function getStore(): ShareStore { return store ?? idbStore } // --- Helpers --- function isShareMessage(data: any): data is ShareMessage { return ( data != null && data.type === 'tosijs-share' && typeof data.path === 'string' ) } function findSharedRoot(changedPath: string): string | undefined { for (const shared of sharedPaths) { if (changedPath === shared || changedPath.startsWith(shared + '.')) { return shared } } return undefined } function isInbound(changedPath: string): boolean { for (const path of inboundPaths) { if (changedPath === path || changedPath.startsWith(path + '.')) { return true } } return false } function applyInbound(path: string, value: any): void { // a live delta landing while a path's snapshot is still being read is // fresher than that snapshot — flag it so restore won't overwrite it const root = findSharedRoot(path) if (root !== undefined && restoringPaths.has(root)) { freshDuringRestore.add(root) } inboundPaths.add(path) try { setByPath(registry, path, value) touch(path) } catch (e) { // A REFUSED DELTA MUST NOT POISON THE CHANNEL. The path is registered as // inbound BEFORE the write so the echo suppressor can recognize it; if // the write throws, the cleanup below was never scheduled, so the path // stayed in inboundPaths for the life of the page — and isInbound() // matches by PREFIX, so every subsequent LOCAL change to that path and // its whole subtree was mistaken for an echo and silently never sent. // One bad message killed sync for a subtree, permanently and quietly. // 1.8.0 made this reachable on purpose: setByPath now REFUSES unsafe path // segments, and a peer or server supplies those paths. inboundPaths.delete(path) console.error( `tosijs: refused an inbound delta at "${path}" —`, e, '(the local state is unchanged and this channel keeps working)' ) return } updates().then(() => { inboundPaths.delete(path) }) } function initChannel(): BroadcastChannel { if (channel != null) return channel origin = crypto.randomUUID() channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { const msg = event.data if (!isShareMessage(msg)) return if (msg.origin === origin) return // Check that the message path falls under a shared root if (findSharedRoot(msg.path) === undefined) return applyInbound(msg.path, msg.value) } return channel } function broadcast(path: string, value: any): void { if (channel == null) return const msg: ShareMessage = { type: 'tosijs-share', path, value, origin, } channel.postMessage(msg) } function schedulePersist(rootPath: string): void { if (!debouncedSaves.has(rootPath)) { debouncedSaves.set( rootPath, debounce(() => { const current = getByPath(registry, rootPath) getStore().set(rootPath, current) }, 500) ) } debouncedSaves.get(rootPath)!() } export async function share(...proxies: any[]): Promise<{ restored: any[] }> { // Graceful degradation for SSR/Node if (typeof BroadcastChannel === 'undefined') { return { restored: [] } } initChannel() const restored: any[] = [] const s = getStore() for (const proxy of proxies) { const path = typeof proxy === 'string' ? proxy : tosiPath(proxy) if (path === undefined) { throw new Error( 'share() requires boxed proxies or string paths. Got a non-proxy value.' ) } // Idempotent — skip if already shared if (sharedPaths.has(path)) continue // Claim the path immediately (idempotency + so inbound deltas that arrive // during the async store read below are recognized as under a shared root) sharedPaths.add(path) restoringPaths.add(path) // Restore from store or seed it const stored = await s.get(path) const gotFresh = freshDuringRestore.has(path) restoringPaths.delete(path) freshDuringRestore.delete(path) if (stored !== undefined && !gotFresh) { // Restore THROUGH applyInbound: it marks the path inbound so the // outbound observer (registered just below) treats the restore touch // as an echo and does NOT broadcast the stored — possibly up-to-500ms- // stale — snapshot back over any live tab's fresher state. applyInbound(path, stored) restored.push(proxy) } else if (stored === undefined) { const value = getByPath(registry, path) await s.set(path, value) } // Outbound observer: sync local changes to other tabs observe( (changedPath: string) => changedPath === path || changedPath.startsWith(path + '.'), (changedPath: string) => { if (isInbound(changedPath)) return const rootPath = findSharedRoot(changedPath) if (rootPath === undefined) return // Broadcast the delta at the changed path const value = getByPath(registry, changedPath) broadcast(changedPath, value) // Debounced full root-path persist for cold start schedulePersist(rootPath) } ) } return { restored } }