@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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 (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +80 -0
  36. package/src/boot/preload.saga.ts +40 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +167 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,477 @@
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, {
85
+ notifyLeader: false,
86
+ closePort: false
87
+ });
88
+ for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
89
+ type: "close-follower-ports",
90
+ brokerInstanceId,
91
+ leadershipId: leader.leadershipId
92
+ });
93
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
94
+ ns.attachRetry.clear();
95
+ return {
96
+ tabId: leader.tabId,
97
+ leadershipId: leader.leadershipId,
98
+ workerLock
99
+ };
100
+ }
101
+ function clearPromotionTimer(ns) {
102
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
103
+ ns.promotionTimer = null;
104
+ }
105
+ /**
106
+ * A promotion did not work out: back the tab off, clear the leadership, and
107
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
108
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
109
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
110
+ * owning it forever.
111
+ *
112
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
113
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
114
+ * the backoff interval forever, with allowMemoryFallback never granted, so
115
+ * every tab timed out in start() and booted solo - each one then contending
116
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
117
+ * stop.
118
+ */
119
+ function failPromotion(ns, tabId, leadershipId, opts) {
120
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
121
+ ns.failedCycles += 1;
122
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
123
+ const previous = clearLeader(ns, {
124
+ demote: false,
125
+ removeTab: false
126
+ });
127
+ const tab = ns.tabs.get(tabId);
128
+ if (tab) tab.heldLeadership = null;
129
+ electIfNeeded(ns, opts.timedOut && previous ? {
130
+ ...previous,
131
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`
132
+ } : previous);
133
+ }
134
+ /**
135
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
136
+ * Locks release the instant their holder's tab dies, so being GRANTED means
137
+ * the leader is gone from this bucket (dead, or moved to another bucket on
138
+ * sign-in/out); release immediately (return from the callback) and re-elect.
139
+ * The leadershipId guard drops stale grants after a normal handoff.
140
+ */
141
+ function startTabLockMonitor(ns, leadershipId) {
142
+ const locks = navigator.locks;
143
+ if (!locks) return;
144
+ ns.tabLockMonitor?.abort();
145
+ const controller = new AbortController();
146
+ ns.tabLockMonitor = controller;
147
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
148
+ locks.request(name, {
149
+ mode: "exclusive",
150
+ signal: controller.signal
151
+ }, () => {
152
+ if (ns.leader?.leadershipId !== leadershipId) return;
153
+ electIfNeeded(ns, clearLeader(ns, {
154
+ demote: true,
155
+ removeTab: true
156
+ }));
157
+ }).catch(() => {});
158
+ }
159
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
160
+ async function lockIsFree(name) {
161
+ const locks = navigator.locks;
162
+ if (!locks) return true;
163
+ try {
164
+ return await locks.request(name, {
165
+ mode: "exclusive",
166
+ ifAvailable: true
167
+ }, (lock) => Promise.resolve(lock !== null));
168
+ } catch {
169
+ return true;
170
+ }
171
+ }
172
+ async function stealLock(name) {
173
+ const locks = navigator.locks;
174
+ if (!locks) return;
175
+ try {
176
+ await locks.request(name, {
177
+ mode: "exclusive",
178
+ steal: true
179
+ }, () => Promise.resolve());
180
+ } catch {}
181
+ }
182
+ /**
183
+ * Before promoting a replacement, give the previous leader's locks a chance to
184
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
185
+ * whether a steal was needed, which the new leader passes to its worker open.
186
+ */
187
+ async function waitForPreviousLeaderLocks(previous) {
188
+ if (!previous?.workerLock) return false;
189
+ if (await lockIsFree(previous.workerLock)) return false;
190
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
191
+ if (await lockIsFree(previous.workerLock)) return false;
192
+ await stealLock(previous.workerLock);
193
+ return true;
194
+ }
195
+ function electIfNeeded(ns, previous = null) {
196
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
197
+ ns.electing = true;
198
+ (async () => {
199
+ try {
200
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
201
+ if (ns.leader || ns.tabs.size === 0) return;
202
+ const candidate = pickCandidate(ns);
203
+ if (!candidate) {
204
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
205
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
206
+ return;
207
+ }
208
+ const resumeHeld = candidate.heldLeadership !== null;
209
+ const leadershipId = ++nextLeadershipId;
210
+ ns.leader = {
211
+ tabId: candidate.tabId,
212
+ leadershipId,
213
+ ready: false
214
+ };
215
+ post(candidate.port, {
216
+ type: "become-leader",
217
+ brokerInstanceId,
218
+ leadershipId,
219
+ forceTakeover,
220
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
221
+ resumeHeld
222
+ });
223
+ clearPromotionTimer(ns);
224
+ ns.promotionTimer = setTimeout(() => {
225
+ ns.promotionTimer = null;
226
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
227
+ }, PROMOTION_TIMEOUT_MS);
228
+ } finally {
229
+ ns.electing = false;
230
+ }
231
+ })();
232
+ }
233
+ function assignFollowerPorts(ns) {
234
+ const leader = ns.leader;
235
+ if (!leader || !leader.ready) return;
236
+ const leaderTab = ns.tabs.get(leader.tabId);
237
+ if (!leaderTab) return;
238
+ for (const tab of ns.tabs.values()) {
239
+ if (tab.tabId === leader.tabId) continue;
240
+ if (ns.attachRetry.has(tab.tabId)) continue;
241
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
242
+ }
243
+ }
244
+ function mintPorts(ns, leaderTab, follower, leadershipId) {
245
+ const db = new MessageChannel();
246
+ const sync = new MessageChannel();
247
+ post(leaderTab.port, {
248
+ type: "attach-follower-ports",
249
+ brokerInstanceId,
250
+ leadershipId,
251
+ followerTabId: follower.tabId
252
+ }, [db.port1, sync.port1]);
253
+ post(follower.port, {
254
+ type: "use-follower-ports",
255
+ brokerInstanceId,
256
+ leadershipId,
257
+ leaderTabId: leaderTab.tabId
258
+ }, [db.port2, sync.port2]);
259
+ const state = ns.attachRetry.get(follower.tabId) ?? {
260
+ count: 0,
261
+ timer: null
262
+ };
263
+ const delay = Math.min(1e3 * 2 ** state.count, 3e4);
264
+ state.count += 1;
265
+ state.timer = setTimeout(() => {
266
+ const cur = ns.attachRetry.get(follower.tabId);
267
+ if (!cur) return;
268
+ cur.timer = null;
269
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
270
+ const stillHere = ns.tabs.get(follower.tabId);
271
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader.tabId) : void 0;
272
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
273
+ else ns.attachRetry.delete(follower.tabId);
274
+ }, delay);
275
+ ns.attachRetry.set(follower.tabId, state);
276
+ }
277
+ function followerAttached(ns, followerTabId) {
278
+ const retry = ns.attachRetry.get(followerTabId);
279
+ if (retry?.timer) clearTimeout(retry.timer);
280
+ ns.attachRetry.delete(followerTabId);
281
+ }
282
+ function ensurePingTimer() {
283
+ if (pingTimer) return;
284
+ pingTimer = setInterval(() => {
285
+ const now = Date.now();
286
+ for (const ns of namespaces.values()) for (const tab of [...ns.tabs.values()]) {
287
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
288
+ evictTab(ns, tab.tabId, "missed pongs");
289
+ continue;
290
+ }
291
+ post(tab.port, {
292
+ type: "ping",
293
+ brokerInstanceId
294
+ });
295
+ }
296
+ stopPingTimerIfIdle();
297
+ }, PING_INTERVAL_MS);
298
+ }
299
+ function stopPingTimerIfIdle() {
300
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
301
+ clearInterval(pingTimer);
302
+ pingTimer = null;
303
+ namespaces.clear();
304
+ canonicalFingerprint = null;
305
+ }
306
+ }
307
+ function removeTab(ns, tabId, opts) {
308
+ const tab = ns.tabs.get(tabId);
309
+ if (!tab) return;
310
+ ns.tabs.delete(tabId);
311
+ portTab.delete(tab.port);
312
+ if (opts.closePort !== false) try {
313
+ tab.port.close();
314
+ } catch {}
315
+ const retry = ns.attachRetry.get(tabId);
316
+ if (retry?.timer) clearTimeout(retry.timer);
317
+ ns.attachRetry.delete(tabId);
318
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
319
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
320
+ if (leaderTab) post(leaderTab.port, {
321
+ type: "close-follower-ports",
322
+ brokerInstanceId,
323
+ leadershipId: ns.leader.leadershipId
324
+ });
325
+ }
326
+ }
327
+ function evictTab(ns, tabId, reason, opts = {}) {
328
+ const wasLeader = ns.leader?.tabId === tabId;
329
+ const previous = wasLeader ? clearLeader(ns, {
330
+ demote: true,
331
+ removeTab: false
332
+ }) : null;
333
+ removeTab(ns, tabId, {
334
+ notifyLeader: !wasLeader,
335
+ closePort: opts.closePort
336
+ });
337
+ if (wasLeader) electIfNeeded(ns, previous);
338
+ stopPingTimerIfIdle();
339
+ }
340
+ function handleTabMessage(port, msg, ports) {
341
+ if (msg.type === "hello") {
342
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
343
+ if (msg.fingerprint !== canonicalFingerprint) {
344
+ post(port, {
345
+ type: "unsupported",
346
+ brokerInstanceId,
347
+ reason: "fingerprint-mismatch"
348
+ });
349
+ return;
350
+ }
351
+ for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
352
+ canonicalFingerprint ??= msg.fingerprint;
353
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
354
+ const tab = {
355
+ port,
356
+ tabId: msg.tabId,
357
+ fingerprint: msg.fingerprint,
358
+ bucketId: msg.bucketId,
359
+ visibility: msg.visibility,
360
+ lastVisibleAt: msg.visibility === "visible" ? Date.now() : 0,
361
+ lastPongAt: Date.now(),
362
+ heldLeadership: msg.heldLeadership
363
+ };
364
+ ns.tabs.set(msg.tabId, tab);
365
+ portTab.set(port, {
366
+ ns,
367
+ tabId: msg.tabId
368
+ });
369
+ ensurePingTimer();
370
+ post(port, {
371
+ type: "broker-hello",
372
+ brokerInstanceId
373
+ });
374
+ if (ns.leader?.ready) {
375
+ post(port, {
376
+ type: "leader-ready",
377
+ brokerInstanceId,
378
+ leadershipId: ns.leader.leadershipId,
379
+ leaderTabId: ns.leader.tabId
380
+ });
381
+ assignFollowerPorts(ns);
382
+ } else electIfNeeded(ns);
383
+ return;
384
+ }
385
+ const entry = portTab.get(port);
386
+ if (!entry) return;
387
+ const { ns } = entry;
388
+ switch (msg.type) {
389
+ case "pong": {
390
+ const tab = ns.tabs.get(msg.tabId);
391
+ if (tab) tab.lastPongAt = Date.now();
392
+ break;
393
+ }
394
+ case "visibility": {
395
+ const tab = ns.tabs.get(msg.tabId);
396
+ if (tab) {
397
+ tab.visibility = msg.visibility;
398
+ if (msg.visibility === "visible") tab.lastVisibleAt = Date.now();
399
+ tab.lastPongAt = Date.now();
400
+ }
401
+ break;
402
+ }
403
+ case "leader-ready": {
404
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
405
+ post(port, {
406
+ type: "demote",
407
+ brokerInstanceId,
408
+ leadershipId: msg.leadershipId
409
+ });
410
+ break;
411
+ }
412
+ ns.leader.ready = true;
413
+ ns.failedCycles = 0;
414
+ clearPromotionTimer(ns);
415
+ startTabLockMonitor(ns, msg.leadershipId);
416
+ const tab = ns.tabs.get(msg.tabId);
417
+ if (tab) tab.heldLeadership = {
418
+ leadershipId: msg.leadershipId,
419
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`
420
+ };
421
+ for (const t of ns.tabs.values()) post(t.port, {
422
+ type: "leader-ready",
423
+ brokerInstanceId,
424
+ leadershipId: msg.leadershipId,
425
+ leaderTabId: msg.tabId
426
+ });
427
+ assignFollowerPorts(ns);
428
+ break;
429
+ }
430
+ case "leader-failed":
431
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
432
+ break;
433
+ case "follower-port-attached":
434
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
435
+ break;
436
+ case "follower-port-closed": {
437
+ if (ns.leader?.tabId !== msg.tabId) break;
438
+ followerAttached(ns, msg.followerTabId);
439
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
440
+ const follower = ns.tabs.get(msg.followerTabId);
441
+ if (leaderTab && follower && ns.leader.ready) mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
442
+ break;
443
+ }
444
+ case "shutdown":
445
+ evictTab(ns, msg.tabId, "shutdown");
446
+ break;
447
+ }
448
+ }
449
+ /** Attach a connecting tab's port. Exported for the node test harness, which
450
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
451
+ function handleConnect(port) {
452
+ port.onmessage = (ev) => {
453
+ handleTabMessage(port, ev.data, ev.ports ?? []);
454
+ };
455
+ port.onmessageerror = () => {
456
+ const entry = portTab.get(port);
457
+ if (entry) evictTab(entry.ns, entry.tabId, "messageerror");
458
+ };
459
+ port.start?.();
460
+ }
461
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
462
+ function __resetBrokerForTests() {
463
+ if (pingTimer) clearInterval(pingTimer);
464
+ pingTimer = null;
465
+ for (const ns of namespaces.values()) for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
466
+ namespaces.clear();
467
+ portTab.clear();
468
+ canonicalFingerprint = null;
469
+ nextLeadershipId = 0;
470
+ }
471
+ if (typeof self !== "undefined") self.onconnect = (event) => {
472
+ const port = event.ports[0];
473
+ if (port) handleConnect(port);
474
+ };
475
+
476
+ //#endregion
477
+ export { __resetBrokerForTests, handleConnect };