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