@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201

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 (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -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 +35 -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/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -0,0 +1,434 @@
1
+ //#region src/services/tabs/tabs-broker-worker.ts
2
+ const PING_INTERVAL_MS = 5e3;
3
+ const PONG_TIMEOUT_MS = 15e3;
4
+ const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
5
+ const LEADER_FAILURE_BACKOFF_MS = 1e3;
6
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
7
+ const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
8
+ const namespaces = /* @__PURE__ */ new Map();
9
+ /** Reverse index: which namespace a port belongs to (for pong routing). */
10
+ const portTab = /* @__PURE__ */ new Map();
11
+ /** The FIRST fingerprint wins for the whole broker instance; a tab from a
12
+ * different build gets `unsupported` and runs solo. Bucket ids may differ. */
13
+ let canonicalFingerprint = null;
14
+ let pingTimer = null;
15
+ function post(port, msg, transfer) {
16
+ try {
17
+ if (transfer) port.postMessage(msg, transfer);
18
+ else port.postMessage(msg);
19
+ } catch {}
20
+ }
21
+ function nsKey(fingerprint, bucketId) {
22
+ return `${fingerprint}::${bucketId}`;
23
+ }
24
+ function getNamespace(fingerprint, bucketId) {
25
+ const key = nsKey(fingerprint, bucketId);
26
+ let ns = namespaces.get(key);
27
+ if (!ns) {
28
+ ns = {
29
+ key,
30
+ fingerprint,
31
+ bucketId,
32
+ tabs: /* @__PURE__ */ new Map(),
33
+ leader: null,
34
+ failedUntil: /* @__PURE__ */ new Map(),
35
+ failedCycles: 0,
36
+ electing: false,
37
+ attachRetry: /* @__PURE__ */ new Map(),
38
+ tabLockMonitor: null
39
+ };
40
+ namespaces.set(key, ns);
41
+ }
42
+ return ns;
43
+ }
44
+ let nextLeadershipId = 0;
45
+ /** Duplicated from protocol.ts selectLeaderCandidate; keep in sync. */
46
+ function pickCandidate(ns) {
47
+ const now = Date.now();
48
+ const eligible = [...ns.tabs.values()].filter((t) => {
49
+ if ((ns.failedUntil.get(t.tabId) ?? 0) > now) return false;
50
+ ns.failedUntil.delete(t.tabId);
51
+ return true;
52
+ });
53
+ if (eligible.length === 0) return null;
54
+ const holding = eligible.filter((t) => t.heldLeadership !== null);
55
+ const base = holding.length > 0 ? holding : eligible;
56
+ const visible = base.filter((t) => t.visibility === "visible");
57
+ return (visible.length > 0 ? visible : base).reduce((best, t) => {
58
+ if (t.lastVisibleAt !== best.lastVisibleAt) return t.lastVisibleAt > best.lastVisibleAt ? t : best;
59
+ return t.tabId > best.tabId ? t : best;
60
+ });
61
+ }
62
+ function clearLeader(ns, opts) {
63
+ const leader = ns.leader;
64
+ if (!leader) return null;
65
+ ns.leader = null;
66
+ ns.tabLockMonitor?.abort();
67
+ ns.tabLockMonitor = null;
68
+ const tab = ns.tabs.get(leader.tabId);
69
+ const workerLock = tab?.heldLeadership?.workerLockName ?? null;
70
+ if (tab && opts.demote) post(tab.port, {
71
+ type: "demote",
72
+ brokerInstanceId,
73
+ leadershipId: leader.leadershipId
74
+ });
75
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
76
+ for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
77
+ type: "close-follower-ports",
78
+ brokerInstanceId,
79
+ leadershipId: leader.leadershipId
80
+ });
81
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
82
+ ns.attachRetry.clear();
83
+ return {
84
+ tabId: leader.tabId,
85
+ leadershipId: leader.leadershipId,
86
+ workerLock
87
+ };
88
+ }
89
+ /**
90
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
91
+ * Locks release the instant their holder's tab dies, so being GRANTED means
92
+ * the leader is gone; release immediately (return from the callback) and
93
+ * re-elect. The leadershipId guard drops stale grants after a normal handoff.
94
+ */
95
+ function startTabLockMonitor(ns, leadershipId) {
96
+ const locks = navigator.locks;
97
+ if (!locks) return;
98
+ ns.tabLockMonitor?.abort();
99
+ const controller = new AbortController();
100
+ ns.tabLockMonitor = controller;
101
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
102
+ locks.request(name, {
103
+ mode: "exclusive",
104
+ signal: controller.signal
105
+ }, () => {
106
+ if (ns.leader?.leadershipId !== leadershipId) return;
107
+ electIfNeeded(ns, clearLeader(ns, {
108
+ demote: true,
109
+ removeTab: true
110
+ }));
111
+ }).catch(() => {});
112
+ }
113
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
114
+ async function lockIsFree(name) {
115
+ const locks = navigator.locks;
116
+ if (!locks) return true;
117
+ try {
118
+ return await locks.request(name, {
119
+ mode: "exclusive",
120
+ ifAvailable: true
121
+ }, (lock) => Promise.resolve(lock !== null));
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+ async function stealLock(name) {
127
+ const locks = navigator.locks;
128
+ if (!locks) return;
129
+ try {
130
+ await locks.request(name, {
131
+ mode: "exclusive",
132
+ steal: true
133
+ }, () => Promise.resolve());
134
+ } catch {}
135
+ }
136
+ /**
137
+ * Before promoting a replacement, give the previous leader's locks a chance to
138
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
139
+ * whether a steal was needed, which the new leader passes to its worker open.
140
+ */
141
+ async function waitForPreviousLeaderLocks(previous) {
142
+ if (!previous?.workerLock) return false;
143
+ if (await lockIsFree(previous.workerLock)) return false;
144
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
145
+ if (await lockIsFree(previous.workerLock)) return false;
146
+ await stealLock(previous.workerLock);
147
+ return true;
148
+ }
149
+ function electIfNeeded(ns, previous = null) {
150
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
151
+ ns.electing = true;
152
+ (async () => {
153
+ try {
154
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
155
+ if (ns.leader || ns.tabs.size === 0) return;
156
+ const candidate = pickCandidate(ns);
157
+ if (!candidate) {
158
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
159
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
160
+ return;
161
+ }
162
+ const resumeHeld = candidate.heldLeadership !== null;
163
+ const leadershipId = ++nextLeadershipId;
164
+ ns.leader = {
165
+ tabId: candidate.tabId,
166
+ leadershipId,
167
+ ready: false
168
+ };
169
+ post(candidate.port, {
170
+ type: "become-leader",
171
+ brokerInstanceId,
172
+ leadershipId,
173
+ forceTakeover,
174
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
175
+ resumeHeld
176
+ });
177
+ } finally {
178
+ ns.electing = false;
179
+ }
180
+ })();
181
+ }
182
+ function assignFollowerPorts(ns) {
183
+ const leader = ns.leader;
184
+ if (!leader || !leader.ready) return;
185
+ const leaderTab = ns.tabs.get(leader.tabId);
186
+ if (!leaderTab) return;
187
+ for (const tab of ns.tabs.values()) {
188
+ if (tab.tabId === leader.tabId) continue;
189
+ if (ns.attachRetry.has(tab.tabId)) continue;
190
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
191
+ }
192
+ }
193
+ function mintPorts(ns, leaderTab, follower, leadershipId) {
194
+ const db = new MessageChannel();
195
+ const sync = new MessageChannel();
196
+ post(leaderTab.port, {
197
+ type: "attach-follower-ports",
198
+ brokerInstanceId,
199
+ leadershipId,
200
+ followerTabId: follower.tabId
201
+ }, [db.port1, sync.port1]);
202
+ post(follower.port, {
203
+ type: "use-follower-ports",
204
+ brokerInstanceId,
205
+ leadershipId,
206
+ leaderTabId: leaderTab.tabId
207
+ }, [db.port2, sync.port2]);
208
+ const state = ns.attachRetry.get(follower.tabId) ?? {
209
+ count: 0,
210
+ timer: null
211
+ };
212
+ const delay = Math.min(1e3 * 2 ** state.count, 3e4);
213
+ state.count += 1;
214
+ state.timer = setTimeout(() => {
215
+ const cur = ns.attachRetry.get(follower.tabId);
216
+ if (!cur) return;
217
+ cur.timer = null;
218
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
219
+ const stillHere = ns.tabs.get(follower.tabId);
220
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader.tabId) : void 0;
221
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
222
+ else ns.attachRetry.delete(follower.tabId);
223
+ }, delay);
224
+ ns.attachRetry.set(follower.tabId, state);
225
+ }
226
+ function followerAttached(ns, followerTabId) {
227
+ const retry = ns.attachRetry.get(followerTabId);
228
+ if (retry?.timer) clearTimeout(retry.timer);
229
+ ns.attachRetry.delete(followerTabId);
230
+ }
231
+ function ensurePingTimer() {
232
+ if (pingTimer) return;
233
+ pingTimer = setInterval(() => {
234
+ const now = Date.now();
235
+ for (const ns of namespaces.values()) for (const tab of [...ns.tabs.values()]) {
236
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
237
+ evictTab(ns, tab.tabId, "missed pongs");
238
+ continue;
239
+ }
240
+ post(tab.port, {
241
+ type: "ping",
242
+ brokerInstanceId
243
+ });
244
+ }
245
+ stopPingTimerIfIdle();
246
+ }, PING_INTERVAL_MS);
247
+ }
248
+ function stopPingTimerIfIdle() {
249
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
250
+ clearInterval(pingTimer);
251
+ pingTimer = null;
252
+ namespaces.clear();
253
+ canonicalFingerprint = null;
254
+ }
255
+ }
256
+ function removeTab(ns, tabId, opts) {
257
+ const tab = ns.tabs.get(tabId);
258
+ if (!tab) return;
259
+ ns.tabs.delete(tabId);
260
+ portTab.delete(tab.port);
261
+ if (opts.closePort !== false) try {
262
+ tab.port.close();
263
+ } catch {}
264
+ const retry = ns.attachRetry.get(tabId);
265
+ if (retry?.timer) clearTimeout(retry.timer);
266
+ ns.attachRetry.delete(tabId);
267
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
268
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
269
+ if (leaderTab) post(leaderTab.port, {
270
+ type: "close-follower-ports",
271
+ brokerInstanceId,
272
+ leadershipId: ns.leader.leadershipId
273
+ });
274
+ }
275
+ }
276
+ function evictTab(ns, tabId, reason, opts = {}) {
277
+ const wasLeader = ns.leader?.tabId === tabId;
278
+ const previous = wasLeader ? clearLeader(ns, {
279
+ demote: true,
280
+ removeTab: false
281
+ }) : null;
282
+ removeTab(ns, tabId, {
283
+ notifyLeader: !wasLeader,
284
+ closePort: opts.closePort
285
+ });
286
+ if (wasLeader) electIfNeeded(ns, previous);
287
+ stopPingTimerIfIdle();
288
+ }
289
+ function handleTabMessage(port, msg, ports) {
290
+ if (msg.type === "hello") {
291
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
292
+ if (msg.fingerprint !== canonicalFingerprint) {
293
+ post(port, {
294
+ type: "unsupported",
295
+ brokerInstanceId,
296
+ reason: "fingerprint-mismatch"
297
+ });
298
+ return;
299
+ }
300
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
301
+ for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
302
+ const tab = {
303
+ port,
304
+ tabId: msg.tabId,
305
+ fingerprint: msg.fingerprint,
306
+ bucketId: msg.bucketId,
307
+ visibility: msg.visibility,
308
+ lastVisibleAt: msg.visibility === "visible" ? Date.now() : 0,
309
+ lastPongAt: Date.now(),
310
+ heldLeadership: msg.heldLeadership
311
+ };
312
+ ns.tabs.set(msg.tabId, tab);
313
+ portTab.set(port, {
314
+ ns,
315
+ tabId: msg.tabId
316
+ });
317
+ ensurePingTimer();
318
+ post(port, {
319
+ type: "broker-hello",
320
+ brokerInstanceId
321
+ });
322
+ if (ns.leader?.ready) {
323
+ post(port, {
324
+ type: "leader-ready",
325
+ brokerInstanceId,
326
+ leadershipId: ns.leader.leadershipId,
327
+ leaderTabId: ns.leader.tabId
328
+ });
329
+ assignFollowerPorts(ns);
330
+ } else electIfNeeded(ns);
331
+ return;
332
+ }
333
+ const entry = portTab.get(port);
334
+ if (!entry) return;
335
+ const { ns } = entry;
336
+ switch (msg.type) {
337
+ case "pong": {
338
+ const tab = ns.tabs.get(msg.tabId);
339
+ if (tab) tab.lastPongAt = Date.now();
340
+ break;
341
+ }
342
+ case "visibility": {
343
+ const tab = ns.tabs.get(msg.tabId);
344
+ if (tab) {
345
+ tab.visibility = msg.visibility;
346
+ if (msg.visibility === "visible") tab.lastVisibleAt = Date.now();
347
+ tab.lastPongAt = Date.now();
348
+ }
349
+ break;
350
+ }
351
+ case "leader-ready": {
352
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
353
+ post(port, {
354
+ type: "demote",
355
+ brokerInstanceId,
356
+ leadershipId: msg.leadershipId
357
+ });
358
+ break;
359
+ }
360
+ ns.leader.ready = true;
361
+ ns.failedCycles = 0;
362
+ startTabLockMonitor(ns, msg.leadershipId);
363
+ const tab = ns.tabs.get(msg.tabId);
364
+ if (tab) tab.heldLeadership = {
365
+ leadershipId: msg.leadershipId,
366
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`
367
+ };
368
+ for (const t of ns.tabs.values()) post(t.port, {
369
+ type: "leader-ready",
370
+ brokerInstanceId,
371
+ leadershipId: msg.leadershipId,
372
+ leaderTabId: msg.tabId
373
+ });
374
+ assignFollowerPorts(ns);
375
+ break;
376
+ }
377
+ case "leader-failed": {
378
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
379
+ ns.failedCycles += 1;
380
+ ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
381
+ const previous = clearLeader(ns, {
382
+ demote: false,
383
+ removeTab: false
384
+ });
385
+ const tab = ns.tabs.get(msg.tabId);
386
+ if (tab) tab.heldLeadership = null;
387
+ electIfNeeded(ns, previous);
388
+ break;
389
+ }
390
+ case "follower-port-attached":
391
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
392
+ break;
393
+ case "follower-port-closed": {
394
+ if (ns.leader?.tabId !== msg.tabId) break;
395
+ followerAttached(ns, msg.followerTabId);
396
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
397
+ const follower = ns.tabs.get(msg.followerTabId);
398
+ if (leaderTab && follower && ns.leader.ready) mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
399
+ break;
400
+ }
401
+ case "shutdown":
402
+ evictTab(ns, msg.tabId, "shutdown");
403
+ break;
404
+ }
405
+ }
406
+ /** Attach a connecting tab's port. Exported for the node test harness, which
407
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
408
+ function handleConnect(port) {
409
+ port.onmessage = (ev) => {
410
+ handleTabMessage(port, ev.data, ev.ports ?? []);
411
+ };
412
+ port.onmessageerror = () => {
413
+ const entry = portTab.get(port);
414
+ if (entry) evictTab(entry.ns, entry.tabId, "messageerror");
415
+ };
416
+ port.start?.();
417
+ }
418
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
419
+ function __resetBrokerForTests() {
420
+ if (pingTimer) clearInterval(pingTimer);
421
+ pingTimer = null;
422
+ for (const ns of namespaces.values()) for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
423
+ namespaces.clear();
424
+ portTab.clear();
425
+ canonicalFingerprint = null;
426
+ nextLeadershipId = 0;
427
+ }
428
+ if (typeof self !== "undefined") self.onconnect = (event) => {
429
+ const port = event.ports[0];
430
+ if (port) handleConnect(port);
431
+ };
432
+
433
+ //#endregion
434
+ export { __resetBrokerForTests, handleConnect };