@use-everywhere/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @use-everywhere/core
2
2
 
3
3
  Framework-agnostic engine for cross-tab shared state, typed events, peer
4
- presence, and secure cross-origin window channels.
4
+ presence, and secure cross-origin window channels. Zero dependencies.
5
5
 
6
6
  ```bash
7
7
  npm i @use-everywhere/core
@@ -12,22 +12,158 @@ npm i @use-everywhere/core
12
12
 
13
13
  Two transports behind one library:
14
14
 
15
- - **BroadcastChannel** (same-origin): shared state with last-writer-wins version
16
- clocks and a late-joiner handshake, typed pub/sub events, and peer presence.
15
+ - **BroadcastChannel** (same-origin): shared state with last-writer-wins
16
+ version clocks and a late-joiner handshake, typed pub/sub events, and peer
17
+ presence.
17
18
  - **window.opener / postMessage** (cross-origin): a secure 1:1 channel to a
18
19
  window you opened. Every message is validated by origin, envelope brand, a
19
20
  per-connection nonce, and the source window.
20
21
 
22
+ ## Shared state
23
+
24
+ One object that exists in every tab, window, and worker on your origin.
25
+ Writes broadcast patches; replicas converge last-writer-wins; tabs opened
26
+ later hydrate to the current value via a hello/snapshot handshake.
27
+
28
+ ```ts
29
+ import { createSharedStore } from '@use-everywhere/core';
30
+
31
+ const store = createSharedStore('checkout', { step: 0, payment: 'idle' });
32
+
33
+ // Imperative writes through the proxy — they sync everywhere:
34
+ store.state.step++;
35
+
36
+ // Or explicit (supports functional updates):
37
+ store.set('payment', 'processing');
38
+ store.set('step', (prev) => prev + 1);
39
+
40
+ // React to changes from any tab, worker, or this one:
41
+ store.subscribe((key, value, meta) => {
42
+ console.log(`${String(key)} = ${value}`, meta.self ? '(me)' : `(peer ${meta.clientId})`);
43
+ });
44
+
45
+ // Immutable snapshot, replaced per change (useSyncExternalStore-compatible):
46
+ store.getSnapshot(); // { step: 1, payment: 'processing' }
47
+ ```
48
+
49
+ Options let you delimit what a store accepts — e.g. ignore writes coming from
50
+ workers:
51
+
52
+ ```ts
53
+ createSharedStore('ui', { theme: 'light' }, { accept: (meta) => meta.kind !== 'worker' });
54
+ ```
55
+
56
+ ## Typed events
57
+
58
+ Fire-and-forget messages between contexts. No history: a tab that joins later
59
+ never sees old events (use shared state for anything a late joiner must know).
60
+
61
+ ```ts
62
+ import { createChannel } from '@use-everywhere/core';
63
+
64
+ type AuthEvents = { 'logged-out': undefined; 'session-renewed': { expiresAt: number } };
65
+
66
+ const channel = createChannel<AuthEvents>('auth');
67
+
68
+ const off = channel.on('logged-out', (_payload, meta) => {
69
+ console.log(`tab ${meta.clientId} logged out`);
70
+ window.location.assign('/login');
71
+ });
72
+
73
+ channel.post('logged-out', undefined); // delivered to every OTHER context
74
+ ```
75
+
76
+ ## Presence
77
+
78
+ Who else is on this origin right now? Heartbeat-based, with instant goodbyes
79
+ on clean tab closes and pruning (~5s) for crashed ones.
80
+
81
+ ```ts
82
+ import { createPresence } from '@use-everywhere/core';
83
+
84
+ const presence = createPresence('app');
85
+ presence.subscribe(() => {
86
+ console.log(presence.getPeers()); // [{ id: 'p8m1q4', kind: 'tab', lastSeen: … }]
87
+ });
88
+ ```
89
+
90
+ ## Cross-origin window channel
91
+
92
+ The case BroadcastChannel cannot do: a checkout on domain A opens a payment
93
+ page on domain B, and the payment page must report back.
94
+
95
+ ```ts
96
+ // On the opener (https://shop.example.com):
97
+ import { openWindow } from '@use-everywhere/core';
98
+
99
+ type ToPayment = { order: { orderId: string; amount: string } };
100
+ type FromPayment = { progress: { step: string } };
101
+ type Receipt = { receiptId: string; last4: string };
102
+
103
+ const opened = openWindow<ToPayment, FromPayment, Receipt>(
104
+ 'https://pay.example.com/checkout',
105
+ { peerOrigin: 'https://pay.example.com' }, // required — '*' throws
106
+ );
107
+
108
+ opened.post('order', { orderId: '48-291', amount: '$69.03' }); // queued until the child is ready
109
+ opened.on('progress', ({ step }) => console.log('payment step:', step));
110
+
111
+ const receipt = await opened.result; // the child's finish() value
112
+ // rejects with WindowClosedError if the user closes the window first
113
+ ```
114
+
115
+ ```ts
116
+ // On the opened page (https://pay.example.com):
117
+ import { connectToOpener } from '@use-everywhere/core';
118
+
119
+ const conn = connectToOpener<ToPayment, FromPayment, Receipt>({
120
+ peerOrigin: 'https://shop.example.com',
121
+ });
122
+
123
+ conn.on('order', (order) => showOrderSummary(order)); // your UI code
124
+ conn.finish({ receiptId: 'r-123', last4: '4242' }); // resolves the opener's `result`
125
+ conn.close();
126
+ ```
127
+
128
+ The handshake retries until the (possibly slow-loading) child connects, and
129
+ both sides queue outgoing messages until then — nothing is dropped. Every
130
+ received message must pass four gates: exact origin, library envelope, a
131
+ per-connection nonce carried in the child URL, and (on the opener) the source
132
+ window itself.
133
+
134
+ ## Testing
135
+
136
+ Every engine accepts an injected transport, so "many tabs" fits in one test —
137
+ no browser required:
138
+
139
+ ```ts
140
+ import { createSharedStore, MemoryHub } from '@use-everywhere/core';
141
+
142
+ const hub = new MemoryHub();
143
+ const tabA = createSharedStore('t', { n: 0 }, { transport: () => hub.connect() });
144
+ const tabB = createSharedStore('t', { n: 0 }, { transport: () => hub.connect() });
145
+
146
+ tabA.set('n', 1);
147
+ await new Promise((r) => setTimeout(r, 0));
148
+ tabB.getSnapshot().n; // 1
149
+ ```
150
+
151
+ `openWindow`/`connectToOpener` take equivalent seams (`openFn`, `localWindow`,
152
+ `opener`, `cid`) for driving window flows with fakes.
153
+
21
154
  ## Design notes
22
155
 
23
156
  - **Shared state never crosses origins.** Two origins are two trust domains;
24
157
  the cross-origin channel is explicit, per-message, and typed.
25
- - Same-origin state sync uses per-key `[counter, clientId]` clocks
26
- (last-writer-wins, deterministic tie-break) and a hello/snapshot handshake so
27
- late-joining tabs hydrate instantly.
28
- - Values must survive structured clone (no functions, DOM nodes, etc.).
158
+ - Values must survive structured clone (no functions, DOM nodes, class
159
+ instances).
160
+ - State lives exactly as long as some context holds it — nothing is persisted.
161
+ - SSR-safe: without `BroadcastChannel`, engines fall back to a local no-op
162
+ transport.
29
163
 
30
- Full docs, demo app (including a real cross-origin payment flow), and source:
164
+ 📖 **[Documentation](https://rxova.github.io/use-everywhere/)** mental
165
+ model, how sync works, security model, recipes, and generated API reference.
166
+ Source and demo app (with a real cross-origin payment flow):
31
167
  [github.com/rxova/use-everywhere](https://github.com/rxova/use-everywhere)
32
168
 
33
169
  ## License
package/dist/index.d.ts CHANGED
@@ -42,6 +42,34 @@ interface Channel<M extends MessageMap> {
42
42
  /** Typed pub/sub over the same-origin bus. */
43
43
  declare function createChannel<M extends MessageMap>(name: string, options?: CommonOptions): Channel<M>;
44
44
 
45
+ /**
46
+ * What goes to disk. The version clocks travel *with* the values — that is the
47
+ * whole point: a reopened tab re-enters the last-writer-wins race with its real
48
+ * term instead of a fresh zero, so a restored value can legitimately beat, or
49
+ * legitimately lose to, whatever the live tabs are holding.
50
+ */
51
+ interface Persisted {
52
+ v: 1;
53
+ state: Record<string, unknown>;
54
+ versions: Record<string, Version>;
55
+ }
56
+ interface PersistAdapter {
57
+ /**
58
+ * Prefer a synchronous read. An async adapter cannot hydrate before the store
59
+ * is handed back, so a write made in that gap can be clobbered by the restore.
60
+ */
61
+ read(): Persisted | undefined | Promise<Persisted | undefined>;
62
+ write(snapshot: Persisted): void | Promise<void>;
63
+ remove?(): void | Promise<void>;
64
+ }
65
+ interface PersistOptions {
66
+ adapter: PersistAdapter;
67
+ /** Persist only these keys. Default: every key that has been written. */
68
+ keys?: string[];
69
+ /** Coalesce writes for this long. Default 100. */
70
+ debounceMs?: number;
71
+ }
72
+
45
73
  interface SharedStoreOptions extends CommonOptions {
46
74
  /**
47
75
  * Gatekeeper for incoming remote writes (patches and snapshot merges):
@@ -49,6 +77,8 @@ interface SharedStoreOptions extends CommonOptions {
49
77
  * e.g. accept only writes from other tabs, not from workers.
50
78
  */
51
79
  accept?: (meta: MessageMeta) => boolean;
80
+ /** Restore this store on creation and write it back as it changes. */
81
+ persist?: PersistOptions;
52
82
  }
53
83
  interface SharedStore<S extends Record<string, unknown>> {
54
84
  readonly clientId: string;
@@ -56,6 +86,8 @@ interface SharedStore<S extends Record<string, unknown>> {
56
86
  readonly state: S;
57
87
  /** Immutable snapshot, replaced whenever a change is applied. Safe for useSyncExternalStore. */
58
88
  getSnapshot(): Readonly<S>;
89
+ /** The per-key version clocks behind the snapshot. Referentially stable, like getSnapshot. */
90
+ getVersions(): Readonly<Record<string, Version>>;
59
91
  set<K extends keyof S & string>(key: K, value: S[K] | ((prev: S[K]) => S[K])): void;
60
92
  subscribe(fn: (key: keyof S & string, value: unknown, meta: MessageMeta) => void): () => void;
61
93
  subscribeKey(key: keyof S & string, fn: () => void): () => void;
@@ -75,6 +107,59 @@ interface SharedStore<S extends Record<string, unknown>> {
75
107
  */
76
108
  declare function createSharedStore<S extends Record<string, unknown>>(name: string, initial: S, options?: SharedStoreOptions): SharedStore<S>;
77
109
 
110
+ /** Everything on the same-origin bus, multiplexed by scope over one BroadcastChannel per name. */
111
+ type BusWire = {
112
+ v: 1;
113
+ scope: 'state';
114
+ type: 'patch';
115
+ key: string;
116
+ value: unknown;
117
+ version: Version;
118
+ clientId: string;
119
+ kind: PeerKind;
120
+ } | {
121
+ v: 1;
122
+ scope: 'state';
123
+ type: 'hello';
124
+ clientId: string;
125
+ kind: PeerKind;
126
+ } | {
127
+ v: 1;
128
+ scope: 'state';
129
+ type: 'snapshot';
130
+ clientId: string;
131
+ kind: PeerKind;
132
+ state: Record<string, unknown>;
133
+ versions: Record<string, Version>;
134
+ } | {
135
+ v: 1;
136
+ scope: 'presence';
137
+ type: 'hello' | 'ping' | 'bye';
138
+ clientId: string;
139
+ kind: PeerKind;
140
+ } | {
141
+ v: 1;
142
+ scope: 'leader';
143
+ type: 'hello';
144
+ clientId: string;
145
+ kind: PeerKind;
146
+ } | {
147
+ v: 1;
148
+ scope: 'leader';
149
+ type: 'claim' | 'heartbeat' | 'resign';
150
+ /** The claimant's term. Arbitrated with newer() — the same clock the store uses. */
151
+ term: Version;
152
+ clientId: string;
153
+ kind: PeerKind;
154
+ } | {
155
+ v: 1;
156
+ scope: 'event';
157
+ type: string;
158
+ payload: unknown;
159
+ clientId: string;
160
+ kind: PeerKind;
161
+ msgId: string;
162
+ };
78
163
  interface BusOptions extends CommonOptions {
79
164
  /** Presence heartbeat interval in ms. Default 2000. */
80
165
  heartbeatMs?: number;
@@ -99,6 +184,70 @@ interface Presence {
99
184
  */
100
185
  declare function createPresence(name: string, options?: PresenceOptions): Presence;
101
186
 
187
+ /**
188
+ * Deliberately extends CommonOptions, not BusOptions: `heartbeatMs` here means
189
+ * the leader's re-announce interval, which is a different thing from the bus's
190
+ * presence ping. See the note in leader.ts about forwarding to getBus.
191
+ */
192
+ interface LeaderOptions extends CommonOptions {
193
+ /** How often the leader re-announces itself, in ms. Default 1000. */
194
+ heartbeatMs?: number;
195
+ /** How long a follower tolerates silence before calling the seat empty, in ms. Default 3000. */
196
+ leaseMs?: number;
197
+ /** May this client hold the leadership? Default true. */
198
+ eligible?: boolean;
199
+ }
200
+ interface LeaderSnapshot {
201
+ /** The current leader's clientId, or null while the seat is empty. */
202
+ readonly leaderId: string | null;
203
+ readonly isLeader: boolean;
204
+ }
205
+ interface Leader {
206
+ readonly clientId: string;
207
+ /** Frozen; a new object only when the leader actually changes. */
208
+ getSnapshot(): LeaderSnapshot;
209
+ subscribe(fn: () => void): () => void;
210
+ /** Give up the seat now. Peers take over immediately rather than waiting for the lease. */
211
+ resign(): void;
212
+ /** Turn candidacy on or off. Eligibility is a property of the tab, not a component. */
213
+ setEligible(eligible: boolean): void;
214
+ close(): void;
215
+ }
216
+
217
+ /**
218
+ * Elects exactly one client on the bus to hold a seat: the tab that owns the
219
+ * WebSocket, polls, or refreshes the token, while the others stand by.
220
+ *
221
+ * Lease and claim, with a sticky incumbent. A leader re-announces every
222
+ * heartbeatMs; followers give up on it after leaseMs of silence and claim the
223
+ * seat with a higher term. Terms are Versions, arbitrated by the same newer()
224
+ * the store uses, so simultaneous claims resolve deterministically instead of
225
+ * flapping.
226
+ *
227
+ * Leadership is advisory. It is not a distributed lock, and a hidden tab whose
228
+ * timers are throttled can lose a lease it deserved to keep.
229
+ */
230
+ declare function createLeader(name: string, options?: LeaderOptions): Leader;
231
+
232
+ type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
233
+ /**
234
+ * Persist to any Storage-shaped thing.
235
+ *
236
+ * `storage` may be a thunk, and that is the form the built-in adapters use:
237
+ * merely *reading* `globalThis.localStorage` throws SecurityError when storage
238
+ * is blocked (a sandboxed iframe, third-party cookies off), so evaluating it at
239
+ * module scope would blow up on import — before any try/catch here could help.
240
+ * Behind a thunk, every access happens inside one.
241
+ *
242
+ * Blocked storage, corrupt JSON, a foreign schema, or a full quota all degrade
243
+ * to a silent no-op. Persistence is best-effort; it must never break the store.
244
+ */
245
+ declare function webStorageAdapter(storage: StorageLike | (() => StorageLike | undefined), key: string): PersistAdapter;
246
+ /** Survives closing every tab. */
247
+ declare function localStorageAdapter(key: string): PersistAdapter;
248
+ /** Survives reloads, but dies with the tab. */
249
+ declare function sessionStorageAdapter(key: string): PersistAdapter;
250
+
102
251
  interface MessageEventLike {
103
252
  data: unknown;
104
253
  origin: string;
@@ -197,6 +346,51 @@ declare class HandshakeTimeoutError extends Error {
197
346
  /** Is `a` newer than `b`? Last-writer-wins; equal counters break ties by clientId. */
198
347
  declare function newer(a: Version, b: Version | undefined): boolean;
199
348
 
349
+ /**
350
+ * The bus name used when a caller does not pick one. A BroadcastChannel is
351
+ * global to the origin, so identity is the name string — everything that
352
+ * omits a name lands on this one bus.
353
+ */
354
+ declare const DEFAULT_NAME = "use-everywhere";
355
+
356
+ /** One wire crossing the bus, in either direction. */
357
+ interface BusEvent {
358
+ /** The bus name the wire crossed. */
359
+ readonly name: string;
360
+ /** 'out' is posted by this client; 'in' is received from a peer. */
361
+ readonly direction: 'in' | 'out';
362
+ readonly wire: BusWire;
363
+ }
364
+ type BusObserver = (event: BusEvent) => void;
365
+ interface DebugOptions {
366
+ /** Bus name to log. Defaults to the default store/channel name. */
367
+ name?: string;
368
+ /** Where to write. Defaults to console.log. */
369
+ log?: (...args: unknown[]) => void;
370
+ }
371
+
372
+ /**
373
+ * Watch every wire crossing the named bus, in both directions. Outbound wires
374
+ * are the interesting half: a post goes straight to the transport, so without
375
+ * this seam nothing this client says is visible to it.
376
+ *
377
+ * Works for buses that do not exist yet — observe first, create later.
378
+ */
379
+ declare function observeBus(name: string, fn: BusObserver): () => void;
380
+ /** Log every wire on a bus to the console. Returns a function to stop. */
381
+ declare function enableDebug(options?: DebugOptions): () => void;
382
+
383
+ /**
384
+ * Get the shared bus for `name`, creating it on first use. Callers must call
385
+ * bus.release() exactly once when done. When a custom transport factory is
386
+ * given (tests), every call creates an isolated bus — one call = one simulated client.
387
+ */
388
+ /**
389
+ * Names of the buses currently alive on this page. Buses built with a custom
390
+ * transport (tests) bypass the registry, so they are not listed.
391
+ */
392
+ declare function getBusNames(): string[];
393
+
200
394
  /** Same-origin transport over a real BroadcastChannel. */
201
395
  declare class BroadcastChannelTransport implements Transport {
202
396
  private bc;
@@ -248,4 +442,4 @@ declare class MemoryHub {
248
442
  disconnect(transport: MemoryTransport): void;
249
443
  }
250
444
 
251
- export { BroadcastChannelTransport, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, HandshakeTimeoutError, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type Transport, type Version, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createPresence, createSharedStore, defaultTransport, isBroadcastChannelAvailable, newer, openWindow };
445
+ export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, type Transport, type Version, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
package/dist/index.js CHANGED
@@ -1,3 +1,37 @@
1
+ // src/defaults.ts
2
+ var DEFAULT_NAME = "use-everywhere";
3
+
4
+ // src/debug.ts
5
+ var observers = /* @__PURE__ */ new Map();
6
+ function emitBusEvent(name, direction, wire) {
7
+ const set = observers.get(name);
8
+ if (!set) return;
9
+ const event = { name, direction, wire };
10
+ for (const fn of set) fn(event);
11
+ }
12
+ function observeBus(name, fn) {
13
+ let set = observers.get(name);
14
+ if (!set) {
15
+ set = /* @__PURE__ */ new Set();
16
+ observers.set(name, set);
17
+ }
18
+ set.add(fn);
19
+ return () => {
20
+ const current = observers.get(name);
21
+ if (!current) return;
22
+ current.delete(fn);
23
+ if (current.size === 0) observers.delete(name);
24
+ };
25
+ }
26
+ function enableDebug(options = {}) {
27
+ const name = options.name ?? DEFAULT_NAME;
28
+ const log = options.log ?? ((...args) => console.log(...args));
29
+ return observeBus(name, ({ direction, wire }) => {
30
+ const arrow = direction === "out" ? "\u2192" : "\u2190";
31
+ log(`[use-everywhere:${name}] ${arrow} ${wire.scope}/${wire.type}`, wire);
32
+ });
33
+ }
34
+
1
35
  // src/ids.ts
2
36
  function newClientId() {
3
37
  return Math.random().toString(36).slice(2, 8);
@@ -64,11 +98,14 @@ function createBus(name, options, onShutdown) {
64
98
  let refs = 0;
65
99
  let closed = false;
66
100
  const post = (wire) => {
67
- if (!closed) transport.post(wire);
101
+ if (closed) return;
102
+ emitBusEvent(name, "out", wire);
103
+ transport.post(wire);
68
104
  };
69
105
  const unsubscribe = transport.subscribe((data) => {
70
106
  if (!isBusWire(data)) return;
71
107
  if (data.clientId === clientId) return;
108
+ emitBusEvent(name, "in", data);
72
109
  if (data.scope === "presence" && data.type === "hello") {
73
110
  post({ v: 1, scope: "presence", type: "ping", clientId, kind });
74
111
  }
@@ -108,6 +145,9 @@ function createBus(name, options, onShutdown) {
108
145
  }
109
146
  };
110
147
  }
148
+ function getBusNames() {
149
+ return [...registry.keys()];
150
+ }
111
151
  function getBus(name, options = {}) {
112
152
  if (options.transport) {
113
153
  const bus2 = createBus(name, options, () => {
@@ -180,10 +220,12 @@ function createSharedStore(name, initial, options = {}) {
180
220
  const versions = {};
181
221
  for (const k in state) versions[k] = [0, clientId];
182
222
  let snapshot = Object.freeze({ ...state });
223
+ let versionsSnapshot = Object.freeze({ ...versions });
183
224
  const listeners = /* @__PURE__ */ new Set();
184
225
  const keyListeners = /* @__PURE__ */ new Map();
185
226
  function notify(key, value, meta) {
186
227
  snapshot = Object.freeze({ ...state });
228
+ versionsSnapshot = Object.freeze({ ...versions });
187
229
  for (const fn of listeners) fn(key, value, meta);
188
230
  const set = keyListeners.get(key);
189
231
  if (set) for (const fn of set) fn();
@@ -242,11 +284,65 @@ function createSharedStore(name, initial, options = {}) {
242
284
  return true;
243
285
  }
244
286
  });
287
+ const persist = options.persist;
288
+ let flushPersist;
289
+ if (persist) {
290
+ const { adapter, keys, debounceMs = 100 } = persist;
291
+ const shouldPersist = (key) => !keys || keys.includes(key);
292
+ const hydrate = (saved2) => {
293
+ if (!saved2) return;
294
+ for (const key in saved2.state) {
295
+ const version = saved2.versions[key];
296
+ if (!version || !shouldPersist(key)) continue;
297
+ applyRemote(key, saved2.state[key], version, { clientId, kind: bus.kind, self: true });
298
+ bus.post({
299
+ v: 1,
300
+ scope: "state",
301
+ type: "patch",
302
+ key,
303
+ value: saved2.state[key],
304
+ version,
305
+ clientId,
306
+ kind: bus.kind
307
+ });
308
+ }
309
+ };
310
+ const collect = () => {
311
+ const out = { v: 1, state: {}, versions: {} };
312
+ for (const key in versions) {
313
+ const version = versions[key];
314
+ if (!version || version[0] === 0 || !shouldPersist(key)) continue;
315
+ out.state[key] = state[key];
316
+ out.versions[key] = version;
317
+ }
318
+ return out;
319
+ };
320
+ let timer;
321
+ flushPersist = () => {
322
+ clearTimeout(timer);
323
+ timer = void 0;
324
+ void adapter.write(collect());
325
+ };
326
+ const saved = adapter.read();
327
+ if (saved instanceof Promise) {
328
+ void saved.then(hydrate);
329
+ } else {
330
+ hydrate(saved);
331
+ }
332
+ listeners.add(() => {
333
+ if (timer !== void 0) return;
334
+ timer = setTimeout(flushPersist, debounceMs);
335
+ });
336
+ if (typeof document !== "undefined" && typeof addEventListener === "function") {
337
+ addEventListener("pagehide", flushPersist);
338
+ }
339
+ }
245
340
  bus.post({ v: 1, scope: "state", type: "hello", clientId, kind: bus.kind });
246
341
  return {
247
342
  clientId,
248
343
  state: proxy,
249
344
  getSnapshot: () => snapshot,
345
+ getVersions: () => versionsSnapshot,
250
346
  set(key, value) {
251
347
  const next = typeof value === "function" ? value(state[key]) : value;
252
348
  setKey(key, next);
@@ -269,8 +365,15 @@ function createSharedStore(name, initial, options = {}) {
269
365
  versions[key] = [0, clientId];
270
366
  state[key] = initialValue;
271
367
  snapshot = Object.freeze({ ...state });
368
+ versionsSnapshot = Object.freeze({ ...versions });
272
369
  },
273
370
  close() {
371
+ if (flushPersist) {
372
+ flushPersist();
373
+ if (typeof document !== "undefined" && typeof removeEventListener === "function") {
374
+ removeEventListener("pagehide", flushPersist);
375
+ }
376
+ }
274
377
  unsubscribe();
275
378
  listeners.clear();
276
379
  keyListeners.clear();
@@ -329,6 +432,167 @@ function createPresence(name, options = {}) {
329
432
  };
330
433
  }
331
434
 
435
+ // src/leader.ts
436
+ var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
437
+ function createLeader(name, options = {}) {
438
+ const heartbeatMs = options.heartbeatMs ?? 1e3;
439
+ const leaseMs = options.leaseMs ?? 3e3;
440
+ const busOptions = {
441
+ ...options.transport ? { transport: options.transport } : {},
442
+ ...options.kind ? { kind: options.kind } : {}
443
+ };
444
+ const bus = getBus(name, busOptions);
445
+ const clientId = bus.clientId;
446
+ let eligible = options.eligible ?? true;
447
+ let leaderId = null;
448
+ let term = [0, clientId];
449
+ let snapshot = NO_LEADER;
450
+ let beat;
451
+ let lease;
452
+ let closed = false;
453
+ const listeners = /* @__PURE__ */ new Set();
454
+ function setLeader(id) {
455
+ if (id === leaderId) return;
456
+ leaderId = id;
457
+ snapshot = Object.freeze({ leaderId: id, isLeader: id === clientId });
458
+ for (const fn of listeners) fn();
459
+ }
460
+ function armLease(delay) {
461
+ clearTimeout(lease);
462
+ lease = setTimeout(onLeaseExpired, delay);
463
+ }
464
+ function onLeaseExpired() {
465
+ if (closed) return;
466
+ setLeader(null);
467
+ if (eligible) claim();
468
+ }
469
+ function heartbeat() {
470
+ bus.post({ v: 1, scope: "leader", type: "heartbeat", term, clientId, kind: bus.kind });
471
+ }
472
+ function claim() {
473
+ term = [term[0] + 1, clientId];
474
+ setLeader(clientId);
475
+ bus.post({ v: 1, scope: "leader", type: "claim", term, clientId, kind: bus.kind });
476
+ clearInterval(beat);
477
+ beat = setInterval(heartbeat, heartbeatMs);
478
+ }
479
+ function stepDown(next) {
480
+ clearInterval(beat);
481
+ beat = void 0;
482
+ setLeader(next);
483
+ }
484
+ const unsubscribe = bus.subscribe((wire) => {
485
+ if (wire.scope !== "leader") return;
486
+ if (wire.type === "hello") {
487
+ if (leaderId === clientId) heartbeat();
488
+ return;
489
+ }
490
+ if (wire.type === "resign") {
491
+ if (wire.clientId !== leaderId) return;
492
+ setLeader(null);
493
+ armLease(0);
494
+ return;
495
+ }
496
+ if (newer(wire.term, term)) {
497
+ term = wire.term;
498
+ stepDown(wire.clientId);
499
+ armLease(leaseMs);
500
+ return;
501
+ }
502
+ if (leaderId === clientId) {
503
+ heartbeat();
504
+ return;
505
+ }
506
+ if (wire.clientId === leaderId) armLease(leaseMs);
507
+ });
508
+ function resign() {
509
+ if (leaderId !== clientId) return;
510
+ const resigning = term;
511
+ stepDown(null);
512
+ bus.post({ v: 1, scope: "leader", type: "resign", term: resigning, clientId, kind: bus.kind });
513
+ armLease(leaseMs);
514
+ }
515
+ const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
516
+ const onPageHide = () => resign();
517
+ if (hasWindow) addEventListener("pagehide", onPageHide);
518
+ bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
519
+ armLease(heartbeatMs);
520
+ return {
521
+ clientId,
522
+ getSnapshot: () => snapshot,
523
+ subscribe(fn) {
524
+ listeners.add(fn);
525
+ return () => listeners.delete(fn);
526
+ },
527
+ resign,
528
+ setEligible(next) {
529
+ if (next === eligible) return;
530
+ eligible = next;
531
+ if (!next) {
532
+ if (leaderId === clientId) resign();
533
+ return;
534
+ }
535
+ if (leaderId === null) armLease(0);
536
+ },
537
+ close() {
538
+ closed = true;
539
+ resign();
540
+ clearInterval(beat);
541
+ clearTimeout(lease);
542
+ if (hasWindow) removeEventListener("pagehide", onPageHide);
543
+ unsubscribe();
544
+ listeners.clear();
545
+ bus.release();
546
+ }
547
+ };
548
+ }
549
+
550
+ // src/persist-web-storage.ts
551
+ function isPersisted(value) {
552
+ if (typeof value !== "object" || value === null) return false;
553
+ const candidate = value;
554
+ return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
555
+ }
556
+ function webStorageAdapter(storage, key) {
557
+ const resolve = () => {
558
+ try {
559
+ return typeof storage === "function" ? storage() : storage;
560
+ } catch {
561
+ return void 0;
562
+ }
563
+ };
564
+ return {
565
+ read() {
566
+ try {
567
+ const raw = resolve()?.getItem(key);
568
+ if (!raw) return void 0;
569
+ const parsed = JSON.parse(raw);
570
+ return isPersisted(parsed) ? parsed : void 0;
571
+ } catch {
572
+ return void 0;
573
+ }
574
+ },
575
+ write(snapshot) {
576
+ try {
577
+ resolve()?.setItem(key, JSON.stringify(snapshot));
578
+ } catch {
579
+ }
580
+ },
581
+ remove() {
582
+ try {
583
+ resolve()?.removeItem(key);
584
+ } catch {
585
+ }
586
+ }
587
+ };
588
+ }
589
+ function localStorageAdapter(key) {
590
+ return webStorageAdapter(() => globalThis.localStorage, key);
591
+ }
592
+ function sessionStorageAdapter(key) {
593
+ return webStorageAdapter(() => globalThis.sessionStorage, key);
594
+ }
595
+
332
596
  // src/errors/handshake-timeout-error.ts
333
597
  var HandshakeTimeoutError = class extends Error {
334
598
  constructor(message = "handshake with the other window timed out") {
@@ -615,6 +879,7 @@ var MemoryHub = class {
615
879
  export {
616
880
  BroadcastChannelTransport,
617
881
  CID_PARAM,
882
+ DEFAULT_NAME,
618
883
  HandshakeTimeoutError,
619
884
  MemoryHub,
620
885
  MemoryTransport,
@@ -622,10 +887,17 @@ export {
622
887
  WindowClosedError,
623
888
  connectToOpener,
624
889
  createChannel,
890
+ createLeader,
625
891
  createPresence,
626
892
  createSharedStore,
627
893
  defaultTransport,
894
+ enableDebug,
895
+ getBusNames,
628
896
  isBroadcastChannelAvailable,
897
+ localStorageAdapter,
629
898
  newer,
630
- openWindow
899
+ observeBus,
900
+ openWindow,
901
+ sessionStorageAdapter,
902
+ webStorageAdapter
631
903
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-everywhere/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Cross-tab shared state, events, presence, and cross-origin window channels",
5
5
  "license": "MIT",
6
6
  "author": "Jonatan Kruszewski <jonakrusze@gmail.com>",
@@ -36,9 +36,73 @@
36
36
  "files": [
37
37
  "dist"
38
38
  ],
39
+ "size-limit": [
40
+ {
41
+ "name": "everything (import *)",
42
+ "path": "dist/index.js",
43
+ "import": "*",
44
+ "limit": "4.5 kB"
45
+ },
46
+ {
47
+ "name": "createLeader",
48
+ "path": "dist/index.js",
49
+ "import": "{ createLeader }",
50
+ "limit": "1.3 kB"
51
+ },
52
+ {
53
+ "name": "createSharedStore",
54
+ "path": "dist/index.js",
55
+ "import": "{ createSharedStore }",
56
+ "limit": "1.6 kB"
57
+ },
58
+ {
59
+ "name": "createChannel",
60
+ "path": "dist/index.js",
61
+ "import": "{ createChannel }",
62
+ "limit": "900 B"
63
+ },
64
+ {
65
+ "name": "createPresence",
66
+ "path": "dist/index.js",
67
+ "import": "{ createPresence }",
68
+ "limit": "1 kB"
69
+ },
70
+ {
71
+ "name": "openWindow (opener side)",
72
+ "path": "dist/index.js",
73
+ "import": "{ openWindow }",
74
+ "limit": "1.15 kB"
75
+ },
76
+ {
77
+ "name": "connectToOpener (child side)",
78
+ "path": "dist/index.js",
79
+ "import": "{ connectToOpener }",
80
+ "limit": "1 kB"
81
+ },
82
+ {
83
+ "name": "MemoryHub (test transport)",
84
+ "path": "dist/index.js",
85
+ "import": "{ MemoryHub }",
86
+ "limit": "300 B"
87
+ },
88
+ {
89
+ "name": "observeBus + enableDebug",
90
+ "path": "dist/index.js",
91
+ "import": "{ observeBus, enableDebug }",
92
+ "limit": "500 B"
93
+ },
94
+ {
95
+ "name": "localStorageAdapter",
96
+ "path": "dist/index.js",
97
+ "import": "{ localStorageAdapter }",
98
+ "limit": "500 B"
99
+ }
100
+ ],
39
101
  "devDependencies": {
102
+ "@size-limit/preset-small-lib": "^12.1.0",
40
103
  "@vitest/coverage-v8": "^4.1.10",
41
104
  "happy-dom": "^20.10.6",
105
+ "size-limit": "^12.1.0",
42
106
  "tsup": "^8.5.0",
43
107
  "typescript": "^5.8.3",
44
108
  "vitest": "^4.1.10"
@@ -46,6 +110,7 @@
46
110
  "scripts": {
47
111
  "build": "tsup",
48
112
  "test": "vitest run --coverage",
49
- "typecheck": "tsc --noEmit"
113
+ "typecheck": "tsc --noEmit",
114
+ "size": "size-limit"
50
115
  }
51
116
  }