@syncular/client 0.1.3 → 0.2.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.
Files changed (233) hide show
  1. package/README.md +227 -709
  2. package/dist/apply.d.ts +80 -0
  3. package/dist/apply.js +301 -0
  4. package/dist/blob.d.ts +127 -0
  5. package/dist/blob.js +201 -0
  6. package/dist/bun-database.d.ts +22 -0
  7. package/dist/bun-database.js +61 -0
  8. package/dist/client.d.ts +320 -69
  9. package/dist/client.js +1627 -163
  10. package/dist/content-type.d.ts +2 -0
  11. package/dist/content-type.js +2 -0
  12. package/dist/database.d.ts +35 -80
  13. package/dist/database.js +32 -947
  14. package/dist/encryption.d.ts +47 -0
  15. package/dist/encryption.js +75 -0
  16. package/dist/errors.d.ts +8 -22
  17. package/dist/errors.js +10 -207
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.js +196 -0
  20. package/dist/index.d.ts +28 -16
  21. package/dist/index.js +25 -12
  22. package/dist/invalidation.d.ts +69 -0
  23. package/dist/invalidation.js +84 -0
  24. package/dist/leader-lock.d.ts +28 -0
  25. package/dist/leader-lock.js +38 -0
  26. package/dist/multi-tab.d.ts +134 -0
  27. package/dist/multi-tab.js +399 -0
  28. package/dist/node-database.d.ts +41 -0
  29. package/dist/node-database.js +133 -0
  30. package/dist/outbox.d.ts +56 -0
  31. package/dist/outbox.js +140 -0
  32. package/dist/schema.d.ts +107 -0
  33. package/dist/schema.js +267 -0
  34. package/dist/state.d.ts +40 -0
  35. package/dist/state.js +77 -0
  36. package/dist/transport.d.ts +44 -0
  37. package/dist/transport.js +7 -0
  38. package/dist/wasm-database.d.ts +30 -0
  39. package/dist/wasm-database.js +170 -0
  40. package/dist/window.d.ts +64 -0
  41. package/dist/window.js +0 -0
  42. package/dist/worker-entry.d.ts +16 -2
  43. package/dist/worker-entry.js +300 -456
  44. package/dist/worker-host.d.ts +174 -0
  45. package/dist/worker-host.js +536 -0
  46. package/dist/worker-protocol.d.ts +173 -61
  47. package/dist/worker-protocol.js +7 -16
  48. package/package.json +45 -112
  49. package/src/apply.ts +435 -0
  50. package/src/blob.ts +333 -0
  51. package/src/bun-database.ts +75 -0
  52. package/src/client.ts +2247 -277
  53. package/src/content-type.ts +2 -0
  54. package/src/database.ts +63 -1389
  55. package/src/encryption.ts +123 -0
  56. package/src/errors.ts +11 -265
  57. package/src/http.ts +254 -0
  58. package/src/index.ts +25 -20
  59. package/src/invalidation.ts +128 -0
  60. package/src/leader-lock.ts +68 -0
  61. package/src/multi-tab.ts +550 -0
  62. package/src/node-database.ts +180 -0
  63. package/src/outbox.ts +206 -0
  64. package/src/schema.ts +397 -0
  65. package/src/state.ts +126 -0
  66. package/src/transport.ts +52 -0
  67. package/src/wasm-database.ts +279 -0
  68. package/src/window.ts +0 -0
  69. package/src/worker-entry.ts +391 -545
  70. package/src/worker-host.ts +800 -0
  71. package/src/worker-protocol.ts +204 -99
  72. package/dist/auth-leases.d.ts +0 -11
  73. package/dist/auth-leases.d.ts.map +0 -1
  74. package/dist/auth-leases.js +0 -140
  75. package/dist/auth-leases.js.map +0 -1
  76. package/dist/blob-limits.d.ts +0 -15
  77. package/dist/blob-limits.d.ts.map +0 -1
  78. package/dist/blob-limits.js +0 -66
  79. package/dist/blob-limits.js.map +0 -1
  80. package/dist/bridge-client.d.ts +0 -69
  81. package/dist/bridge-client.d.ts.map +0 -1
  82. package/dist/bridge-client.js +0 -282
  83. package/dist/bridge-client.js.map +0 -1
  84. package/dist/client-config.d.ts +0 -6
  85. package/dist/client-config.d.ts.map +0 -1
  86. package/dist/client-config.js +0 -18
  87. package/dist/client-config.js.map +0 -1
  88. package/dist/client.d.ts.map +0 -1
  89. package/dist/client.js.map +0 -1
  90. package/dist/command-history.d.ts +0 -35
  91. package/dist/command-history.d.ts.map +0 -1
  92. package/dist/command-history.js +0 -378
  93. package/dist/command-history.js.map +0 -1
  94. package/dist/console-diagnostics.d.ts +0 -33
  95. package/dist/console-diagnostics.d.ts.map +0 -1
  96. package/dist/console-diagnostics.js +0 -496
  97. package/dist/console-diagnostics.js.map +0 -1
  98. package/dist/crdt-yjs/index.d.ts +0 -4
  99. package/dist/crdt-yjs/index.d.ts.map +0 -1
  100. package/dist/crdt-yjs/index.js +0 -4
  101. package/dist/crdt-yjs/index.js.map +0 -1
  102. package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
  103. package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
  104. package/dist/crdt-yjs/webview-host-facade.js +0 -284
  105. package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
  106. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
  107. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
  108. package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
  109. package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
  110. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
  111. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
  112. package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
  113. package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
  114. package/dist/database.d.ts.map +0 -1
  115. package/dist/database.js.map +0 -1
  116. package/dist/diagnostics.d.ts +0 -10
  117. package/dist/diagnostics.d.ts.map +0 -1
  118. package/dist/diagnostics.js +0 -83
  119. package/dist/diagnostics.js.map +0 -1
  120. package/dist/errors.d.ts.map +0 -1
  121. package/dist/errors.js.map +0 -1
  122. package/dist/generated-bridge.d.ts +0 -365
  123. package/dist/generated-bridge.d.ts.map +0 -1
  124. package/dist/generated-bridge.js +0 -250
  125. package/dist/generated-bridge.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/mutations.d.ts +0 -72
  129. package/dist/mutations.d.ts.map +0 -1
  130. package/dist/mutations.js +0 -63
  131. package/dist/mutations.js.map +0 -1
  132. package/dist/network.d.ts +0 -3
  133. package/dist/network.d.ts.map +0 -1
  134. package/dist/network.js +0 -17
  135. package/dist/network.js.map +0 -1
  136. package/dist/react/index.d.ts +0 -169
  137. package/dist/react/index.d.ts.map +0 -1
  138. package/dist/react/index.js +0 -628
  139. package/dist/react/index.js.map +0 -1
  140. package/dist/react-native/index.d.ts +0 -35
  141. package/dist/react-native/index.d.ts.map +0 -1
  142. package/dist/react-native/index.js +0 -49
  143. package/dist/react-native/index.js.map +0 -1
  144. package/dist/runtime-contract.d.ts +0 -13
  145. package/dist/runtime-contract.d.ts.map +0 -1
  146. package/dist/runtime-contract.js +0 -24
  147. package/dist/runtime-contract.js.map +0 -1
  148. package/dist/rust-client.d.ts +0 -106
  149. package/dist/rust-client.d.ts.map +0 -1
  150. package/dist/rust-client.js +0 -938
  151. package/dist/rust-client.js.map +0 -1
  152. package/dist/sentry.d.ts +0 -35
  153. package/dist/sentry.d.ts.map +0 -1
  154. package/dist/sentry.js +0 -155
  155. package/dist/sentry.js.map +0 -1
  156. package/dist/sql-safety.d.ts +0 -3
  157. package/dist/sql-safety.d.ts.map +0 -1
  158. package/dist/sql-safety.js +0 -62
  159. package/dist/sql-safety.js.map +0 -1
  160. package/dist/syncular-runtime-artifacts.json +0 -61
  161. package/dist/tauri/index.d.ts +0 -35
  162. package/dist/tauri/index.d.ts.map +0 -1
  163. package/dist/tauri/index.js +0 -114
  164. package/dist/tauri/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -1096
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -2
  168. package/dist/types.js.map +0 -1
  169. package/dist/wasm/.syncular-wasm-profile +0 -1
  170. package/dist/wasm/syncular-runtime-artifact.json +0 -21
  171. package/dist/wasm/syncular.d.ts +0 -207
  172. package/dist/wasm/syncular.js +0 -2341
  173. package/dist/wasm/syncular_bg.wasm +0 -0
  174. package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
  175. package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
  176. package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
  177. package/dist/wasm-bindings/runtime-contract.js +0 -112
  178. package/dist/wasm-bindings/runtime-contract.js.map +0 -1
  179. package/dist/wasm-core/.syncular-wasm-profile +0 -1
  180. package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
  181. package/dist/wasm-core/syncular.d.ts +0 -162
  182. package/dist/wasm-core/syncular.js +0 -1847
  183. package/dist/wasm-core/syncular_bg.wasm +0 -0
  184. package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
  185. package/dist/wasm-perf/.syncular-wasm-profile +0 -1
  186. package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
  187. package/dist/wasm-perf/syncular.d.ts +0 -207
  188. package/dist/wasm-perf/syncular.js +0 -2341
  189. package/dist/wasm-perf/syncular_bg.wasm +0 -0
  190. package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
  191. package/dist/wasm-runtime.d.ts +0 -23
  192. package/dist/wasm-runtime.d.ts.map +0 -1
  193. package/dist/wasm-runtime.js +0 -69
  194. package/dist/wasm-runtime.js.map +0 -1
  195. package/dist/worker-client.d.ts +0 -123
  196. package/dist/worker-client.d.ts.map +0 -1
  197. package/dist/worker-client.js +0 -1735
  198. package/dist/worker-client.js.map +0 -1
  199. package/dist/worker-entry.d.ts.map +0 -1
  200. package/dist/worker-entry.js.map +0 -1
  201. package/dist/worker-protocol.d.ts.map +0 -1
  202. package/dist/worker-protocol.js.map +0 -1
  203. package/dist/worker-realtime.d.ts +0 -39
  204. package/dist/worker-realtime.d.ts.map +0 -1
  205. package/dist/worker-realtime.js +0 -677
  206. package/dist/worker-realtime.js.map +0 -1
  207. package/src/auth-leases.ts +0 -251
  208. package/src/blob-limits.ts +0 -98
  209. package/src/bridge-client.ts +0 -512
  210. package/src/client-config.ts +0 -29
  211. package/src/command-history.ts +0 -623
  212. package/src/console-diagnostics.ts +0 -617
  213. package/src/crdt-yjs/index.ts +0 -3
  214. package/src/crdt-yjs/webview-host-facade.ts +0 -477
  215. package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
  216. package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
  217. package/src/diagnostics.ts +0 -116
  218. package/src/generated-bridge.ts +0 -741
  219. package/src/mutations.ts +0 -168
  220. package/src/network.ts +0 -32
  221. package/src/react/index.ts +0 -1036
  222. package/src/react-native/index.ts +0 -152
  223. package/src/runtime-contract.ts +0 -48
  224. package/src/rust-client.ts +0 -1491
  225. package/src/sentry.ts +0 -215
  226. package/src/sql-safety.ts +0 -61
  227. package/src/tauri/index.ts +0 -211
  228. package/src/types.ts +0 -1397
  229. package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
  230. package/src/wasm-bindings/runtime-contract.ts +0 -158
  231. package/src/wasm-runtime.ts +0 -145
  232. package/src/worker-client.ts +0 -2289
  233. package/src/worker-realtime.ts +0 -843
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Multi-tab followers (TODO 3.2, REVISE B3): one core per origin, N tabs.
3
+ *
4
+ * The leader tab holds the Web Locks lease and runs the worker core (the
5
+ * existing worker-host path, unchanged). Every OTHER tab is a FOLLOWER: it
6
+ * loses the lock election and, instead of a dead not-leader handle, opens a
7
+ * BroadcastChannel to the leader and proxies the whole logical API over it —
8
+ * one sync loop, one WebSocket, one DB, N tabs.
9
+ *
10
+ * Wire (all messages structured-clone-safe; `Set`/`Uint8Array`/`ArrayBuffer`
11
+ * survive `postMessage` on a BroadcastChannel):
12
+ *
13
+ * follower → leader
14
+ * hello {t,epoch?,fromId} — "who's the leader?" on join
15
+ * req {t,epoch,fromId,reqId,method,args}
16
+ * bye {t,fromId} — follower leaving (best effort)
17
+ * leader → all
18
+ * announce {t,epoch,clientId} — "I am the leader, this epoch"
19
+ * res {t,epoch,reqId,ok,value|error} — reply to one req
20
+ * event {t,epoch,event} — fan-out (invalidate/presence/…)
21
+ *
22
+ * Epoch (leader generation token): a monotonic counter carried in a shared
23
+ * BroadcastChannel and bumped by every promotion. Followers stamp requests
24
+ * with the epoch they last heard in an `announce`; a leader ignores requests
25
+ * from a stale epoch, and a follower discards any `res`/`event` that does not
26
+ * match its current epoch — so a late reply from a tab that has since died
27
+ * (or a duplicate from a previous leader) can never be mistaken for a live
28
+ * one. Epoch is derived deterministically from a per-origin clock: each
29
+ * promoter reads the highest epoch it has seen and adds one, so successive
30
+ * leaders always strictly increase it even across the lock-handover gap.
31
+ *
32
+ * Presence identity: all tabs share the leader's one connection, so a device
33
+ * is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
34
+ * A follower's `setPresence` forwards to the leader's single publisher; there
35
+ * is no per-tab presence peer (documented in the web-client README).
36
+ */
37
+ import { ClientSyncError } from './errors';
38
+ import type { SyncWorkerEvent } from './worker-protocol';
39
+ import { WORKER_FAILED_CODE } from './worker-protocol';
40
+
41
+ /** Client-local: a follower call could not reach a leader before its deadline. */
42
+ export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
43
+
44
+ /** Default deadline for a follower request (covers a leader-handover gap). */
45
+ export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
46
+ /** Max follower calls queued across a handover before we fail loudly. */
47
+ export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Wire messages
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface HelloMessage {
54
+ readonly t: 'hello';
55
+ readonly fromId: string;
56
+ /** Highest epoch the sender has observed (helps a promoter monotonically
57
+ * advance even if it never saw the previous leader's announce). */
58
+ readonly epoch?: number;
59
+ }
60
+
61
+ interface ByeMessage {
62
+ readonly t: 'bye';
63
+ readonly fromId: string;
64
+ }
65
+
66
+ interface ReqMessage {
67
+ readonly t: 'req';
68
+ readonly epoch: number;
69
+ readonly fromId: string;
70
+ readonly reqId: number;
71
+ readonly method: string;
72
+ readonly args: readonly unknown[];
73
+ }
74
+
75
+ interface AnnounceMessage {
76
+ readonly t: 'announce';
77
+ readonly epoch: number;
78
+ readonly clientId: string;
79
+ }
80
+
81
+ interface ResMessage {
82
+ readonly t: 'res';
83
+ readonly epoch: number;
84
+ readonly reqId: number;
85
+ readonly ok: boolean;
86
+ readonly value?: unknown;
87
+ readonly error?: { code: string; message: string; retryable: boolean };
88
+ }
89
+
90
+ interface EventMessage {
91
+ readonly t: 'event';
92
+ readonly epoch: number;
93
+ readonly event: SyncWorkerEvent;
94
+ }
95
+
96
+ export type MultiTabMessage =
97
+ | HelloMessage
98
+ | ByeMessage
99
+ | ReqMessage
100
+ | AnnounceMessage
101
+ | ResMessage
102
+ | EventMessage;
103
+
104
+ /**
105
+ * The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
106
+ * satisfies it. Injectable so bun tests can pair two instances by name.
107
+ */
108
+ export interface CrossTabChannel {
109
+ postMessage(message: MultiTabMessage): void;
110
+ addEventListener(
111
+ type: 'message',
112
+ listener: (event: { data: MultiTabMessage }) => void,
113
+ ): void;
114
+ removeEventListener(
115
+ type: 'message',
116
+ listener: (event: { data: MultiTabMessage }) => void,
117
+ ): void;
118
+ close(): void;
119
+ }
120
+
121
+ /** Default channel factory: a real `BroadcastChannel` named per lock. */
122
+ export function broadcastChannelFactory(): (name: string) => CrossTabChannel {
123
+ return (name) => new BroadcastChannel(name) as unknown as CrossTabChannel;
124
+ }
125
+
126
+ /** The channel name a leader and its followers rendezvous on. */
127
+ export function multiTabChannelName(lockName: string): string {
128
+ return `syncular-mt:${lockName}`;
129
+ }
130
+
131
+ let uniqueCounter = 0;
132
+ /** A per-tab identity for addressing (not a presence identity). */
133
+ export function newTabId(): string {
134
+ const rand =
135
+ typeof crypto !== 'undefined' && crypto.randomUUID !== undefined
136
+ ? crypto.randomUUID()
137
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
138
+ return `tab-${rand}-${(uniqueCounter++).toString(36)}`;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Leader side: bridge follower requests into the worker, fan-out events
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Runs on the leader tab. Announces leadership, answers follower `req`s by
147
+ * invoking the (already-running) worker via `invoke`, and rebroadcasts every
148
+ * worker event to followers. Owns nothing about the worker lifecycle — the
149
+ * worker-host stays the single core owner; this is a relay.
150
+ */
151
+ export class LeaderBridge {
152
+ readonly #channel: CrossTabChannel;
153
+ readonly #epoch: number;
154
+ readonly #clientId: string;
155
+ readonly #invoke: (
156
+ method: string,
157
+ args: readonly unknown[],
158
+ ) => Promise<unknown>;
159
+ readonly #onMessage: (event: { data: MultiTabMessage }) => void;
160
+ #closed = false;
161
+
162
+ constructor(options: {
163
+ channel: CrossTabChannel;
164
+ epoch: number;
165
+ clientId: string;
166
+ invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
167
+ }) {
168
+ this.#channel = options.channel;
169
+ this.#epoch = options.epoch;
170
+ this.#clientId = options.clientId;
171
+ this.#invoke = options.invoke;
172
+ this.#onMessage = (event) => this.#handle(event.data);
173
+ this.#channel.addEventListener('message', this.#onMessage);
174
+ this.announce();
175
+ }
176
+
177
+ /** Re-announce leadership (on promotion and on a follower `hello`). */
178
+ announce(): void {
179
+ if (this.#closed) return;
180
+ this.#channel.postMessage({
181
+ t: 'announce',
182
+ epoch: this.#epoch,
183
+ clientId: this.#clientId,
184
+ });
185
+ }
186
+
187
+ /** Fan a worker event out to all followers on the current epoch. */
188
+ broadcastEvent(event: SyncWorkerEvent): void {
189
+ if (this.#closed) return;
190
+ this.#channel.postMessage({ t: 'event', epoch: this.#epoch, event });
191
+ }
192
+
193
+ #handle(message: MultiTabMessage): void {
194
+ if (this.#closed) return;
195
+ if (message.t === 'hello') {
196
+ // A follower joined (or is contesting) — tell it who leads.
197
+ this.announce();
198
+ return;
199
+ }
200
+ if (message.t !== 'req') return;
201
+ // Ignore requests stamped for a different (dead/older) leader; that
202
+ // follower will re-stamp once it sees our announce.
203
+ if (message.epoch !== this.#epoch) return;
204
+ const { reqId, method, args } = message;
205
+ this.#invoke(method, args).then(
206
+ (value) => {
207
+ this.#channel.postMessage({
208
+ t: 'res',
209
+ epoch: this.#epoch,
210
+ reqId,
211
+ ok: true,
212
+ value,
213
+ });
214
+ },
215
+ (error: unknown) => {
216
+ const shape =
217
+ error instanceof ClientSyncError
218
+ ? {
219
+ code: error.code,
220
+ message: error.message,
221
+ retryable: error.retryable,
222
+ }
223
+ : {
224
+ code: WORKER_FAILED_CODE,
225
+ message:
226
+ error instanceof Error ? error.message : 'unknown error',
227
+ retryable: false,
228
+ };
229
+ this.#channel.postMessage({
230
+ t: 'res',
231
+ epoch: this.#epoch,
232
+ reqId,
233
+ ok: false,
234
+ error: shape,
235
+ });
236
+ },
237
+ );
238
+ }
239
+
240
+ close(): void {
241
+ if (this.#closed) return;
242
+ this.#closed = true;
243
+ this.#channel.removeEventListener('message', this.#onMessage);
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Follower side: proxy the logical API to the leader over the channel
249
+ // ---------------------------------------------------------------------------
250
+
251
+ interface QueuedCall {
252
+ readonly method: string;
253
+ readonly args: readonly unknown[];
254
+ readonly resolve: (value: unknown) => void;
255
+ readonly reject: (error: unknown) => void;
256
+ timer: ReturnType<typeof setTimeout> | undefined;
257
+ }
258
+
259
+ interface InFlight {
260
+ readonly resolve: (value: unknown) => void;
261
+ readonly reject: (error: unknown) => void;
262
+ timer: ReturnType<typeof setTimeout> | undefined;
263
+ }
264
+
265
+ /**
266
+ * Runs on a follower tab. Sends `req`s to the leader and settles them on the
267
+ * matching `res`; queues calls (bounded, timed) while no leader is bound (the
268
+ * handover gap) and flushes them once an `announce` binds a new leader.
269
+ * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
270
+ * the resolved leader `clientId` back through `onLeaderChange`.
271
+ */
272
+ export class FollowerLink {
273
+ readonly #channel: CrossTabChannel;
274
+ readonly #fromId: string;
275
+ readonly #onEvent: (event: SyncWorkerEvent) => void;
276
+ readonly #onLeaderChange: (clientId: string) => void;
277
+ readonly #callTimeoutMs: number;
278
+ readonly #queueLimit: number;
279
+ readonly #onMessage: (event: { data: MultiTabMessage }) => void;
280
+
281
+ /** -1 until the first `announce` binds us to a leader. */
282
+ #epoch = -1;
283
+ /** Highest epoch ever seen (survives a leader gap for monotonic promotion). */
284
+ #maxEpochSeen = -1;
285
+ #leaderClientId = '';
286
+ #nextReqId = 1;
287
+ readonly #inFlight = new Map<number, InFlight>();
288
+ #queue: QueuedCall[] = [];
289
+ #closed = false;
290
+ /** Resolvers waiting for the first `announce` to bind a leader. */
291
+ #bindWaiters: Array<() => void> = [];
292
+
293
+ constructor(options: {
294
+ channel: CrossTabChannel;
295
+ fromId: string;
296
+ onEvent: (event: SyncWorkerEvent) => void;
297
+ onLeaderChange: (clientId: string) => void;
298
+ callTimeoutMs?: number;
299
+ queueLimit?: number;
300
+ }) {
301
+ this.#channel = options.channel;
302
+ this.#fromId = options.fromId;
303
+ this.#onEvent = options.onEvent;
304
+ this.#onLeaderChange = options.onLeaderChange;
305
+ this.#callTimeoutMs =
306
+ options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
307
+ this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
308
+ this.#onMessage = (event) => this.#handle(event.data);
309
+ this.#channel.addEventListener('message', this.#onMessage);
310
+ // Ask the current leader to announce itself.
311
+ this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
312
+ }
313
+
314
+ get epoch(): number {
315
+ return this.#epoch;
316
+ }
317
+
318
+ get maxEpochSeen(): number {
319
+ return this.#maxEpochSeen;
320
+ }
321
+
322
+ get leaderClientId(): string {
323
+ return this.#leaderClientId;
324
+ }
325
+
326
+ /** Whether a leader is currently bound (an announce has been heard). */
327
+ get bound(): boolean {
328
+ return this.#epoch >= 0;
329
+ }
330
+
331
+ /**
332
+ * Resolve once the link is bound to a leader (the first `announce` arrived),
333
+ * or reject if `timeoutMs` elapses first. A caller awaits this before
334
+ * treating the follower as ready: until an announce is processed the link's
335
+ * epoch is -1, so any fanned-out `event` in that window is dropped (an
336
+ * unbound follower cannot match the leader's epoch). Awaiting binding closes
337
+ * that gap — a just-opened follower tab never misses an invalidation emitted
338
+ * between its `hello` and the leader's `announce`. Resolves synchronously
339
+ * when already bound.
340
+ */
341
+ waitUntilBound(timeoutMs = this.#callTimeoutMs): Promise<void> {
342
+ if (this.#epoch >= 0) return Promise.resolve();
343
+ if (this.#closed) {
344
+ return Promise.reject(
345
+ new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'),
346
+ );
347
+ }
348
+ return new Promise((resolve, reject) => {
349
+ const timer = setTimeout(() => {
350
+ this.#dropBindWaiter(settle);
351
+ reject(
352
+ new ClientSyncError(
353
+ FOLLOWER_TIMEOUT_CODE,
354
+ 'no leader announced within the follower bind timeout',
355
+ ),
356
+ );
357
+ }, timeoutMs);
358
+ const settle = (): void => {
359
+ clearTimeout(timer);
360
+ resolve();
361
+ };
362
+ this.#bindWaiters.push(settle);
363
+ });
364
+ }
365
+
366
+ #dropBindWaiter(waiter: () => void): void {
367
+ const index = this.#bindWaiters.indexOf(waiter);
368
+ if (index >= 0) this.#bindWaiters.splice(index, 1);
369
+ }
370
+
371
+ #resolveBindWaiters(): void {
372
+ if (this.#bindWaiters.length === 0) return;
373
+ const waiters = this.#bindWaiters;
374
+ this.#bindWaiters = [];
375
+ for (const settle of waiters) settle();
376
+ }
377
+
378
+ /** Forward one logical API call to the leader (queued if unbound). */
379
+ call(method: string, args: readonly unknown[]): Promise<unknown> {
380
+ if (this.#closed) {
381
+ return Promise.reject(
382
+ new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'),
383
+ );
384
+ }
385
+ return new Promise((resolve, reject) => {
386
+ const queued: QueuedCall = {
387
+ method,
388
+ args,
389
+ resolve,
390
+ reject,
391
+ timer: undefined,
392
+ };
393
+ if (this.#epoch < 0) {
394
+ // No leader bound yet — queue with a deadline so we never hang.
395
+ if (this.#queue.length >= this.#queueLimit) {
396
+ reject(
397
+ new ClientSyncError(
398
+ FOLLOWER_TIMEOUT_CODE,
399
+ 'follower call queue overflow while awaiting a leader',
400
+ ),
401
+ );
402
+ return;
403
+ }
404
+ queued.timer = setTimeout(() => {
405
+ this.#dropQueued(queued);
406
+ reject(
407
+ new ClientSyncError(
408
+ FOLLOWER_TIMEOUT_CODE,
409
+ 'no leader answered within the follower call timeout',
410
+ ),
411
+ );
412
+ }, this.#callTimeoutMs);
413
+ this.#queue.push(queued);
414
+ return;
415
+ }
416
+ this.#send(queued);
417
+ });
418
+ }
419
+
420
+ #send(queued: QueuedCall): void {
421
+ const reqId = this.#nextReqId++;
422
+ const inflight: InFlight = {
423
+ resolve: queued.resolve,
424
+ reject: queued.reject,
425
+ timer: setTimeout(() => {
426
+ this.#inFlight.delete(reqId);
427
+ queued.reject(
428
+ new ClientSyncError(
429
+ FOLLOWER_TIMEOUT_CODE,
430
+ 'the leader did not answer within the follower call timeout',
431
+ ),
432
+ );
433
+ }, this.#callTimeoutMs),
434
+ };
435
+ this.#inFlight.set(reqId, inflight);
436
+ this.#channel.postMessage({
437
+ t: 'req',
438
+ epoch: this.#epoch,
439
+ fromId: this.#fromId,
440
+ reqId,
441
+ method: queued.method,
442
+ args: queued.args,
443
+ });
444
+ }
445
+
446
+ #dropQueued(queued: QueuedCall): void {
447
+ const index = this.#queue.indexOf(queued);
448
+ if (index >= 0) this.#queue.splice(index, 1);
449
+ }
450
+
451
+ #handle(message: MultiTabMessage): void {
452
+ if (this.#closed) return;
453
+ if (message.t === 'announce') {
454
+ if (message.epoch > this.#maxEpochSeen) {
455
+ this.#maxEpochSeen = message.epoch;
456
+ }
457
+ // Ignore a stale announce (older than the leader we already track).
458
+ if (message.epoch < this.#epoch) return;
459
+ const changed =
460
+ message.epoch !== this.#epoch ||
461
+ message.clientId !== this.#leaderClientId;
462
+ this.#epoch = message.epoch;
463
+ this.#leaderClientId = message.clientId;
464
+ if (changed) this.#onLeaderChange(message.clientId);
465
+ this.#resolveBindWaiters();
466
+ this.#flushQueue();
467
+ return;
468
+ }
469
+ if (message.t === 'res') {
470
+ // Discard a reply for a stale epoch — the leader that produced it is
471
+ // gone; the request (if still pending) is being retried elsewhere.
472
+ if (message.epoch !== this.#epoch) return;
473
+ const inflight = this.#inFlight.get(message.reqId);
474
+ if (inflight === undefined) return;
475
+ this.#inFlight.delete(message.reqId);
476
+ if (inflight.timer !== undefined) clearTimeout(inflight.timer);
477
+ if (message.ok) {
478
+ inflight.resolve(message.value);
479
+ } else {
480
+ const err = message.error;
481
+ inflight.reject(
482
+ new ClientSyncError(
483
+ err?.code ?? WORKER_FAILED_CODE,
484
+ err?.message ?? 'follower request failed',
485
+ err?.retryable ?? false,
486
+ ),
487
+ );
488
+ }
489
+ return;
490
+ }
491
+ if (message.t === 'event') {
492
+ if (message.epoch !== this.#epoch) return;
493
+ this.#onEvent(message.event);
494
+ return;
495
+ }
496
+ }
497
+
498
+ /** A new leader bound: re-send everything that was waiting for one. */
499
+ #flushQueue(): void {
500
+ const pending = this.#queue;
501
+ this.#queue = [];
502
+ for (const queued of pending) {
503
+ if (queued.timer !== undefined) clearTimeout(queued.timer);
504
+ queued.timer = undefined;
505
+ this.#send(queued);
506
+ }
507
+ }
508
+
509
+ /**
510
+ * The leader we were bound to went away (its lock released). Un-bind so new
511
+ * calls queue again; re-request an announce so the next leader finds us.
512
+ * In-flight calls stay pending — their per-call timeout is the backstop,
513
+ * and the winner's announce will flush any that were queued.
514
+ */
515
+ unbind(): void {
516
+ if (this.#closed) return;
517
+ this.#epoch = -1;
518
+ this.#leaderClientId = '';
519
+ this.#channel.postMessage({
520
+ t: 'hello',
521
+ fromId: this.#fromId,
522
+ epoch: this.#maxEpochSeen,
523
+ });
524
+ }
525
+
526
+ close(): void {
527
+ if (this.#closed) return;
528
+ this.#closed = true;
529
+ this.#channel.removeEventListener('message', this.#onMessage);
530
+ this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
531
+ const closedError = new ClientSyncError(
532
+ WORKER_FAILED_CODE,
533
+ 'the follower link was closed',
534
+ );
535
+ for (const inflight of this.#inFlight.values()) {
536
+ if (inflight.timer !== undefined) clearTimeout(inflight.timer);
537
+ inflight.reject(closedError);
538
+ }
539
+ this.#inFlight.clear();
540
+ for (const queued of this.#queue) {
541
+ if (queued.timer !== undefined) clearTimeout(queued.timer);
542
+ queued.reject(closedError);
543
+ }
544
+ this.#queue = [];
545
+ // Bind waiters carry their own timeout timers; resolving them here lets an
546
+ // awaiting boot path settle promptly instead of hanging until that timeout.
547
+ this.#resolveBindWaiters();
548
+ this.#channel.close();
549
+ }
550
+ }