@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,652 @@
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
+ // Its port stays open: the only caller is the tab-lock monitor, and a lock
187
+ // released is not always a dead tab. A leader that signs in or out drops
188
+ // its lock before re-helloing into the new bucket, and closing the port
189
+ // here swallowed that hello, leaving the tab roleless until its watchdog
190
+ // reconnected. A dead tab's port is gone anyway.
191
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false, closePort: false });
192
+ // Followers must drop their ports; new ones are minted after re-election.
193
+ for (const t of ns.tabs.values()) {
194
+ if (t.tabId !== leader.tabId) {
195
+ post(t.port, {
196
+ type: 'close-follower-ports',
197
+ brokerInstanceId,
198
+ leadershipId: leader.leadershipId,
199
+ });
200
+ }
201
+ }
202
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
203
+ ns.attachRetry.clear();
204
+ return { tabId: leader.tabId, leadershipId: leader.leadershipId, workerLock };
205
+ }
206
+
207
+ function clearPromotionTimer(ns: Namespace): void {
208
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
209
+ ns.promotionTimer = null;
210
+ }
211
+
212
+ /**
213
+ * A promotion did not work out: back the tab off, clear the leadership, and
214
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
215
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
216
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
217
+ * owning it forever.
218
+ *
219
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
220
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
221
+ * the backoff interval forever, with allowMemoryFallback never granted, so
222
+ * every tab timed out in start() and booted solo - each one then contending
223
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
224
+ * stop.
225
+ */
226
+ function failPromotion(
227
+ ns: Namespace,
228
+ tabId: string,
229
+ leadershipId: number,
230
+ opts: { timedOut: boolean }
231
+ ): void {
232
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
233
+ ns.failedCycles += 1;
234
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
235
+ const previous = clearLeader(ns, { demote: false, removeTab: false });
236
+ const tab = ns.tabs.get(tabId);
237
+ if (tab) tab.heldLeadership = null;
238
+ // A tab that reported `leader-failed` has already given back its tab lock and
239
+ // any worker it opened, so there is nothing for the next candidate to wait
240
+ // on. A tab that went silent may have opened the OPFS pool and never told us,
241
+ // so hand the next election the lock name it would be holding: the probe in
242
+ // `waitForPreviousLeaderLocks` returns immediately when it is free, and
243
+ // steals it when it is not. `heldLeadership` is deliberately NOT used for
244
+ // this - it means "confirmed leader" and drives the broker-restart resume.
245
+ const stranded =
246
+ opts.timedOut && previous
247
+ ? {
248
+ ...previous,
249
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`,
250
+ }
251
+ : previous;
252
+ electIfNeeded(ns, stranded);
253
+ }
254
+
255
+ /**
256
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
257
+ * Locks release the instant their holder's tab dies, so being GRANTED means
258
+ * the leader is gone from this bucket (dead, or moved to another bucket on
259
+ * sign-in/out); release immediately (return from the callback) and re-elect.
260
+ * The leadershipId guard drops stale grants after a normal handoff.
261
+ */
262
+ function startTabLockMonitor(ns: Namespace, leadershipId: number): void {
263
+ const locks = (navigator as { locks?: LockManager }).locks;
264
+ if (!locks) return;
265
+ ns.tabLockMonitor?.abort();
266
+ const controller = new AbortController();
267
+ ns.tabLockMonitor = controller;
268
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
269
+ locks
270
+ .request(name, { mode: 'exclusive', signal: controller.signal }, () => {
271
+ if (ns.leader?.leadershipId !== leadershipId) return;
272
+ const previous = clearLeader(ns, { demote: true, removeTab: true });
273
+ electIfNeeded(ns, previous);
274
+ })
275
+ .catch(() => {
276
+ /* aborted (normal handoff) or Web Locks hiccup; pong timeout covers it */
277
+ });
278
+ }
279
+
280
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
281
+ async function lockIsFree(name: string): Promise<boolean> {
282
+ const locks = (navigator as { locks?: LockManager }).locks;
283
+ if (!locks) return true;
284
+ try {
285
+ return await locks.request(name, { mode: 'exclusive', ifAvailable: true }, (lock) =>
286
+ Promise.resolve(lock !== null)
287
+ );
288
+ } catch {
289
+ return true;
290
+ }
291
+ }
292
+
293
+ async function stealLock(name: string): Promise<void> {
294
+ const locks = (navigator as { locks?: LockManager }).locks;
295
+ if (!locks) return;
296
+ try {
297
+ await locks.request(name, { mode: 'exclusive', steal: true }, () => Promise.resolve());
298
+ } catch {
299
+ /* best effort */
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Before promoting a replacement, give the previous leader's locks a chance to
305
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
306
+ * whether a steal was needed, which the new leader passes to its worker open.
307
+ */
308
+ async function waitForPreviousLeaderLocks(previous: ClearedLeader | null): Promise<boolean> {
309
+ if (!previous?.workerLock) return false;
310
+ if (await lockIsFree(previous.workerLock)) return false;
311
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
312
+ if (await lockIsFree(previous.workerLock)) return false;
313
+ await stealLock(previous.workerLock);
314
+ return true;
315
+ }
316
+
317
+ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): void {
318
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
319
+ ns.electing = true;
320
+ void (async () => {
321
+ try {
322
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
323
+ // State may have moved while we waited.
324
+ if (ns.leader || ns.tabs.size === 0) return;
325
+ const candidate = pickCandidate(ns);
326
+ if (!candidate) {
327
+ // Everyone is in failure backoff; retry when the earliest expires.
328
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
329
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
330
+ return;
331
+ }
332
+ // A tab that survived a broker restart and still holds its lock keeps
333
+ // its worker; it only rolls the lock name forward to the new id.
334
+ const resumeHeld = candidate.heldLeadership !== null;
335
+ const leadershipId = ++nextLeadershipId;
336
+ ns.leader = { tabId: candidate.tabId, leadershipId, ready: false };
337
+ post(candidate.port, {
338
+ type: 'become-leader',
339
+ brokerInstanceId,
340
+ leadershipId,
341
+ forceTakeover,
342
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
343
+ resumeHeld,
344
+ });
345
+ clearPromotionTimer(ns);
346
+ ns.promotionTimer = setTimeout(() => {
347
+ ns.promotionTimer = null;
348
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
349
+ }, PROMOTION_TIMEOUT_MS);
350
+ } finally {
351
+ ns.electing = false;
352
+ }
353
+ })();
354
+ }
355
+
356
+ // ---- follower attachment ------------------------------------------------------
357
+
358
+ function assignFollowerPorts(ns: Namespace): void {
359
+ const leader = ns.leader;
360
+ if (!leader || !leader.ready) return;
361
+ const leaderTab = ns.tabs.get(leader.tabId);
362
+ if (!leaderTab) return;
363
+ for (const tab of ns.tabs.values()) {
364
+ if (tab.tabId === leader.tabId) continue;
365
+ if (ns.attachRetry.has(tab.tabId)) continue;
366
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
367
+ }
368
+ }
369
+
370
+ function mintPorts(ns: Namespace, leaderTab: BrokerTab, follower: BrokerTab, leadershipId: number): void {
371
+ const db = new MessageChannel();
372
+ const sync = new MessageChannel();
373
+ post(
374
+ leaderTab.port,
375
+ {
376
+ type: 'attach-follower-ports',
377
+ brokerInstanceId,
378
+ leadershipId,
379
+ followerTabId: follower.tabId,
380
+ },
381
+ [db.port1, sync.port1]
382
+ );
383
+ post(
384
+ follower.port,
385
+ { type: 'use-follower-ports', brokerInstanceId, leadershipId, leaderTabId: leaderTab.tabId },
386
+ [db.port2, sync.port2]
387
+ );
388
+ // Re-mint with backoff until the leader confirms (or leadership changes).
389
+ // 1s doubling to 30s, duplicated from protocol constants.
390
+ const state = ns.attachRetry.get(follower.tabId) ?? { count: 0, timer: null };
391
+ const delay = Math.min(1000 * 2 ** state.count, 30_000);
392
+ state.count += 1;
393
+ state.timer = setTimeout(() => {
394
+ const cur = ns.attachRetry.get(follower.tabId);
395
+ if (!cur) return;
396
+ cur.timer = null;
397
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
398
+ const stillHere = ns.tabs.get(follower.tabId);
399
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader!.tabId) : undefined;
400
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
401
+ else ns.attachRetry.delete(follower.tabId);
402
+ }, delay);
403
+ ns.attachRetry.set(follower.tabId, state);
404
+ }
405
+
406
+ function followerAttached(ns: Namespace, followerTabId: string): void {
407
+ const retry = ns.attachRetry.get(followerTabId);
408
+ if (retry?.timer) clearTimeout(retry.timer);
409
+ ns.attachRetry.delete(followerTabId);
410
+ }
411
+
412
+ // ---- liveness -----------------------------------------------------------------
413
+
414
+ function ensurePingTimer(): void {
415
+ if (pingTimer) return;
416
+ pingTimer = setInterval(() => {
417
+ const now = Date.now();
418
+ for (const ns of namespaces.values()) {
419
+ for (const tab of [...ns.tabs.values()]) {
420
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
421
+ evictTab(ns, tab.tabId, 'missed pongs');
422
+ continue;
423
+ }
424
+ post(tab.port, { type: 'ping', brokerInstanceId });
425
+ }
426
+ }
427
+ stopPingTimerIfIdle();
428
+ }, PING_INTERVAL_MS);
429
+ }
430
+
431
+ function stopPingTimerIfIdle(): void {
432
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
433
+ clearInterval(pingTimer);
434
+ pingTimer = null;
435
+ namespaces.clear();
436
+ canonicalFingerprint = null;
437
+ }
438
+ }
439
+
440
+ function removeTab(
441
+ ns: Namespace,
442
+ tabId: string,
443
+ opts: { notifyLeader: boolean; closePort?: boolean }
444
+ ): void {
445
+ const tab = ns.tabs.get(tabId);
446
+ if (!tab) return;
447
+ ns.tabs.delete(tabId);
448
+ portTab.delete(tab.port);
449
+ // A re-hello (bucket switch, reconnect) re-homes the tab on the SAME port;
450
+ // closing it would sever the still-connected tab. Only a real departure
451
+ // (shutdown, eviction) closes.
452
+ if (opts.closePort !== false) {
453
+ try {
454
+ tab.port.close();
455
+ } catch {
456
+ /* ignore */
457
+ }
458
+ }
459
+ const retry = ns.attachRetry.get(tabId);
460
+ if (retry?.timer) clearTimeout(retry.timer);
461
+ ns.attachRetry.delete(tabId);
462
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
463
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
464
+ if (leaderTab) {
465
+ post(leaderTab.port, {
466
+ type: 'close-follower-ports',
467
+ brokerInstanceId,
468
+ leadershipId: ns.leader.leadershipId,
469
+ });
470
+ }
471
+ }
472
+ }
473
+
474
+ function evictTab(
475
+ ns: Namespace,
476
+ tabId: string,
477
+ reason: string,
478
+ opts: { closePort?: boolean } = {}
479
+ ): void {
480
+ const wasLeader = ns.leader?.tabId === tabId;
481
+ const previous = wasLeader ? clearLeader(ns, { demote: true, removeTab: false }) : null;
482
+ removeTab(ns, tabId, { notifyLeader: !wasLeader, closePort: opts.closePort });
483
+ void reason;
484
+ if (wasLeader) electIfNeeded(ns, previous);
485
+ stopPingTimerIfIdle();
486
+ }
487
+
488
+ // ---- message handling -----------------------------------------------------------
489
+
490
+ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: readonly MessagePort[]): void {
491
+ void ports;
492
+ if (msg.type === 'hello') {
493
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
494
+ if (msg.fingerprint !== canonicalFingerprint) {
495
+ post(port, { type: 'unsupported', brokerInstanceId, reason: 'fingerprint-mismatch' });
496
+ return;
497
+ }
498
+ // Drop stale state for this tab EVERYWHERE first. A re-hello is either a
499
+ // reconnect (same namespace) or a bucket switch (leave old namespace,
500
+ // join the new one); in both cases exactly one entry may remain, and if
501
+ // the tab led its old namespace that namespace needs a new leader now,
502
+ // not after a 15s pong eviction.
503
+ for (const other of [...namespaces.values()]) {
504
+ if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, 'rehello', { closePort: false });
505
+ }
506
+ // Only now look the namespace up: evicting the broker's last tab resets it
507
+ // as idle (all namespaces and the fingerprint dropped), and a namespace
508
+ // fetched before that would be orphaned, never pinged or found again. A
509
+ // lone tab signing in lost its leader that way until its watchdog
510
+ // reconnected.
511
+ canonicalFingerprint ??= msg.fingerprint;
512
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
513
+ const tab: BrokerTab = {
514
+ port,
515
+ tabId: msg.tabId,
516
+ fingerprint: msg.fingerprint,
517
+ bucketId: msg.bucketId,
518
+ visibility: msg.visibility,
519
+ lastVisibleAt: msg.visibility === 'visible' ? Date.now() : 0,
520
+ lastPongAt: Date.now(),
521
+ heldLeadership: msg.heldLeadership,
522
+ };
523
+ ns.tabs.set(msg.tabId, tab);
524
+ portTab.set(port, { ns, tabId: msg.tabId });
525
+ ensurePingTimer();
526
+ post(port, { type: 'broker-hello', brokerInstanceId });
527
+ if (ns.leader?.ready) {
528
+ post(port, {
529
+ type: 'leader-ready',
530
+ brokerInstanceId,
531
+ leadershipId: ns.leader.leadershipId,
532
+ leaderTabId: ns.leader.tabId,
533
+ });
534
+ assignFollowerPorts(ns);
535
+ } else {
536
+ electIfNeeded(ns);
537
+ }
538
+ return;
539
+ }
540
+
541
+ const entry = portTab.get(port);
542
+ if (!entry) return;
543
+ const { ns } = entry;
544
+
545
+ switch (msg.type) {
546
+ case 'pong': {
547
+ const tab = ns.tabs.get(msg.tabId);
548
+ if (tab) tab.lastPongAt = Date.now();
549
+ break;
550
+ }
551
+ case 'visibility': {
552
+ const tab = ns.tabs.get(msg.tabId);
553
+ if (tab) {
554
+ tab.visibility = msg.visibility;
555
+ if (msg.visibility === 'visible') tab.lastVisibleAt = Date.now();
556
+ tab.lastPongAt = Date.now();
557
+ }
558
+ break;
559
+ }
560
+ case 'leader-ready': {
561
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
562
+ // Stale promotion (a newer election superseded it): demote it.
563
+ post(port, { type: 'demote', brokerInstanceId, leadershipId: msg.leadershipId });
564
+ break;
565
+ }
566
+ ns.leader.ready = true;
567
+ ns.failedCycles = 0;
568
+ clearPromotionTimer(ns);
569
+ startTabLockMonitor(ns, msg.leadershipId);
570
+ const tab = ns.tabs.get(msg.tabId);
571
+ if (tab) {
572
+ // The leader now holds the per-leadership worker lock; remember it so
573
+ // a broker restart (or its next takeover) can find it.
574
+ tab.heldLeadership = {
575
+ leadershipId: msg.leadershipId,
576
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`,
577
+ };
578
+ }
579
+ for (const t of ns.tabs.values()) {
580
+ post(t.port, {
581
+ type: 'leader-ready',
582
+ brokerInstanceId,
583
+ leadershipId: msg.leadershipId,
584
+ leaderTabId: msg.tabId,
585
+ });
586
+ }
587
+ assignFollowerPorts(ns);
588
+ break;
589
+ }
590
+ case 'leader-failed': {
591
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
592
+ break;
593
+ }
594
+ case 'follower-port-attached': {
595
+ // Leader confirmed the follower's ports are live: stop the re-mint loop.
596
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) {
597
+ followerAttached(ns, msg.followerTabId);
598
+ }
599
+ break;
600
+ }
601
+ case 'follower-port-closed': {
602
+ // Leader reports a follower's data port died: re-mint on the retry path.
603
+ if (ns.leader?.tabId !== msg.tabId) break;
604
+ followerAttached(ns, msg.followerTabId);
605
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
606
+ const follower = ns.tabs.get(msg.followerTabId);
607
+ if (leaderTab && follower && ns.leader.ready) {
608
+ mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
609
+ }
610
+ break;
611
+ }
612
+ case 'shutdown': {
613
+ evictTab(ns, msg.tabId, 'shutdown');
614
+ break;
615
+ }
616
+ }
617
+ }
618
+
619
+ /** Attach a connecting tab's port. Exported for the node test harness, which
620
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
621
+ export function handleConnect(port: MessagePort): void {
622
+ port.onmessage = (ev: MessageEvent) => {
623
+ handleTabMessage(port, ev.data as TabToBrokerMessage, (ev.ports ?? []) as MessagePort[]);
624
+ };
625
+ port.onmessageerror = () => {
626
+ const entry = portTab.get(port);
627
+ if (entry) evictTab(entry.ns, entry.tabId, 'messageerror');
628
+ };
629
+ port.start?.();
630
+ }
631
+
632
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
633
+ export function __resetBrokerForTests(): void {
634
+ if (pingTimer) clearInterval(pingTimer);
635
+ pingTimer = null;
636
+ for (const ns of namespaces.values()) {
637
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
638
+ }
639
+ namespaces.clear();
640
+ portTab.clear();
641
+ canonicalFingerprint = null;
642
+ nextLeadershipId = 0;
643
+ }
644
+
645
+ // `self` only exists in a real worker scope; the node test harness imports
646
+ // this module and calls handleConnect directly.
647
+ if (typeof self !== 'undefined') {
648
+ (self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => {
649
+ const port = event.ports[0];
650
+ if (port) handleConnect(port);
651
+ };
652
+ }