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

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,472 @@
1
+ //#region src/services/tabs/tabs-broker-worker.ts
2
+ const PING_INTERVAL_MS = 5e3;
3
+ const PONG_TIMEOUT_MS = 15e3;
4
+ /** How long a tab has to answer `become-leader` with `leader-ready`. A tab
5
+ * that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
6
+ * and keeps answering pings, so nothing else here would notice: the
7
+ * namespace would keep a leader that is not `ready`, mint no follower ports,
8
+ * and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
9
+ * until every tab was reloaded. Treat the silence as a failed promotion. */
10
+ const PROMOTION_TIMEOUT_MS = 2e4;
11
+ const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
12
+ const LEADER_FAILURE_BACKOFF_MS = 1e3;
13
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
14
+ const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
15
+ const namespaces = /* @__PURE__ */ new Map();
16
+ /** Reverse index: which namespace a port belongs to (for pong routing). */
17
+ const portTab = /* @__PURE__ */ new Map();
18
+ /** The FIRST fingerprint wins for the whole broker instance; a tab from a
19
+ * different build gets `unsupported` and runs solo. Bucket ids may differ. */
20
+ let canonicalFingerprint = null;
21
+ let pingTimer = null;
22
+ function post(port, msg, transfer) {
23
+ try {
24
+ if (transfer) port.postMessage(msg, transfer);
25
+ else port.postMessage(msg);
26
+ } catch {}
27
+ }
28
+ function nsKey(fingerprint, bucketId) {
29
+ return `${fingerprint}::${bucketId}`;
30
+ }
31
+ function getNamespace(fingerprint, bucketId) {
32
+ const key = nsKey(fingerprint, bucketId);
33
+ let ns = namespaces.get(key);
34
+ if (!ns) {
35
+ ns = {
36
+ key,
37
+ fingerprint,
38
+ bucketId,
39
+ tabs: /* @__PURE__ */ new Map(),
40
+ leader: null,
41
+ failedUntil: /* @__PURE__ */ new Map(),
42
+ failedCycles: 0,
43
+ electing: false,
44
+ attachRetry: /* @__PURE__ */ new Map(),
45
+ tabLockMonitor: null,
46
+ promotionTimer: null
47
+ };
48
+ namespaces.set(key, ns);
49
+ }
50
+ return ns;
51
+ }
52
+ let nextLeadershipId = 0;
53
+ /** Duplicated from protocol.ts selectLeaderCandidate; keep in sync. */
54
+ function pickCandidate(ns) {
55
+ const now = Date.now();
56
+ const eligible = [...ns.tabs.values()].filter((t) => {
57
+ if ((ns.failedUntil.get(t.tabId) ?? 0) > now) return false;
58
+ ns.failedUntil.delete(t.tabId);
59
+ return true;
60
+ });
61
+ if (eligible.length === 0) return null;
62
+ const holding = eligible.filter((t) => t.heldLeadership !== null);
63
+ const base = holding.length > 0 ? holding : eligible;
64
+ const visible = base.filter((t) => t.visibility === "visible");
65
+ return (visible.length > 0 ? visible : base).reduce((best, t) => {
66
+ if (t.lastVisibleAt !== best.lastVisibleAt) return t.lastVisibleAt > best.lastVisibleAt ? t : best;
67
+ return t.tabId > best.tabId ? t : best;
68
+ });
69
+ }
70
+ function clearLeader(ns, opts) {
71
+ const leader = ns.leader;
72
+ if (!leader) return null;
73
+ ns.leader = null;
74
+ clearPromotionTimer(ns);
75
+ ns.tabLockMonitor?.abort();
76
+ ns.tabLockMonitor = null;
77
+ const tab = ns.tabs.get(leader.tabId);
78
+ const workerLock = tab?.heldLeadership?.workerLockName ?? null;
79
+ if (tab && opts.demote) post(tab.port, {
80
+ type: "demote",
81
+ brokerInstanceId,
82
+ leadershipId: leader.leadershipId
83
+ });
84
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
85
+ for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
86
+ type: "close-follower-ports",
87
+ brokerInstanceId,
88
+ leadershipId: leader.leadershipId
89
+ });
90
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
91
+ ns.attachRetry.clear();
92
+ return {
93
+ tabId: leader.tabId,
94
+ leadershipId: leader.leadershipId,
95
+ workerLock
96
+ };
97
+ }
98
+ function clearPromotionTimer(ns) {
99
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
100
+ ns.promotionTimer = null;
101
+ }
102
+ /**
103
+ * A promotion did not work out: back the tab off, clear the leadership, and
104
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
105
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
106
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
107
+ * owning it forever.
108
+ *
109
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
110
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
111
+ * the backoff interval forever, with allowMemoryFallback never granted, so
112
+ * every tab timed out in start() and booted solo - each one then contending
113
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
114
+ * stop.
115
+ */
116
+ function failPromotion(ns, tabId, leadershipId, opts) {
117
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
118
+ ns.failedCycles += 1;
119
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
120
+ const previous = clearLeader(ns, {
121
+ demote: false,
122
+ removeTab: false
123
+ });
124
+ const tab = ns.tabs.get(tabId);
125
+ if (tab) tab.heldLeadership = null;
126
+ electIfNeeded(ns, opts.timedOut && previous ? {
127
+ ...previous,
128
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`
129
+ } : previous);
130
+ }
131
+ /**
132
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
133
+ * Locks release the instant their holder's tab dies, so being GRANTED means
134
+ * the leader is gone; release immediately (return from the callback) and
135
+ * re-elect. The leadershipId guard drops stale grants after a normal handoff.
136
+ */
137
+ function startTabLockMonitor(ns, leadershipId) {
138
+ const locks = navigator.locks;
139
+ if (!locks) return;
140
+ ns.tabLockMonitor?.abort();
141
+ const controller = new AbortController();
142
+ ns.tabLockMonitor = controller;
143
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
144
+ locks.request(name, {
145
+ mode: "exclusive",
146
+ signal: controller.signal
147
+ }, () => {
148
+ if (ns.leader?.leadershipId !== leadershipId) return;
149
+ electIfNeeded(ns, clearLeader(ns, {
150
+ demote: true,
151
+ removeTab: true
152
+ }));
153
+ }).catch(() => {});
154
+ }
155
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
156
+ async function lockIsFree(name) {
157
+ const locks = navigator.locks;
158
+ if (!locks) return true;
159
+ try {
160
+ return await locks.request(name, {
161
+ mode: "exclusive",
162
+ ifAvailable: true
163
+ }, (lock) => Promise.resolve(lock !== null));
164
+ } catch {
165
+ return true;
166
+ }
167
+ }
168
+ async function stealLock(name) {
169
+ const locks = navigator.locks;
170
+ if (!locks) return;
171
+ try {
172
+ await locks.request(name, {
173
+ mode: "exclusive",
174
+ steal: true
175
+ }, () => Promise.resolve());
176
+ } catch {}
177
+ }
178
+ /**
179
+ * Before promoting a replacement, give the previous leader's locks a chance to
180
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
181
+ * whether a steal was needed, which the new leader passes to its worker open.
182
+ */
183
+ async function waitForPreviousLeaderLocks(previous) {
184
+ if (!previous?.workerLock) return false;
185
+ if (await lockIsFree(previous.workerLock)) return false;
186
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
187
+ if (await lockIsFree(previous.workerLock)) return false;
188
+ await stealLock(previous.workerLock);
189
+ return true;
190
+ }
191
+ function electIfNeeded(ns, previous = null) {
192
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
193
+ ns.electing = true;
194
+ (async () => {
195
+ try {
196
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
197
+ if (ns.leader || ns.tabs.size === 0) return;
198
+ const candidate = pickCandidate(ns);
199
+ if (!candidate) {
200
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
201
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
202
+ return;
203
+ }
204
+ const resumeHeld = candidate.heldLeadership !== null;
205
+ const leadershipId = ++nextLeadershipId;
206
+ ns.leader = {
207
+ tabId: candidate.tabId,
208
+ leadershipId,
209
+ ready: false
210
+ };
211
+ post(candidate.port, {
212
+ type: "become-leader",
213
+ brokerInstanceId,
214
+ leadershipId,
215
+ forceTakeover,
216
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
217
+ resumeHeld
218
+ });
219
+ clearPromotionTimer(ns);
220
+ ns.promotionTimer = setTimeout(() => {
221
+ ns.promotionTimer = null;
222
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
223
+ }, PROMOTION_TIMEOUT_MS);
224
+ } finally {
225
+ ns.electing = false;
226
+ }
227
+ })();
228
+ }
229
+ function assignFollowerPorts(ns) {
230
+ const leader = ns.leader;
231
+ if (!leader || !leader.ready) return;
232
+ const leaderTab = ns.tabs.get(leader.tabId);
233
+ if (!leaderTab) return;
234
+ for (const tab of ns.tabs.values()) {
235
+ if (tab.tabId === leader.tabId) continue;
236
+ if (ns.attachRetry.has(tab.tabId)) continue;
237
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
238
+ }
239
+ }
240
+ function mintPorts(ns, leaderTab, follower, leadershipId) {
241
+ const db = new MessageChannel();
242
+ const sync = new MessageChannel();
243
+ post(leaderTab.port, {
244
+ type: "attach-follower-ports",
245
+ brokerInstanceId,
246
+ leadershipId,
247
+ followerTabId: follower.tabId
248
+ }, [db.port1, sync.port1]);
249
+ post(follower.port, {
250
+ type: "use-follower-ports",
251
+ brokerInstanceId,
252
+ leadershipId,
253
+ leaderTabId: leaderTab.tabId
254
+ }, [db.port2, sync.port2]);
255
+ const state = ns.attachRetry.get(follower.tabId) ?? {
256
+ count: 0,
257
+ timer: null
258
+ };
259
+ const delay = Math.min(1e3 * 2 ** state.count, 3e4);
260
+ state.count += 1;
261
+ state.timer = setTimeout(() => {
262
+ const cur = ns.attachRetry.get(follower.tabId);
263
+ if (!cur) return;
264
+ cur.timer = null;
265
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
266
+ const stillHere = ns.tabs.get(follower.tabId);
267
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader.tabId) : void 0;
268
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
269
+ else ns.attachRetry.delete(follower.tabId);
270
+ }, delay);
271
+ ns.attachRetry.set(follower.tabId, state);
272
+ }
273
+ function followerAttached(ns, followerTabId) {
274
+ const retry = ns.attachRetry.get(followerTabId);
275
+ if (retry?.timer) clearTimeout(retry.timer);
276
+ ns.attachRetry.delete(followerTabId);
277
+ }
278
+ function ensurePingTimer() {
279
+ if (pingTimer) return;
280
+ pingTimer = setInterval(() => {
281
+ const now = Date.now();
282
+ for (const ns of namespaces.values()) for (const tab of [...ns.tabs.values()]) {
283
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
284
+ evictTab(ns, tab.tabId, "missed pongs");
285
+ continue;
286
+ }
287
+ post(tab.port, {
288
+ type: "ping",
289
+ brokerInstanceId
290
+ });
291
+ }
292
+ stopPingTimerIfIdle();
293
+ }, PING_INTERVAL_MS);
294
+ }
295
+ function stopPingTimerIfIdle() {
296
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
297
+ clearInterval(pingTimer);
298
+ pingTimer = null;
299
+ namespaces.clear();
300
+ canonicalFingerprint = null;
301
+ }
302
+ }
303
+ function removeTab(ns, tabId, opts) {
304
+ const tab = ns.tabs.get(tabId);
305
+ if (!tab) return;
306
+ ns.tabs.delete(tabId);
307
+ portTab.delete(tab.port);
308
+ if (opts.closePort !== false) try {
309
+ tab.port.close();
310
+ } catch {}
311
+ const retry = ns.attachRetry.get(tabId);
312
+ if (retry?.timer) clearTimeout(retry.timer);
313
+ ns.attachRetry.delete(tabId);
314
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
315
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
316
+ if (leaderTab) post(leaderTab.port, {
317
+ type: "close-follower-ports",
318
+ brokerInstanceId,
319
+ leadershipId: ns.leader.leadershipId
320
+ });
321
+ }
322
+ }
323
+ function evictTab(ns, tabId, reason, opts = {}) {
324
+ const wasLeader = ns.leader?.tabId === tabId;
325
+ const previous = wasLeader ? clearLeader(ns, {
326
+ demote: true,
327
+ removeTab: false
328
+ }) : null;
329
+ removeTab(ns, tabId, {
330
+ notifyLeader: !wasLeader,
331
+ closePort: opts.closePort
332
+ });
333
+ if (wasLeader) electIfNeeded(ns, previous);
334
+ stopPingTimerIfIdle();
335
+ }
336
+ function handleTabMessage(port, msg, ports) {
337
+ if (msg.type === "hello") {
338
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
339
+ if (msg.fingerprint !== canonicalFingerprint) {
340
+ post(port, {
341
+ type: "unsupported",
342
+ brokerInstanceId,
343
+ reason: "fingerprint-mismatch"
344
+ });
345
+ return;
346
+ }
347
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
348
+ for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
349
+ const tab = {
350
+ port,
351
+ tabId: msg.tabId,
352
+ fingerprint: msg.fingerprint,
353
+ bucketId: msg.bucketId,
354
+ visibility: msg.visibility,
355
+ lastVisibleAt: msg.visibility === "visible" ? Date.now() : 0,
356
+ lastPongAt: Date.now(),
357
+ heldLeadership: msg.heldLeadership
358
+ };
359
+ ns.tabs.set(msg.tabId, tab);
360
+ portTab.set(port, {
361
+ ns,
362
+ tabId: msg.tabId
363
+ });
364
+ ensurePingTimer();
365
+ post(port, {
366
+ type: "broker-hello",
367
+ brokerInstanceId
368
+ });
369
+ if (ns.leader?.ready) {
370
+ post(port, {
371
+ type: "leader-ready",
372
+ brokerInstanceId,
373
+ leadershipId: ns.leader.leadershipId,
374
+ leaderTabId: ns.leader.tabId
375
+ });
376
+ assignFollowerPorts(ns);
377
+ } else electIfNeeded(ns);
378
+ return;
379
+ }
380
+ const entry = portTab.get(port);
381
+ if (!entry) return;
382
+ const { ns } = entry;
383
+ switch (msg.type) {
384
+ case "pong": {
385
+ const tab = ns.tabs.get(msg.tabId);
386
+ if (tab) tab.lastPongAt = Date.now();
387
+ break;
388
+ }
389
+ case "visibility": {
390
+ const tab = ns.tabs.get(msg.tabId);
391
+ if (tab) {
392
+ tab.visibility = msg.visibility;
393
+ if (msg.visibility === "visible") tab.lastVisibleAt = Date.now();
394
+ tab.lastPongAt = Date.now();
395
+ }
396
+ break;
397
+ }
398
+ case "leader-ready": {
399
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
400
+ post(port, {
401
+ type: "demote",
402
+ brokerInstanceId,
403
+ leadershipId: msg.leadershipId
404
+ });
405
+ break;
406
+ }
407
+ ns.leader.ready = true;
408
+ ns.failedCycles = 0;
409
+ clearPromotionTimer(ns);
410
+ startTabLockMonitor(ns, msg.leadershipId);
411
+ const tab = ns.tabs.get(msg.tabId);
412
+ if (tab) tab.heldLeadership = {
413
+ leadershipId: msg.leadershipId,
414
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`
415
+ };
416
+ for (const t of ns.tabs.values()) post(t.port, {
417
+ type: "leader-ready",
418
+ brokerInstanceId,
419
+ leadershipId: msg.leadershipId,
420
+ leaderTabId: msg.tabId
421
+ });
422
+ assignFollowerPorts(ns);
423
+ break;
424
+ }
425
+ case "leader-failed":
426
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
427
+ break;
428
+ case "follower-port-attached":
429
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
430
+ break;
431
+ case "follower-port-closed": {
432
+ if (ns.leader?.tabId !== msg.tabId) break;
433
+ followerAttached(ns, msg.followerTabId);
434
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
435
+ const follower = ns.tabs.get(msg.followerTabId);
436
+ if (leaderTab && follower && ns.leader.ready) mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
437
+ break;
438
+ }
439
+ case "shutdown":
440
+ evictTab(ns, msg.tabId, "shutdown");
441
+ break;
442
+ }
443
+ }
444
+ /** Attach a connecting tab's port. Exported for the node test harness, which
445
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
446
+ function handleConnect(port) {
447
+ port.onmessage = (ev) => {
448
+ handleTabMessage(port, ev.data, ev.ports ?? []);
449
+ };
450
+ port.onmessageerror = () => {
451
+ const entry = portTab.get(port);
452
+ if (entry) evictTab(entry.ns, entry.tabId, "messageerror");
453
+ };
454
+ port.start?.();
455
+ }
456
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
457
+ function __resetBrokerForTests() {
458
+ if (pingTimer) clearInterval(pingTimer);
459
+ pingTimer = null;
460
+ for (const ns of namespaces.values()) for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
461
+ namespaces.clear();
462
+ portTab.clear();
463
+ canonicalFingerprint = null;
464
+ nextLeadershipId = 0;
465
+ }
466
+ if (typeof self !== "undefined") self.onconnect = (event) => {
467
+ const port = event.ports[0];
468
+ if (port) handleConnect(port);
469
+ };
470
+
471
+ //#endregion
472
+ export { __resetBrokerForTests, handleConnect };