@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 +144 -8
- package/dist/index.d.ts +195 -1
- package/dist/index.js +274 -2
- package/package.json +67 -2
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
|
|
16
|
-
clocks and a late-joiner handshake, typed pub/sub events, and peer
|
|
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
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
}
|