@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,640 @@
1
+ /// <reference lib="webworker" />
2
+ /**
3
+ * SharedWorker broker for shared-tabs mode: elects ONE leader tab per
4
+ * namespace (fingerprint + bucketId), mints the MessageChannel pairs that
5
+ * connect each follower tab to the leader, and monitors tab liveness. Control
6
+ * plane only; no application data ever flows through here.
7
+ *
8
+ * SELF-CONTAINED ON PURPOSE: bundlers cannot reliably trace
9
+ * `new SharedWorker(url)` module graphs, so this file has NO runtime imports
10
+ * (type-only imports erase at build time). The few constants shared with the
11
+ * tab side are duplicated from `protocol.ts`; keep them in sync. A build check
12
+ * (`scripts/check-broker-bundle.mjs`) fails the build if an import statement
13
+ * survives into the emitted file.
14
+ *
15
+ * Lifecycle notes:
16
+ * - `brokerInstanceId` identifies THIS in-memory instance. The browser may
17
+ * kill and restart a SharedWorker at any time; tabs detect the id change
18
+ * (or missed pings) and re-hello. A surviving leader re-announces its held
19
+ * leadership so it is re-promoted without reopening the OPFS pool.
20
+ * - Leadership ids are monotonic per instance. Web Lock names embed them
21
+ * (see protocol.ts workerLockName), so a steal permanently retires a name.
22
+ * - The broker never touches Web Locks for itself; it only asks TABS to
23
+ * acquire/steal, except for the availability probe in
24
+ * `waitForPreviousLeaderLocks` which acquires-and-releases to test freedom.
25
+ */
26
+ import type {
27
+ BrokerToTabMessage,
28
+ HeldLeadership,
29
+ TabToBrokerMessage,
30
+ TabVisibility,
31
+ } from './protocol';
32
+
33
+ // ---- constants duplicated from protocol.ts (keep in sync) -------------------
34
+ const PING_INTERVAL_MS = 5000;
35
+ const PONG_TIMEOUT_MS = 15_000;
36
+ /** How long a tab has to answer `become-leader` with `leader-ready`. A tab
37
+ * that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
38
+ * and keeps answering pings, so nothing else here would notice: the
39
+ * namespace would keep a leader that is not `ready`, mint no follower ports,
40
+ * and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
41
+ * until every tab was reloaded. Treat the silence as a failed promotion. */
42
+ const PROMOTION_TIMEOUT_MS = 20_000;
43
+ const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
44
+ const LEADER_FAILURE_BACKOFF_MS = 1000;
45
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
46
+
47
+ interface BrokerTab {
48
+ port: MessagePort;
49
+ tabId: string;
50
+ fingerprint: string;
51
+ bucketId: string;
52
+ visibility: TabVisibility;
53
+ lastVisibleAt: number;
54
+ lastPongAt: number;
55
+ heldLeadership: HeldLeadership | null;
56
+ }
57
+
58
+ interface Leader {
59
+ tabId: string;
60
+ leadershipId: number;
61
+ ready: boolean;
62
+ }
63
+
64
+ interface Namespace {
65
+ key: string;
66
+ fingerprint: string;
67
+ bucketId: string;
68
+ tabs: Map<string, BrokerTab>;
69
+ leader: Leader | null;
70
+ /** Tabs whose promotion recently failed: tabId -> retry-not-before. */
71
+ failedUntil: Map<string, number>;
72
+ /** Consecutive elections whose promotion failed, for any reason. */
73
+ failedCycles: number;
74
+ /** Single-flight guard for the async election path. */
75
+ electing: boolean;
76
+ /** Pending follower attach state: followerTabId -> retry bookkeeping. */
77
+ attachRetry: Map<string, { count: number; timer: ReturnType<typeof setTimeout> | null }>;
78
+ /** Aborts the queued Web Lock request watching the current leader's tab
79
+ * lock (granted = the leader tab died; locks release instantly on death,
80
+ * unlike the pong timeout). */
81
+ tabLockMonitor: AbortController | null;
82
+ /** Deadline for the pending `become-leader` to report `leader-ready`. */
83
+ promotionTimer: ReturnType<typeof setTimeout> | null;
84
+ }
85
+
86
+ const brokerInstanceId =
87
+ typeof crypto !== 'undefined' && crypto.randomUUID
88
+ ? crypto.randomUUID()
89
+ : `bk_${Math.random().toString(36).slice(2)}`;
90
+
91
+ const namespaces = new Map<string, Namespace>();
92
+ /** Reverse index: which namespace a port belongs to (for pong routing). */
93
+ const portTab = new Map<MessagePort, { ns: Namespace; tabId: string }>();
94
+ /** The FIRST fingerprint wins for the whole broker instance; a tab from a
95
+ * different build gets `unsupported` and runs solo. Bucket ids may differ. */
96
+ let canonicalFingerprint: string | null = null;
97
+
98
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
99
+
100
+ function post(port: MessagePort, msg: BrokerToTabMessage, transfer?: Transferable[]): void {
101
+ try {
102
+ if (transfer) port.postMessage(msg, transfer);
103
+ else port.postMessage(msg);
104
+ } catch {
105
+ /* dead port; eviction will catch up */
106
+ }
107
+ }
108
+
109
+ function nsKey(fingerprint: string, bucketId: string): string {
110
+ return `${fingerprint}::${bucketId}`;
111
+ }
112
+
113
+ function getNamespace(fingerprint: string, bucketId: string): Namespace {
114
+ const key = nsKey(fingerprint, bucketId);
115
+ let ns = namespaces.get(key);
116
+ if (!ns) {
117
+ ns = {
118
+ key,
119
+ fingerprint,
120
+ bucketId,
121
+ tabs: new Map(),
122
+ leader: null,
123
+ failedUntil: new Map(),
124
+ failedCycles: 0,
125
+ electing: false,
126
+ attachRetry: new Map(),
127
+ tabLockMonitor: null,
128
+ promotionTimer: null,
129
+ };
130
+ namespaces.set(key, ns);
131
+ }
132
+ return ns;
133
+ }
134
+
135
+ // Monotonic per broker instance (shared across namespaces; only monotonicity
136
+ // matters, not density).
137
+ let nextLeadershipId = 0;
138
+
139
+ // ---- election ----------------------------------------------------------------
140
+
141
+ /** Duplicated from protocol.ts selectLeaderCandidate; keep in sync. */
142
+ function pickCandidate(ns: Namespace): BrokerTab | null {
143
+ const now = Date.now();
144
+ const eligible = [...ns.tabs.values()].filter((t) => {
145
+ const until = ns.failedUntil.get(t.tabId) ?? 0;
146
+ if (until > now) return false;
147
+ ns.failedUntil.delete(t.tabId);
148
+ return true;
149
+ });
150
+ if (eligible.length === 0) return null;
151
+ // A tab that still holds a live fenced worker (broker restart, rehello)
152
+ // beats everyone: re-promoting it avoids reopening the OPFS pool at all.
153
+ const holding = eligible.filter((t) => t.heldLeadership !== null);
154
+ const base = holding.length > 0 ? holding : eligible;
155
+ const visible = base.filter((t) => t.visibility === 'visible');
156
+ const pool = visible.length > 0 ? visible : base;
157
+ return pool.reduce((best, t) => {
158
+ if (t.lastVisibleAt !== best.lastVisibleAt) {
159
+ return t.lastVisibleAt > best.lastVisibleAt ? t : best;
160
+ }
161
+ return t.tabId > best.tabId ? t : best;
162
+ });
163
+ }
164
+
165
+ interface ClearedLeader {
166
+ tabId: string;
167
+ leadershipId: number;
168
+ workerLock: string | null;
169
+ }
170
+
171
+ function clearLeader(
172
+ ns: Namespace,
173
+ opts: { demote: boolean; removeTab: boolean }
174
+ ): ClearedLeader | null {
175
+ const leader = ns.leader;
176
+ if (!leader) return null;
177
+ ns.leader = null;
178
+ clearPromotionTimer(ns);
179
+ ns.tabLockMonitor?.abort();
180
+ ns.tabLockMonitor = null;
181
+ const tab = ns.tabs.get(leader.tabId);
182
+ const workerLock = tab?.heldLeadership?.workerLockName ?? null;
183
+ if (tab && opts.demote) {
184
+ post(tab.port, { type: 'demote', brokerInstanceId, leadershipId: leader.leadershipId });
185
+ }
186
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
187
+ // Followers must drop their ports; new ones are minted after re-election.
188
+ for (const t of ns.tabs.values()) {
189
+ if (t.tabId !== leader.tabId) {
190
+ post(t.port, {
191
+ type: 'close-follower-ports',
192
+ brokerInstanceId,
193
+ leadershipId: leader.leadershipId,
194
+ });
195
+ }
196
+ }
197
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
198
+ ns.attachRetry.clear();
199
+ return { tabId: leader.tabId, leadershipId: leader.leadershipId, workerLock };
200
+ }
201
+
202
+ function clearPromotionTimer(ns: Namespace): void {
203
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
204
+ ns.promotionTimer = null;
205
+ }
206
+
207
+ /**
208
+ * A promotion did not work out: back the tab off, clear the leadership, and
209
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
210
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
211
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
212
+ * owning it forever.
213
+ *
214
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
215
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
216
+ * the backoff interval forever, with allowMemoryFallback never granted, so
217
+ * every tab timed out in start() and booted solo - each one then contending
218
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
219
+ * stop.
220
+ */
221
+ function failPromotion(
222
+ ns: Namespace,
223
+ tabId: string,
224
+ leadershipId: number,
225
+ opts: { timedOut: boolean }
226
+ ): void {
227
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
228
+ ns.failedCycles += 1;
229
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
230
+ const previous = clearLeader(ns, { demote: false, removeTab: false });
231
+ const tab = ns.tabs.get(tabId);
232
+ if (tab) tab.heldLeadership = null;
233
+ // A tab that reported `leader-failed` has already given back its tab lock and
234
+ // any worker it opened, so there is nothing for the next candidate to wait
235
+ // on. A tab that went silent may have opened the OPFS pool and never told us,
236
+ // so hand the next election the lock name it would be holding: the probe in
237
+ // `waitForPreviousLeaderLocks` returns immediately when it is free, and
238
+ // steals it when it is not. `heldLeadership` is deliberately NOT used for
239
+ // this - it means "confirmed leader" and drives the broker-restart resume.
240
+ const stranded =
241
+ opts.timedOut && previous
242
+ ? {
243
+ ...previous,
244
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`,
245
+ }
246
+ : previous;
247
+ electIfNeeded(ns, stranded);
248
+ }
249
+
250
+ /**
251
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
252
+ * Locks release the instant their holder's tab dies, so being GRANTED means
253
+ * the leader is gone; release immediately (return from the callback) and
254
+ * re-elect. The leadershipId guard drops stale grants after a normal handoff.
255
+ */
256
+ function startTabLockMonitor(ns: Namespace, leadershipId: number): void {
257
+ const locks = (navigator as { locks?: LockManager }).locks;
258
+ if (!locks) return;
259
+ ns.tabLockMonitor?.abort();
260
+ const controller = new AbortController();
261
+ ns.tabLockMonitor = controller;
262
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
263
+ locks
264
+ .request(name, { mode: 'exclusive', signal: controller.signal }, () => {
265
+ if (ns.leader?.leadershipId !== leadershipId) return;
266
+ const previous = clearLeader(ns, { demote: true, removeTab: true });
267
+ electIfNeeded(ns, previous);
268
+ })
269
+ .catch(() => {
270
+ /* aborted (normal handoff) or Web Locks hiccup; pong timeout covers it */
271
+ });
272
+ }
273
+
274
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
275
+ async function lockIsFree(name: string): Promise<boolean> {
276
+ const locks = (navigator as { locks?: LockManager }).locks;
277
+ if (!locks) return true;
278
+ try {
279
+ return await locks.request(name, { mode: 'exclusive', ifAvailable: true }, (lock) =>
280
+ Promise.resolve(lock !== null)
281
+ );
282
+ } catch {
283
+ return true;
284
+ }
285
+ }
286
+
287
+ async function stealLock(name: string): Promise<void> {
288
+ const locks = (navigator as { locks?: LockManager }).locks;
289
+ if (!locks) return;
290
+ try {
291
+ await locks.request(name, { mode: 'exclusive', steal: true }, () => Promise.resolve());
292
+ } catch {
293
+ /* best effort */
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Before promoting a replacement, give the previous leader's locks a chance to
299
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
300
+ * whether a steal was needed, which the new leader passes to its worker open.
301
+ */
302
+ async function waitForPreviousLeaderLocks(previous: ClearedLeader | null): Promise<boolean> {
303
+ if (!previous?.workerLock) return false;
304
+ if (await lockIsFree(previous.workerLock)) return false;
305
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
306
+ if (await lockIsFree(previous.workerLock)) return false;
307
+ await stealLock(previous.workerLock);
308
+ return true;
309
+ }
310
+
311
+ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): void {
312
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
313
+ ns.electing = true;
314
+ void (async () => {
315
+ try {
316
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
317
+ // State may have moved while we waited.
318
+ if (ns.leader || ns.tabs.size === 0) return;
319
+ const candidate = pickCandidate(ns);
320
+ if (!candidate) {
321
+ // Everyone is in failure backoff; retry when the earliest expires.
322
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
323
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
324
+ return;
325
+ }
326
+ // A tab that survived a broker restart and still holds its lock keeps
327
+ // its worker; it only rolls the lock name forward to the new id.
328
+ const resumeHeld = candidate.heldLeadership !== null;
329
+ const leadershipId = ++nextLeadershipId;
330
+ ns.leader = { tabId: candidate.tabId, leadershipId, ready: false };
331
+ post(candidate.port, {
332
+ type: 'become-leader',
333
+ brokerInstanceId,
334
+ leadershipId,
335
+ forceTakeover,
336
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
337
+ resumeHeld,
338
+ });
339
+ clearPromotionTimer(ns);
340
+ ns.promotionTimer = setTimeout(() => {
341
+ ns.promotionTimer = null;
342
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
343
+ }, PROMOTION_TIMEOUT_MS);
344
+ } finally {
345
+ ns.electing = false;
346
+ }
347
+ })();
348
+ }
349
+
350
+ // ---- follower attachment ------------------------------------------------------
351
+
352
+ function assignFollowerPorts(ns: Namespace): void {
353
+ const leader = ns.leader;
354
+ if (!leader || !leader.ready) return;
355
+ const leaderTab = ns.tabs.get(leader.tabId);
356
+ if (!leaderTab) return;
357
+ for (const tab of ns.tabs.values()) {
358
+ if (tab.tabId === leader.tabId) continue;
359
+ if (ns.attachRetry.has(tab.tabId)) continue;
360
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
361
+ }
362
+ }
363
+
364
+ function mintPorts(ns: Namespace, leaderTab: BrokerTab, follower: BrokerTab, leadershipId: number): void {
365
+ const db = new MessageChannel();
366
+ const sync = new MessageChannel();
367
+ post(
368
+ leaderTab.port,
369
+ {
370
+ type: 'attach-follower-ports',
371
+ brokerInstanceId,
372
+ leadershipId,
373
+ followerTabId: follower.tabId,
374
+ },
375
+ [db.port1, sync.port1]
376
+ );
377
+ post(
378
+ follower.port,
379
+ { type: 'use-follower-ports', brokerInstanceId, leadershipId, leaderTabId: leaderTab.tabId },
380
+ [db.port2, sync.port2]
381
+ );
382
+ // Re-mint with backoff until the leader confirms (or leadership changes).
383
+ // 1s doubling to 30s, duplicated from protocol constants.
384
+ const state = ns.attachRetry.get(follower.tabId) ?? { count: 0, timer: null };
385
+ const delay = Math.min(1000 * 2 ** state.count, 30_000);
386
+ state.count += 1;
387
+ state.timer = setTimeout(() => {
388
+ const cur = ns.attachRetry.get(follower.tabId);
389
+ if (!cur) return;
390
+ cur.timer = null;
391
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
392
+ const stillHere = ns.tabs.get(follower.tabId);
393
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader!.tabId) : undefined;
394
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
395
+ else ns.attachRetry.delete(follower.tabId);
396
+ }, delay);
397
+ ns.attachRetry.set(follower.tabId, state);
398
+ }
399
+
400
+ function followerAttached(ns: Namespace, followerTabId: string): void {
401
+ const retry = ns.attachRetry.get(followerTabId);
402
+ if (retry?.timer) clearTimeout(retry.timer);
403
+ ns.attachRetry.delete(followerTabId);
404
+ }
405
+
406
+ // ---- liveness -----------------------------------------------------------------
407
+
408
+ function ensurePingTimer(): void {
409
+ if (pingTimer) return;
410
+ pingTimer = setInterval(() => {
411
+ const now = Date.now();
412
+ for (const ns of namespaces.values()) {
413
+ for (const tab of [...ns.tabs.values()]) {
414
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
415
+ evictTab(ns, tab.tabId, 'missed pongs');
416
+ continue;
417
+ }
418
+ post(tab.port, { type: 'ping', brokerInstanceId });
419
+ }
420
+ }
421
+ stopPingTimerIfIdle();
422
+ }, PING_INTERVAL_MS);
423
+ }
424
+
425
+ function stopPingTimerIfIdle(): void {
426
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
427
+ clearInterval(pingTimer);
428
+ pingTimer = null;
429
+ namespaces.clear();
430
+ canonicalFingerprint = null;
431
+ }
432
+ }
433
+
434
+ function removeTab(
435
+ ns: Namespace,
436
+ tabId: string,
437
+ opts: { notifyLeader: boolean; closePort?: boolean }
438
+ ): void {
439
+ const tab = ns.tabs.get(tabId);
440
+ if (!tab) return;
441
+ ns.tabs.delete(tabId);
442
+ portTab.delete(tab.port);
443
+ // A re-hello (bucket switch, reconnect) re-homes the tab on the SAME port;
444
+ // closing it would sever the still-connected tab. Only a real departure
445
+ // (shutdown, eviction) closes.
446
+ if (opts.closePort !== false) {
447
+ try {
448
+ tab.port.close();
449
+ } catch {
450
+ /* ignore */
451
+ }
452
+ }
453
+ const retry = ns.attachRetry.get(tabId);
454
+ if (retry?.timer) clearTimeout(retry.timer);
455
+ ns.attachRetry.delete(tabId);
456
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
457
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
458
+ if (leaderTab) {
459
+ post(leaderTab.port, {
460
+ type: 'close-follower-ports',
461
+ brokerInstanceId,
462
+ leadershipId: ns.leader.leadershipId,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ function evictTab(
469
+ ns: Namespace,
470
+ tabId: string,
471
+ reason: string,
472
+ opts: { closePort?: boolean } = {}
473
+ ): void {
474
+ const wasLeader = ns.leader?.tabId === tabId;
475
+ const previous = wasLeader ? clearLeader(ns, { demote: true, removeTab: false }) : null;
476
+ removeTab(ns, tabId, { notifyLeader: !wasLeader, closePort: opts.closePort });
477
+ void reason;
478
+ if (wasLeader) electIfNeeded(ns, previous);
479
+ stopPingTimerIfIdle();
480
+ }
481
+
482
+ // ---- message handling -----------------------------------------------------------
483
+
484
+ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: readonly MessagePort[]): void {
485
+ void ports;
486
+ if (msg.type === 'hello') {
487
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
488
+ if (msg.fingerprint !== canonicalFingerprint) {
489
+ post(port, { type: 'unsupported', brokerInstanceId, reason: 'fingerprint-mismatch' });
490
+ return;
491
+ }
492
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
493
+ // Drop stale state for this tab EVERYWHERE first. A re-hello is either a
494
+ // reconnect (same namespace) or a bucket switch (leave old namespace,
495
+ // join the new one); in both cases exactly one entry may remain, and if
496
+ // the tab led its old namespace that namespace needs a new leader now,
497
+ // not after a 15s pong eviction.
498
+ for (const other of [...namespaces.values()]) {
499
+ if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, 'rehello', { closePort: false });
500
+ }
501
+ const tab: BrokerTab = {
502
+ port,
503
+ tabId: msg.tabId,
504
+ fingerprint: msg.fingerprint,
505
+ bucketId: msg.bucketId,
506
+ visibility: msg.visibility,
507
+ lastVisibleAt: msg.visibility === 'visible' ? Date.now() : 0,
508
+ lastPongAt: Date.now(),
509
+ heldLeadership: msg.heldLeadership,
510
+ };
511
+ ns.tabs.set(msg.tabId, tab);
512
+ portTab.set(port, { ns, tabId: msg.tabId });
513
+ ensurePingTimer();
514
+ post(port, { type: 'broker-hello', brokerInstanceId });
515
+ if (ns.leader?.ready) {
516
+ post(port, {
517
+ type: 'leader-ready',
518
+ brokerInstanceId,
519
+ leadershipId: ns.leader.leadershipId,
520
+ leaderTabId: ns.leader.tabId,
521
+ });
522
+ assignFollowerPorts(ns);
523
+ } else {
524
+ electIfNeeded(ns);
525
+ }
526
+ return;
527
+ }
528
+
529
+ const entry = portTab.get(port);
530
+ if (!entry) return;
531
+ const { ns } = entry;
532
+
533
+ switch (msg.type) {
534
+ case 'pong': {
535
+ const tab = ns.tabs.get(msg.tabId);
536
+ if (tab) tab.lastPongAt = Date.now();
537
+ break;
538
+ }
539
+ case 'visibility': {
540
+ const tab = ns.tabs.get(msg.tabId);
541
+ if (tab) {
542
+ tab.visibility = msg.visibility;
543
+ if (msg.visibility === 'visible') tab.lastVisibleAt = Date.now();
544
+ tab.lastPongAt = Date.now();
545
+ }
546
+ break;
547
+ }
548
+ case 'leader-ready': {
549
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
550
+ // Stale promotion (a newer election superseded it): demote it.
551
+ post(port, { type: 'demote', brokerInstanceId, leadershipId: msg.leadershipId });
552
+ break;
553
+ }
554
+ ns.leader.ready = true;
555
+ ns.failedCycles = 0;
556
+ clearPromotionTimer(ns);
557
+ startTabLockMonitor(ns, msg.leadershipId);
558
+ const tab = ns.tabs.get(msg.tabId);
559
+ if (tab) {
560
+ // The leader now holds the per-leadership worker lock; remember it so
561
+ // a broker restart (or its next takeover) can find it.
562
+ tab.heldLeadership = {
563
+ leadershipId: msg.leadershipId,
564
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`,
565
+ };
566
+ }
567
+ for (const t of ns.tabs.values()) {
568
+ post(t.port, {
569
+ type: 'leader-ready',
570
+ brokerInstanceId,
571
+ leadershipId: msg.leadershipId,
572
+ leaderTabId: msg.tabId,
573
+ });
574
+ }
575
+ assignFollowerPorts(ns);
576
+ break;
577
+ }
578
+ case 'leader-failed': {
579
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
580
+ break;
581
+ }
582
+ case 'follower-port-attached': {
583
+ // Leader confirmed the follower's ports are live: stop the re-mint loop.
584
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) {
585
+ followerAttached(ns, msg.followerTabId);
586
+ }
587
+ break;
588
+ }
589
+ case 'follower-port-closed': {
590
+ // Leader reports a follower's data port died: re-mint on the retry path.
591
+ if (ns.leader?.tabId !== msg.tabId) break;
592
+ followerAttached(ns, msg.followerTabId);
593
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
594
+ const follower = ns.tabs.get(msg.followerTabId);
595
+ if (leaderTab && follower && ns.leader.ready) {
596
+ mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
597
+ }
598
+ break;
599
+ }
600
+ case 'shutdown': {
601
+ evictTab(ns, msg.tabId, 'shutdown');
602
+ break;
603
+ }
604
+ }
605
+ }
606
+
607
+ /** Attach a connecting tab's port. Exported for the node test harness, which
608
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
609
+ export function handleConnect(port: MessagePort): void {
610
+ port.onmessage = (ev: MessageEvent) => {
611
+ handleTabMessage(port, ev.data as TabToBrokerMessage, (ev.ports ?? []) as MessagePort[]);
612
+ };
613
+ port.onmessageerror = () => {
614
+ const entry = portTab.get(port);
615
+ if (entry) evictTab(entry.ns, entry.tabId, 'messageerror');
616
+ };
617
+ port.start?.();
618
+ }
619
+
620
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
621
+ export function __resetBrokerForTests(): void {
622
+ if (pingTimer) clearInterval(pingTimer);
623
+ pingTimer = null;
624
+ for (const ns of namespaces.values()) {
625
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
626
+ }
627
+ namespaces.clear();
628
+ portTab.clear();
629
+ canonicalFingerprint = null;
630
+ nextLeadershipId = 0;
631
+ }
632
+
633
+ // `self` only exists in a real worker scope; the node test harness imports
634
+ // this module and calls handleConnect directly.
635
+ if (typeof self !== 'undefined') {
636
+ (self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => {
637
+ const port = event.ports[0];
638
+ if (port) handleConnect(port);
639
+ };
640
+ }