@inkandswitch/patchwork-bootloader 0.3.0 → 0.3.2
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/CHANGELOG.md +20 -0
- package/dist/automerge-worker.js +468 -9
- package/dist/externals.js +1 -2
- package/dist/module-loader-worker.d.ts +1 -0
- package/dist/module-loader-worker.js +55 -0
- package/dist/module-loader.d.ts +13 -0
- package/dist/module-loader.js +72 -0
- package/dist/service-worker.js +58 -6
- package/dist/setup.d.ts +6 -1
- package/dist/setup.js +177 -2
- package/dist/site.d.ts +7 -7
- package/dist/site.js +194 -71
- package/dist/types.d.ts +72 -0
- package/dist/types.js +6 -0
- package/dist/vite/service-worker-plugin.js +4 -0
- package/package.json +18 -14
- package/src/automerge-worker.ts +512 -9
- package/src/externals.ts +1 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +78 -6
- package/src/setup.ts +194 -7
- package/src/site.ts +230 -92
- package/src/types.ts +97 -0
- package/src/vite/service-worker-plugin.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @inkandswitch/patchwork-bootloader
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0e1eb95: add syncstate info shape
|
|
8
|
+
|
|
9
|
+
## 0.3.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 099e931: Discover a package's plugin descriptors in a dedicated module worker off the
|
|
14
|
+
main thread, then re-import the package (pinned to the same heads) on the main
|
|
15
|
+
thread to run each plugin's real loader. Adds
|
|
16
|
+
`importPluginFromFolderDocUrl(folderDocUrl, pluginType, pluginId)`, which selects
|
|
17
|
+
the plugin by both its `type` and `id` — a plugin `id` is only unique within a
|
|
18
|
+
plugin type, so a package may export e.g. a `patchwork:datatype` and a
|
|
19
|
+
`patchwork:tool` that share the same id.
|
|
20
|
+
- Updated dependencies [099e931]
|
|
21
|
+
- @inkandswitch/patchwork-filesystem@0.1.1
|
|
22
|
+
|
|
3
23
|
## 0.2.8
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/dist/automerge-worker.js
CHANGED
|
@@ -20,12 +20,135 @@ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
|
20
20
|
import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
|
|
21
21
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
22
22
|
// Small adapters — bundled directly into the worker
|
|
23
|
-
import {
|
|
23
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
24
24
|
import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
|
|
25
|
-
import {
|
|
25
|
+
import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
26
26
|
import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
|
|
27
|
-
import { HANDOFF_CHANNEL, } from "./types.js";
|
|
27
|
+
import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
|
|
28
28
|
let debugging = false;
|
|
29
|
+
// Per-boot identity so a tab can detect a worker *restart*: a fresh instance
|
|
30
|
+
// means a new repo peerId + cold in-memory state, so the tab's docs must be
|
|
31
|
+
// re-subscribed. Sent in `hello` (on connect) and every `pong`.
|
|
32
|
+
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
33
|
+
const WORKER_BOOT_TIME = Date.now();
|
|
34
|
+
// ── Forward console output + uncaught errors to the main thread ─────────
|
|
35
|
+
// The SharedWorker has its own console that's a pain to find (chrome://inspect
|
|
36
|
+
// → shared workers). Patch console.* and the global error handlers to also
|
|
37
|
+
// post back over every connected tab's control port, tagged [automerge-worker].
|
|
38
|
+
const controlPorts = new Set();
|
|
39
|
+
// ── Per-tab sync-state subscriptions ────────────────────────────────────
|
|
40
|
+
// Each tab's control port subscribes to the documents it cares about; we push
|
|
41
|
+
// only those docs' heads back down that port (addressed — tab A never sees tab
|
|
42
|
+
// B's docs), and drop a port's whole subscription set when it closes (the tab
|
|
43
|
+
// went away), so there's nothing to reference-count or time out. The global
|
|
44
|
+
// connection/whoami signals still go over SYNCSTATE_CHANNEL.
|
|
45
|
+
const syncWatchers = new Map();
|
|
46
|
+
// Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
|
|
47
|
+
// fresh `sync-sub` can be replayed the doc's current heads immediately. Null
|
|
48
|
+
// until then; subscriptions taken during boot are replayed when it installs.
|
|
49
|
+
let replaySyncForPort = null;
|
|
50
|
+
function syncSubscribe(port, documentId) {
|
|
51
|
+
let docs = syncWatchers.get(port);
|
|
52
|
+
if (!docs)
|
|
53
|
+
syncWatchers.set(port, (docs = new Set()));
|
|
54
|
+
if (docs.has(documentId))
|
|
55
|
+
return;
|
|
56
|
+
docs.add(documentId);
|
|
57
|
+
replaySyncForPort?.(documentId, port);
|
|
58
|
+
}
|
|
59
|
+
function syncUnsubscribe(port, documentId) {
|
|
60
|
+
syncWatchers.get(port)?.delete(documentId);
|
|
61
|
+
}
|
|
62
|
+
// Push one document's heads to every control port currently watching it.
|
|
63
|
+
function pushSyncState(message) {
|
|
64
|
+
for (const [port, docs] of syncWatchers) {
|
|
65
|
+
if (!docs.has(message.documentId))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
port.postMessage(message);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Port already gone; its close handler will reap the entry.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Logs emitted before any tab has connected (e.g. during wasm boot) would
|
|
76
|
+
// otherwise be lost — buffer a bounded number and flush on first connect.
|
|
77
|
+
const preConnectBuffer = [];
|
|
78
|
+
const MAX_BUFFER = 200;
|
|
79
|
+
function serializeArg(arg) {
|
|
80
|
+
if (typeof arg === "string")
|
|
81
|
+
return arg;
|
|
82
|
+
if (arg instanceof Error)
|
|
83
|
+
return arg.stack || `${arg.name}: ${arg.message}`;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.stringify(arg);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return String(arg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function forwardToMainThread(level, rawArgs) {
|
|
92
|
+
const args = rawArgs.map(serializeArg);
|
|
93
|
+
if (!controlPorts.size) {
|
|
94
|
+
if (preConnectBuffer.length < MAX_BUFFER) {
|
|
95
|
+
preConnectBuffer.push({ level, args });
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const port of controlPorts) {
|
|
100
|
+
try {
|
|
101
|
+
port.postMessage({ type: "console", level, args });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Port may be closing — ignore.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
109
|
+
const original = console[level].bind(console);
|
|
110
|
+
console[level] = (...args) => {
|
|
111
|
+
original(...args);
|
|
112
|
+
forwardToMainThread(level, args);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
self.addEventListener("error", (event) => {
|
|
116
|
+
const e = event;
|
|
117
|
+
forwardToMainThread("error", [
|
|
118
|
+
`uncaught error: ${e.message}`,
|
|
119
|
+
e.error instanceof Error ? e.error.stack : undefined,
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
123
|
+
const reason = event.reason;
|
|
124
|
+
forwardToMainThread("error", [
|
|
125
|
+
"unhandled rejection:",
|
|
126
|
+
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
// Boot marker, buffered until the first tab connects. A new instance id means
|
|
130
|
+
// the worker restarted (fresh peerId + cold state).
|
|
131
|
+
console.warn(`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
|
|
132
|
+
`SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
|
|
133
|
+
// ── Suspension watchdog ─────────────────────────────────────────────────
|
|
134
|
+
// A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
|
|
135
|
+
// drift. A large gap means keepalive pongs stalled and the server may have
|
|
136
|
+
// reaped us.
|
|
137
|
+
const WATCHDOG_TICK_MS = 5_000;
|
|
138
|
+
const WATCHDOG_GAP_FACTOR = 2;
|
|
139
|
+
let watchdogLast = Date.now();
|
|
140
|
+
setInterval(() => {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const gap = now - watchdogLast;
|
|
143
|
+
watchdogLast = now;
|
|
144
|
+
if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
|
|
145
|
+
console.warn(`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
|
|
146
|
+
`(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
|
|
147
|
+
`suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
|
|
148
|
+
`during this window, so the sync server may have reaped us. at ` +
|
|
149
|
+
`${new Date(now).toISOString()}`);
|
|
150
|
+
}
|
|
151
|
+
}, WATCHDOG_TICK_MS);
|
|
29
152
|
// Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
|
|
30
153
|
// to target keyhive.sync.automerge.org.
|
|
31
154
|
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
@@ -43,6 +166,13 @@ const SUBDUCTION_ENDPOINTS = [
|
|
|
43
166
|
: "wss://subduction.sync.inkandswitch.com",
|
|
44
167
|
];
|
|
45
168
|
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
169
|
+
// Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
|
|
170
|
+
// sync server, so it's the only place that can notice a doc whose heads have
|
|
171
|
+
// settled out of sync with the server and re-arm a sync round for it.
|
|
172
|
+
const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
|
|
173
|
+
const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
|
|
174
|
+
const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
|
|
175
|
+
const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
|
|
46
176
|
const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
|
|
47
177
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
48
178
|
let classicSyncAdapter = null;
|
|
@@ -61,7 +191,7 @@ async function connectClassicSyncNetwork(server) {
|
|
|
61
191
|
classicSyncConnectPromise = (async () => {
|
|
62
192
|
const { repo } = await getRepoHive();
|
|
63
193
|
if (!classicSyncAdapter) {
|
|
64
|
-
classicSyncAdapter = new
|
|
194
|
+
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
65
195
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
66
196
|
}
|
|
67
197
|
await classicSyncAdapter.whenReady();
|
|
@@ -98,8 +228,13 @@ function getRepoHive() {
|
|
|
98
228
|
log("wasm initialized");
|
|
99
229
|
if (!useKeyhive) {
|
|
100
230
|
const signer = await WebCryptoSigner.setup();
|
|
231
|
+
const identity = {
|
|
232
|
+
peerId: signer.peerId().toString(),
|
|
233
|
+
verifyingKey: signer.verifyingKey().toHex(),
|
|
234
|
+
};
|
|
235
|
+
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
101
236
|
const repo = new Repo({
|
|
102
|
-
storage: new
|
|
237
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
103
238
|
signer,
|
|
104
239
|
peerId: ("automerge-worker-" +
|
|
105
240
|
Math.random()
|
|
@@ -112,6 +247,8 @@ function getRepoHive() {
|
|
|
112
247
|
subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
|
|
113
248
|
});
|
|
114
249
|
self.repo = repo;
|
|
250
|
+
self.syncIdentity = identity;
|
|
251
|
+
setupSyncStateBroadcast(repo, identity);
|
|
115
252
|
log("repo constructed (no keyhive), waiting for network subsystem");
|
|
116
253
|
repo.networkSubsystem.whenReady().then(() => {
|
|
117
254
|
log("repo network subsystem ready");
|
|
@@ -122,7 +259,7 @@ function getRepoHive() {
|
|
|
122
259
|
// ARK variant for talking to the keyhive-enabled subduction sync server.
|
|
123
260
|
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
124
261
|
createRepo: (config) => new Repo(config),
|
|
125
|
-
storage: new
|
|
262
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
126
263
|
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
127
264
|
automaticArchiveIngestion: true,
|
|
128
265
|
cachingMode: "periodic",
|
|
@@ -131,13 +268,14 @@ function getRepoHive() {
|
|
|
131
268
|
// defaults to "subduction".
|
|
132
269
|
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
133
270
|
repo: {
|
|
134
|
-
storage: new
|
|
271
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
135
272
|
subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
|
|
136
273
|
enableRemoteHeadsGossiping: true,
|
|
137
274
|
},
|
|
138
275
|
});
|
|
139
276
|
self.repo = repo;
|
|
140
277
|
self.hive = hive;
|
|
278
|
+
setupSyncStateBroadcast(repo);
|
|
141
279
|
log("repo constructed, waiting for network subsystem");
|
|
142
280
|
// Don't block getRepoHive() on whenReady() — the network subsystem starts
|
|
143
281
|
// with only the subduction adapter, and the MessageChannel adapter is
|
|
@@ -160,6 +298,285 @@ function getRepoHive() {
|
|
|
160
298
|
}
|
|
161
299
|
return repoHivePromise;
|
|
162
300
|
}
|
|
301
|
+
// ── Sync-state broadcast ───────────────────────────────────────────────
|
|
302
|
+
//
|
|
303
|
+
// Only this worker is directly connected to the sync server, so it's the only
|
|
304
|
+
// place that learns the server's heads (the repo's "subduction-remote-heads"
|
|
305
|
+
// event, keyed by each Subduction peer's verifying-key storageId) and whether
|
|
306
|
+
// the server link is up ("subduction-connection"). We rebroadcast both on
|
|
307
|
+
// SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
|
|
308
|
+
// its own server connection. A tab that opens mid-stream posts {type:"request"}
|
|
309
|
+
// to get the current snapshot replayed.
|
|
310
|
+
let syncStateWired = false;
|
|
311
|
+
function setupSyncStateBroadcast(repo, identity) {
|
|
312
|
+
if (syncStateWired)
|
|
313
|
+
return;
|
|
314
|
+
syncStateWired = true;
|
|
315
|
+
const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
|
|
316
|
+
// documentId -> storageId (verifying key) -> last-known heads
|
|
317
|
+
const snapshot = new Map();
|
|
318
|
+
let connected = repo.isSubductionConnected();
|
|
319
|
+
// Directly-connected sync-server peer ids (verifying keys). Stable once
|
|
320
|
+
// known; tabs use this to judge "synced" against the server specifically.
|
|
321
|
+
let serverPeerIds = [];
|
|
322
|
+
const postWhoAmI = () => {
|
|
323
|
+
if (!identity)
|
|
324
|
+
return;
|
|
325
|
+
channel.postMessage({
|
|
326
|
+
type: "whoami",
|
|
327
|
+
peerId: identity.peerId,
|
|
328
|
+
verifyingKey: identity.verifyingKey,
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
// Announce our identity so tabs can label which peer rows are this worker.
|
|
332
|
+
postWhoAmI();
|
|
333
|
+
// Heads are addressed, not broadcast: push a doc's heads only to the control
|
|
334
|
+
// ports that subscribed to it (see syncWatchers / pushSyncState).
|
|
335
|
+
const postHeads = (documentId, storageId, heads, timestamp) => pushSyncState({
|
|
336
|
+
type: "sync-state",
|
|
337
|
+
documentId,
|
|
338
|
+
storageId,
|
|
339
|
+
heads,
|
|
340
|
+
timestamp,
|
|
341
|
+
});
|
|
342
|
+
// Let a `sync-sub` (which may have arrived while the repo was still booting)
|
|
343
|
+
// replay this doc's current snapshot to the subscribing port immediately.
|
|
344
|
+
const replayDoc = (documentId, port) => {
|
|
345
|
+
const byStorage = snapshot.get(documentId);
|
|
346
|
+
if (!byStorage)
|
|
347
|
+
return;
|
|
348
|
+
for (const [storageId, { heads, timestamp }] of byStorage) {
|
|
349
|
+
try {
|
|
350
|
+
port.postMessage({
|
|
351
|
+
type: "sync-state",
|
|
352
|
+
documentId,
|
|
353
|
+
storageId,
|
|
354
|
+
heads,
|
|
355
|
+
timestamp,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Port gone; its close handler reaps it.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
replaySyncForPort = replayDoc;
|
|
364
|
+
// Catch up any ports that subscribed before this wiring existed.
|
|
365
|
+
for (const [port, docs] of syncWatchers) {
|
|
366
|
+
for (const documentId of docs)
|
|
367
|
+
replayDoc(documentId, port);
|
|
368
|
+
}
|
|
369
|
+
const postConnection = () => channel.postMessage({
|
|
370
|
+
type: "connection",
|
|
371
|
+
connected,
|
|
372
|
+
serverPeerIds,
|
|
373
|
+
});
|
|
374
|
+
// Learn (and re-announce) which connected Subduction peer is the sync server.
|
|
375
|
+
// The peer list is empty until the handshake finishes, so retry briefly.
|
|
376
|
+
const refreshServerPeers = async () => {
|
|
377
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
378
|
+
try {
|
|
379
|
+
const ids = await repo.connectedSubductionPeerIds();
|
|
380
|
+
if (ids.length > 0) {
|
|
381
|
+
serverPeerIds = ids;
|
|
382
|
+
postConnection();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// repo has no subduction source / not ready yet
|
|
388
|
+
}
|
|
389
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
// Advertise the worker's OWN heads for every doc it holds (keyed by our
|
|
393
|
+
// verifying key), so the worker hop is visible on every document.
|
|
394
|
+
//
|
|
395
|
+
// Docs pushed in by Subduction that this worker never explicitly opened don't
|
|
396
|
+
// surface via the repo's "document" event, so we discover them by re-scanning
|
|
397
|
+
// repo.handles (on a tick, and whenever the server reports a doc) and attach a
|
|
398
|
+
// heads-changed listener once per doc. No-op when there's no identity (keyhive
|
|
399
|
+
// path).
|
|
400
|
+
const ownTracked = new Set();
|
|
401
|
+
const broadcastOwnHeads = (handle) => {
|
|
402
|
+
if (!identity)
|
|
403
|
+
return;
|
|
404
|
+
const documentId = handle.documentId;
|
|
405
|
+
let heads;
|
|
406
|
+
try {
|
|
407
|
+
heads = [...handle.heads()];
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return; // handle not ready yet
|
|
411
|
+
}
|
|
412
|
+
const timestamp = Date.now();
|
|
413
|
+
let byStorage = snapshot.get(documentId);
|
|
414
|
+
if (!byStorage) {
|
|
415
|
+
byStorage = new Map();
|
|
416
|
+
snapshot.set(documentId, byStorage);
|
|
417
|
+
}
|
|
418
|
+
byStorage.set(identity.peerId, { heads, timestamp });
|
|
419
|
+
postHeads(documentId, identity.peerId, heads, timestamp);
|
|
420
|
+
reviewResync(documentId);
|
|
421
|
+
};
|
|
422
|
+
const trackOwnHandle = (handle) => {
|
|
423
|
+
if (!identity || ownTracked.has(handle.documentId))
|
|
424
|
+
return;
|
|
425
|
+
ownTracked.add(handle.documentId);
|
|
426
|
+
handle.on("heads-changed", () => broadcastOwnHeads(handle));
|
|
427
|
+
broadcastOwnHeads(handle);
|
|
428
|
+
};
|
|
429
|
+
const scanOwnHandles = () => {
|
|
430
|
+
if (!identity)
|
|
431
|
+
return;
|
|
432
|
+
for (const handle of Object.values(repo.handles)) {
|
|
433
|
+
trackOwnHandle(handle);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
// ── Backoff re-sync of stuck/diverged docs ──────────────────────────
|
|
437
|
+
//
|
|
438
|
+
// Subduction sync is event-driven and only retries syncs it observed *fail*;
|
|
439
|
+
// a doc that settles missing commits the server holds — or whose heal retries
|
|
440
|
+
// were exhausted — is otherwise never retried. When we're behind and the
|
|
441
|
+
// server's advertised heads haven't advanced for a grace window (so it's
|
|
442
|
+
// genuinely stuck, not just lagging a live edit), we re-arm its sync round
|
|
443
|
+
// with per-doc exponential backoff. Convergence clears the state.
|
|
444
|
+
const serverHeadSetsFor = (documentId) => {
|
|
445
|
+
const byStorage = snapshot.get(documentId);
|
|
446
|
+
if (!byStorage)
|
|
447
|
+
return [];
|
|
448
|
+
const sets = [];
|
|
449
|
+
for (const [storageId, { heads }] of byStorage) {
|
|
450
|
+
if (serverPeerIds.includes(storageId))
|
|
451
|
+
sets.push(heads);
|
|
452
|
+
}
|
|
453
|
+
return sets;
|
|
454
|
+
};
|
|
455
|
+
const resyncState = new Map();
|
|
456
|
+
// Inspectable from the SharedWorker console as `self.patchworkResync` to see
|
|
457
|
+
// whether/how often a doc is being re-synced and against which server heads.
|
|
458
|
+
const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
|
|
459
|
+
const reviewResync = (documentId) => {
|
|
460
|
+
if (!identity || !connected) {
|
|
461
|
+
resyncState.delete(documentId);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const handle = repo.handles[documentId];
|
|
465
|
+
if (!handle)
|
|
466
|
+
return;
|
|
467
|
+
const serverSets = serverHeadSetsFor(documentId);
|
|
468
|
+
if (serverSets.length === 0) {
|
|
469
|
+
resyncState.delete(documentId); // no server signal to compare against
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// The server advertises subduction *sedimentree* heads (loose-commit +
|
|
473
|
+
// fragment-boundary commit ids), which are NOT the Automerge frontier — so
|
|
474
|
+
// never compare them to handle.heads() for equality. Instead ask whether we
|
|
475
|
+
// already hold every commit the server advertises (`DocHandle.containsHeads`).
|
|
476
|
+
// If we do, the server has nothing we're missing → caught up. If not, we're
|
|
477
|
+
// genuinely behind and a re-sync can pull the rest.
|
|
478
|
+
const serverHeadsUrl = [...new Set(serverSets.flat())];
|
|
479
|
+
let haveAll;
|
|
480
|
+
try {
|
|
481
|
+
haveAll = handle.containsHeads(serverHeadsUrl);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return; // doc not ready, or an undecodable head
|
|
485
|
+
}
|
|
486
|
+
if (haveAll) {
|
|
487
|
+
resyncState.delete(documentId); // we hold everything the server has
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Behind. "Stuck" = the server's advertised set hasn't advanced (no
|
|
491
|
+
// progress) for a while. Key the grace timer on the server heads only, so
|
|
492
|
+
// your own edits churning don't keep resetting it.
|
|
493
|
+
const serverSig = [...serverHeadsUrl].sort().join(",");
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const prev = resyncState.get(documentId);
|
|
496
|
+
if (!prev || prev.serverSig !== serverSig) {
|
|
497
|
+
// First sighting, or the server advanced its view (progress): restart.
|
|
498
|
+
resyncState.set(documentId, {
|
|
499
|
+
serverSig,
|
|
500
|
+
since: now,
|
|
501
|
+
delay: RESYNC_INITIAL_DELAY_MS,
|
|
502
|
+
lastResyncAt: 0,
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (now - prev.since < RESYNC_GRACE_MS)
|
|
507
|
+
return; // not stuck long enough yet
|
|
508
|
+
if (now - prev.lastResyncAt < prev.delay)
|
|
509
|
+
return; // within backoff cooldown
|
|
510
|
+
log("re-syncing behind doc", documentId, { serverSets });
|
|
511
|
+
resyncDiag.fires++;
|
|
512
|
+
resyncDiag.byDoc[documentId] = {
|
|
513
|
+
at: now,
|
|
514
|
+
count: (resyncDiag.byDoc[documentId]
|
|
515
|
+
?.count ?? 0) + 1,
|
|
516
|
+
serverSets,
|
|
517
|
+
};
|
|
518
|
+
try {
|
|
519
|
+
repo.resyncSubduction(documentId);
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
log("resyncSubduction failed", e);
|
|
523
|
+
}
|
|
524
|
+
prev.lastResyncAt = now;
|
|
525
|
+
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
526
|
+
};
|
|
527
|
+
const reviewAllResync = () => {
|
|
528
|
+
if (!identity)
|
|
529
|
+
return;
|
|
530
|
+
for (const documentId of snapshot.keys())
|
|
531
|
+
reviewResync(documentId);
|
|
532
|
+
for (const id of [...resyncState.keys()]) {
|
|
533
|
+
if (!snapshot.has(id))
|
|
534
|
+
resyncState.delete(id);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
|
|
538
|
+
const headsCopy = [...heads];
|
|
539
|
+
let byStorage = snapshot.get(documentId);
|
|
540
|
+
if (!byStorage) {
|
|
541
|
+
byStorage = new Map();
|
|
542
|
+
snapshot.set(documentId, byStorage);
|
|
543
|
+
}
|
|
544
|
+
byStorage.set(storageId, { heads: headsCopy, timestamp });
|
|
545
|
+
postHeads(documentId, storageId, headsCopy, timestamp);
|
|
546
|
+
// A doc the server reported is one we hold — make sure we're advertising
|
|
547
|
+
// our own heads for it too.
|
|
548
|
+
scanOwnHandles();
|
|
549
|
+
reviewResync(documentId);
|
|
550
|
+
});
|
|
551
|
+
repo.on("subduction-connection", ({ connected: isConnected }) => {
|
|
552
|
+
connected = isConnected;
|
|
553
|
+
postConnection();
|
|
554
|
+
if (isConnected)
|
|
555
|
+
void refreshServerPeers();
|
|
556
|
+
});
|
|
557
|
+
// A BroadcastChannel never receives its own posts, so this only sees tabs'
|
|
558
|
+
// requests, never our own broadcasts. We replay just the global signals here;
|
|
559
|
+
// a late tab gets per-doc heads by subscribing (sync-sub), not from this.
|
|
560
|
+
channel.addEventListener("message", (event) => {
|
|
561
|
+
const data = event.data;
|
|
562
|
+
if (data?.type !== "request")
|
|
563
|
+
return;
|
|
564
|
+
postWhoAmI();
|
|
565
|
+
postConnection();
|
|
566
|
+
});
|
|
567
|
+
// In case we're already connected by the time this wires up.
|
|
568
|
+
void refreshServerPeers();
|
|
569
|
+
// Discover the worker's docs by re-scanning repo.handles initially and on a
|
|
570
|
+
// tick (Subduction-pushed docs don't surface via the "document" event).
|
|
571
|
+
scanOwnHandles();
|
|
572
|
+
if (identity)
|
|
573
|
+
setInterval(scanOwnHandles, 3000);
|
|
574
|
+
// Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
|
|
575
|
+
// the "stuck" case is precisely when no head events are firing, so the
|
|
576
|
+
// grace/backoff timers can only advance on a timer.
|
|
577
|
+
if (identity)
|
|
578
|
+
setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
|
|
579
|
+
}
|
|
163
580
|
function dropRepoChannel(repo, channel) {
|
|
164
581
|
// removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
|
|
165
582
|
// calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
|
|
@@ -242,6 +659,16 @@ function handleControlMessage(event, controlPort, connection) {
|
|
|
242
659
|
});
|
|
243
660
|
});
|
|
244
661
|
}
|
|
662
|
+
else if (data?.type === "sync-sub") {
|
|
663
|
+
if (typeof data.documentId === "string") {
|
|
664
|
+
syncSubscribe(controlPort, data.documentId);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else if (data?.type === "sync-unsub") {
|
|
668
|
+
if (typeof data.documentId === "string") {
|
|
669
|
+
syncUnsubscribe(controlPort, data.documentId);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
245
672
|
else if (data?.type === "debug") {
|
|
246
673
|
debugging = data.debug;
|
|
247
674
|
log("automerge worker debugging enabled");
|
|
@@ -266,6 +693,14 @@ function handleControlMessage(event, controlPort, connection) {
|
|
|
266
693
|
replyPort?.close();
|
|
267
694
|
});
|
|
268
695
|
}
|
|
696
|
+
else if (data?.type === "ping") {
|
|
697
|
+
// Heartbeat: reply so the tab can detect our death or restart.
|
|
698
|
+
controlPort.postMessage({
|
|
699
|
+
type: "pong",
|
|
700
|
+
id: data.id,
|
|
701
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
269
704
|
}
|
|
270
705
|
self.addEventListener("connect", (event) => {
|
|
271
706
|
const controlPort = event.ports[0];
|
|
@@ -276,9 +711,33 @@ self.addEventListener("connect", (event) => {
|
|
|
276
711
|
// Fires when the owning page is destroyed. Browsers without the close
|
|
277
712
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
278
713
|
controlPort.addEventListener("close", () => {
|
|
714
|
+
controlPorts.delete(controlPort);
|
|
715
|
+
// The tab is gone — drop its sync subscriptions wholesale so we stop
|
|
716
|
+
// pushing it heads (no per-doc unsub needed, no leak).
|
|
717
|
+
syncWatchers.delete(controlPort);
|
|
279
718
|
void dropConnection(connection);
|
|
280
719
|
});
|
|
281
720
|
controlPort.start();
|
|
721
|
+
// Greet the tab with our per-boot instance id so it can detect a restart
|
|
722
|
+
// (a different id than last seen) even if no port "close" fired.
|
|
723
|
+
controlPort.postMessage({
|
|
724
|
+
type: "hello",
|
|
725
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
726
|
+
bootTime: WORKER_BOOT_TIME,
|
|
727
|
+
});
|
|
728
|
+
// Start forwarding console output to this tab, and flush anything buffered
|
|
729
|
+
// while no tab was connected (e.g. boot-time logs) to the first arrival.
|
|
730
|
+
controlPorts.add(controlPort);
|
|
731
|
+
if (preConnectBuffer.length) {
|
|
732
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
733
|
+
try {
|
|
734
|
+
controlPort.postMessage({ type: "console", level, args });
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
// Port may already be gone — ignore.
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
282
741
|
});
|
|
283
742
|
// ── Automerge URL resolution ───────────────────────────────────────────
|
|
284
743
|
/**
|
|
@@ -422,7 +881,7 @@ async function handleHandoffRequest(message) {
|
|
|
422
881
|
id,
|
|
423
882
|
type: "response",
|
|
424
883
|
response: {
|
|
425
|
-
status:
|
|
884
|
+
status: 557,
|
|
426
885
|
body,
|
|
427
886
|
headers: { "content-type": "text/plain" },
|
|
428
887
|
},
|
|
@@ -469,7 +928,7 @@ async function handleHandoffRequest(message) {
|
|
|
469
928
|
id,
|
|
470
929
|
type: "response",
|
|
471
930
|
response: {
|
|
472
|
-
status:
|
|
931
|
+
status: 558,
|
|
473
932
|
body: String(error),
|
|
474
933
|
headers: { "content-type": "text/plain" },
|
|
475
934
|
},
|
package/dist/externals.js
CHANGED
|
@@ -7,10 +7,9 @@ const externals = [
|
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
9
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
|
+
"@automerge/automerge-repo-network-websocket",
|
|
10
11
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
11
12
|
"@automerge/automerge-repo-keyhive",
|
|
12
|
-
"@automerge/automerge-repo-network-messagechannel",
|
|
13
|
-
"@automerge/automerge-repo-storage-indexeddb",
|
|
14
13
|
"@automerge/automerge-subduction",
|
|
15
14
|
"@automerge/automerge-subduction/slim",
|
|
16
15
|
"@keyhive/keyhive",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Dedicated module worker for plugin-descriptor discovery.
|
|
2
|
+
//
|
|
3
|
+
// A module-settings doc lists Automerge folder-doc packages. To register the
|
|
4
|
+
// plugins a package provides we only need their *descriptions* (id, type,
|
|
5
|
+
// name, icon…), not their implementations. This worker imports a package's
|
|
6
|
+
// entry point off the main thread purely to read its exported `plugins` array,
|
|
7
|
+
// strips the non-cloneable `load()` / `import` machinery, and posts the plain
|
|
8
|
+
// descriptors back. The main thread re-imports the package (at the same heads)
|
|
9
|
+
// only when a plugin is actually loaded — see `importPluginFromFolderDocUrl`.
|
|
10
|
+
//
|
|
11
|
+
// Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
|
|
12
|
+
// entry points is served by the service worker that controls this worker.
|
|
13
|
+
import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
14
|
+
// Keep only the structured-cloneable description fields. `load` is a closure
|
|
15
|
+
// and `module` is the (possibly already-loaded) implementation — neither can
|
|
16
|
+
// cross the worker boundary. `import` is droppable too: the main thread
|
|
17
|
+
// rebuilds loading by re-importing the package and calling the live plugin.
|
|
18
|
+
function toDescriptor(plugin) {
|
|
19
|
+
if (!plugin || typeof plugin !== "object")
|
|
20
|
+
return {};
|
|
21
|
+
const { load, import: _import, module, ...description } = plugin;
|
|
22
|
+
return description;
|
|
23
|
+
}
|
|
24
|
+
function isDiscoverRequest(data) {
|
|
25
|
+
return (typeof data === "object" &&
|
|
26
|
+
data !== null &&
|
|
27
|
+
data.type === "discover" &&
|
|
28
|
+
typeof data.id === "number" &&
|
|
29
|
+
typeof data.url === "string");
|
|
30
|
+
}
|
|
31
|
+
self.addEventListener("message", (event) => {
|
|
32
|
+
const data = event.data;
|
|
33
|
+
if (!isDiscoverRequest(data))
|
|
34
|
+
return;
|
|
35
|
+
const { id, url } = data;
|
|
36
|
+
importModuleFromFolderDocUrl(url)
|
|
37
|
+
.then((mod) => {
|
|
38
|
+
const plugins = Array.isArray(mod?.plugins) ? mod.plugins : [];
|
|
39
|
+
const descriptors = plugins.map(toDescriptor);
|
|
40
|
+
self.postMessage({
|
|
41
|
+
type: "descriptors",
|
|
42
|
+
id,
|
|
43
|
+
descriptors,
|
|
44
|
+
});
|
|
45
|
+
})
|
|
46
|
+
.catch((error) => {
|
|
47
|
+
self.postMessage({
|
|
48
|
+
type: "error",
|
|
49
|
+
id,
|
|
50
|
+
error: error instanceof Error
|
|
51
|
+
? (error.stack ?? error.message)
|
|
52
|
+
: String(error),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|