@inkandswitch/patchwork-bootloader 0.6.2 → 0.7.0
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 +44 -0
- package/dist/automerge-protocol-handler-worker.js +315 -0
- package/dist/externals-list.js +1 -5
- package/dist/setup.d.ts +4 -7
- package/dist/setup.js +22 -395
- package/dist/shared-worker-lifecycle.d.ts +30 -0
- package/dist/shared-worker-lifecycle.js +242 -0
- package/dist/siblings.d.ts +20 -0
- package/dist/siblings.js +28 -0
- package/dist/types.d.ts +1 -75
- package/dist/types.js +0 -6
- package/dist/worker-control.d.ts +8 -0
- package/dist/worker-control.js +111 -0
- package/package.json +25 -20
- package/src/automerge-protocol-handler-worker.ts +421 -0
- package/src/externals-list.ts +1 -5
- package/src/setup.ts +34 -439
- package/src/shared-worker-lifecycle.ts +289 -0
- package/src/siblings.ts +32 -0
- package/src/types.ts +2 -108
- package/src/worker-control.ts +130 -0
- package/dist/automerge-worker.js +0 -811
- package/src/automerge-worker.ts +0 -1035
- /package/dist/{automerge-worker.d.ts → automerge-protocol-handler-worker.d.ts} +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import debug from "debug";
|
|
2
|
+
|
|
3
|
+
export const lifecycleLog = debug("patchwork:lifecycle");
|
|
4
|
+
|
|
5
|
+
function describeErrorEvent(event: Event): string {
|
|
6
|
+
const error = event as ErrorEvent;
|
|
7
|
+
const where = error.filename
|
|
8
|
+
? ` (${error.filename}:${error.lineno}:${error.colno})`
|
|
9
|
+
: "";
|
|
10
|
+
return `${error.message || String(event)}${where}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A silent port is not proof of death: the worker may still be evaluating its
|
|
14
|
+
// module graph, or be busy with wasm/sync work. In both cases every queued
|
|
15
|
+
// message is delivered once it catches up, and tearing the port down would lose
|
|
16
|
+
// them. So silence only starts a non-destructive probe: a second connection to
|
|
17
|
+
// the same instance. Only if the probe gets a `hello` while this port stays
|
|
18
|
+
// silent do we know the instance is alive but our port is stranded, and
|
|
19
|
+
// recover.
|
|
20
|
+
const HEARTBEAT_MS = 5_000;
|
|
21
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
22
|
+
// An idle worker hellos within milliseconds of connecting, so before first
|
|
23
|
+
// contact the budget is tighter — probing early rescues stranded boots fast.
|
|
24
|
+
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
25
|
+
// After a slow boot both connections hello at roughly the same moment and
|
|
26
|
+
// cross-port delivery order isn't guaranteed, so give the suspect this long to
|
|
27
|
+
// also speak before concluding it's stranded.
|
|
28
|
+
const PROBE_GRACE_MS = 500;
|
|
29
|
+
// Below this spacing, skip: if the fresh worker is dead too, its own heartbeat
|
|
30
|
+
// re-triggers recovery later rather than spinning in a tight loop.
|
|
31
|
+
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
32
|
+
|
|
33
|
+
export type SharedWorkerHandle = {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** The current instance, spawning one if there isn't a live one. */
|
|
36
|
+
get(): SharedWorker;
|
|
37
|
+
/** Send on the current instance's control port. */
|
|
38
|
+
post(message: unknown, transfer?: Transferable[]): void;
|
|
39
|
+
/**
|
|
40
|
+
* The worker died and was replaced. Anything held against the old instance —
|
|
41
|
+
* a port, a subscription — is stranded; the new one boots with cold state.
|
|
42
|
+
*/
|
|
43
|
+
onRecreated(listener: () => void): () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A SharedWorker a tab keeps alive: spawned on demand, heartbeated, and rebuilt
|
|
48
|
+
* if the browser kills it (which it may, under memory pressure).
|
|
49
|
+
*/
|
|
50
|
+
export function sharedWorkerHandle(
|
|
51
|
+
name: string,
|
|
52
|
+
/** Read on every spawn, so a site can set the path after this is built. */
|
|
53
|
+
path: () => string,
|
|
54
|
+
{
|
|
55
|
+
debugging,
|
|
56
|
+
onMessage,
|
|
57
|
+
onSpawn,
|
|
58
|
+
}: {
|
|
59
|
+
debugging: boolean;
|
|
60
|
+
onMessage: (event: MessageEvent) => void;
|
|
61
|
+
onSpawn?: (worker: SharedWorker) => void;
|
|
62
|
+
}
|
|
63
|
+
): SharedWorkerHandle {
|
|
64
|
+
let current: SharedWorker | undefined;
|
|
65
|
+
let disposeDeathDetection: (() => void) | undefined;
|
|
66
|
+
let recovering = false;
|
|
67
|
+
let lastRecoveryAt = 0;
|
|
68
|
+
const recreatedListeners = new Set<() => void>();
|
|
69
|
+
|
|
70
|
+
const get = (): SharedWorker => {
|
|
71
|
+
if (current) return current;
|
|
72
|
+
|
|
73
|
+
const worker = new SharedWorker(path(), { name, type: "module" });
|
|
74
|
+
current = worker;
|
|
75
|
+
|
|
76
|
+
// Fires when a message can't be structured-deserialized. Silent otherwise:
|
|
77
|
+
// the message is dropped, which looks identical to a worker that never
|
|
78
|
+
// replied.
|
|
79
|
+
worker.port.addEventListener("messageerror", (event) => {
|
|
80
|
+
console.error(`[${name}] undeserializable message from worker:`, event);
|
|
81
|
+
});
|
|
82
|
+
// Replies come back on this port, and we listen with addEventListener
|
|
83
|
+
// rather than onmessage, so it needs start().
|
|
84
|
+
worker.port.start();
|
|
85
|
+
worker.port.addEventListener("message", onMessage);
|
|
86
|
+
worker.port.postMessage({ type: "debug", debug: debugging });
|
|
87
|
+
|
|
88
|
+
onSpawn?.(worker);
|
|
89
|
+
disposeDeathDetection = installDeathDetection(worker);
|
|
90
|
+
return worker;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async function recover(reason: string, dead: SharedWorker): Promise<void> {
|
|
94
|
+
if (dead !== current) return;
|
|
95
|
+
if (recovering) return;
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (now - lastRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
|
|
98
|
+
recovering = true;
|
|
99
|
+
lastRecoveryAt = now;
|
|
100
|
+
lifecycleLog("recreating the %s SharedWorker (%s)", name, reason);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
disposeDeathDetection?.();
|
|
104
|
+
disposeDeathDetection = undefined;
|
|
105
|
+
current = undefined;
|
|
106
|
+
try {
|
|
107
|
+
dead.port.close();
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
get();
|
|
111
|
+
for (const listener of recreatedListeners) {
|
|
112
|
+
try {
|
|
113
|
+
listener();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`[${name}] recreated listener threw`, error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
recovering = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function installDeathDetection(worker: SharedWorker): () => void {
|
|
124
|
+
let instanceId: string | undefined;
|
|
125
|
+
let lastHeardAt = Date.now();
|
|
126
|
+
let warnedUnresponsive = false;
|
|
127
|
+
let warnedSendFailed = false;
|
|
128
|
+
let disposed = false;
|
|
129
|
+
let probe: SharedWorker | undefined;
|
|
130
|
+
let seq = 0;
|
|
131
|
+
|
|
132
|
+
const closeProbe = () => {
|
|
133
|
+
if (!probe) return;
|
|
134
|
+
try {
|
|
135
|
+
probe.port.close();
|
|
136
|
+
} catch {}
|
|
137
|
+
probe = undefined;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
141
|
+
const data = event.data;
|
|
142
|
+
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
143
|
+
lastHeardAt = Date.now();
|
|
144
|
+
warnedUnresponsive = false;
|
|
145
|
+
closeProbe();
|
|
146
|
+
if (instanceId === undefined) {
|
|
147
|
+
instanceId = data.instanceId;
|
|
148
|
+
lifecycleLog(
|
|
149
|
+
"%s SharedWorker instance %s (via %s)",
|
|
150
|
+
name,
|
|
151
|
+
data.instanceId,
|
|
152
|
+
data.type
|
|
153
|
+
);
|
|
154
|
+
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
155
|
+
lifecycleLog(
|
|
156
|
+
"%s SharedWorker instance changed (instance %s, was %s)",
|
|
157
|
+
name,
|
|
158
|
+
data.instanceId,
|
|
159
|
+
instanceId
|
|
160
|
+
);
|
|
161
|
+
instanceId = data.instanceId;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
worker.port.addEventListener("close", () => {
|
|
166
|
+
if (disposed) return;
|
|
167
|
+
lifecycleLog("%s SharedWorker control port closed", name);
|
|
168
|
+
void recover("control port closed", worker);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Not gated on the debug namespace: a worker that fails to load never
|
|
172
|
+
// replies to anything, and this is the only signal that says so.
|
|
173
|
+
worker.addEventListener("error", (event) => {
|
|
174
|
+
console.error(`${name} SharedWorker error:`, describeErrorEvent(event));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const startProbe = (reason: string) => {
|
|
178
|
+
if (probe || disposed) return;
|
|
179
|
+
lifecycleLog(
|
|
180
|
+
"%s SharedWorker %s; probing with a second connection",
|
|
181
|
+
name,
|
|
182
|
+
reason
|
|
183
|
+
);
|
|
184
|
+
const startedAt = Date.now();
|
|
185
|
+
const p = new SharedWorker(path(), { name, type: "module" });
|
|
186
|
+
probe = p;
|
|
187
|
+
p.port.start();
|
|
188
|
+
p.port.addEventListener("message", (event: MessageEvent) => {
|
|
189
|
+
if (event.data?.type !== "hello") return;
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
if (disposed || probe !== p) return;
|
|
192
|
+
closeProbe();
|
|
193
|
+
// The suspect spoke while the probe ran: it was merely busy, and
|
|
194
|
+
// everything queued on it has been delivered.
|
|
195
|
+
if (lastHeardAt >= startedAt) return;
|
|
196
|
+
void recover(
|
|
197
|
+
`port unresponsive on a live worker (${reason}; probe confirmed)`,
|
|
198
|
+
worker
|
|
199
|
+
);
|
|
200
|
+
}, PROBE_GRACE_MS);
|
|
201
|
+
});
|
|
202
|
+
// No hello on the probe means the instance is loading or busy. The probe
|
|
203
|
+
// waits indefinitely rather than tearing anything down on a timer.
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const heartbeat = setInterval(() => {
|
|
207
|
+
try {
|
|
208
|
+
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Without this a failed send is indistinguishable from a dead worker.
|
|
211
|
+
if (!warnedSendFailed) {
|
|
212
|
+
warnedSendFailed = true;
|
|
213
|
+
console.error(`${name} SharedWorker ping send threw`, error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const neverHeard = instanceId === undefined;
|
|
218
|
+
const silentMs = Date.now() - lastHeardAt;
|
|
219
|
+
const timeoutMs = neverHeard
|
|
220
|
+
? FIRST_CONTACT_TIMEOUT_MS
|
|
221
|
+
: HEARTBEAT_TIMEOUT_MS;
|
|
222
|
+
if (silentMs <= timeoutMs) return;
|
|
223
|
+
|
|
224
|
+
// First contact probes regardless of visibility: SharedWorkers don't
|
|
225
|
+
// suspend with the tab, and the probe destroys nothing. Post-contact
|
|
226
|
+
// silence defers to visibility, since a hidden page's throttling can fake
|
|
227
|
+
// it.
|
|
228
|
+
const visible =
|
|
229
|
+
typeof document === "undefined" ||
|
|
230
|
+
document.visibilityState === "visible";
|
|
231
|
+
if (!neverHeard && !visible) return;
|
|
232
|
+
|
|
233
|
+
const seconds = Math.round(silentMs / 1000);
|
|
234
|
+
const reason = neverHeard
|
|
235
|
+
? `no hello ~${seconds}s after connecting`
|
|
236
|
+
: `no pong for ~${seconds}s`;
|
|
237
|
+
if (!warnedUnresponsive) {
|
|
238
|
+
warnedUnresponsive = true;
|
|
239
|
+
lifecycleLog("%s SharedWorker %s (tab visible)", name, reason);
|
|
240
|
+
}
|
|
241
|
+
startProbe(reason);
|
|
242
|
+
}, HEARTBEAT_MS);
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
disposed = true;
|
|
246
|
+
clearInterval(heartbeat);
|
|
247
|
+
closeProbe();
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
get,
|
|
254
|
+
post(message, transfer) {
|
|
255
|
+
get().port.postMessage(message, transfer ?? []);
|
|
256
|
+
},
|
|
257
|
+
onRecreated(listener) {
|
|
258
|
+
recreatedListeners.add(listener);
|
|
259
|
+
return () => {
|
|
260
|
+
recreatedListeners.delete(listener);
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Mirror a worker's forwarded console output into this tab's console, since a
|
|
268
|
+
* SharedWorker's own console is only visible in chrome://inspect.
|
|
269
|
+
*/
|
|
270
|
+
export function forwardWorkerConsole(name: string, data: any): boolean {
|
|
271
|
+
if (data?.type !== "console") return false;
|
|
272
|
+
const { level, args } = data;
|
|
273
|
+
if (
|
|
274
|
+
!lifecycleLog.enabled &&
|
|
275
|
+
typeof args?.[0] === "string" &&
|
|
276
|
+
args[0].includes("[lifecycle]")
|
|
277
|
+
) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
const write = (console as any)[level] ?? console.log;
|
|
281
|
+
// The worker's logs carry %c directives in args[0] with CSS in the following
|
|
282
|
+
// args, so the tag has to go inside the format string or the CSS prints raw.
|
|
283
|
+
if (typeof args[0] === "string") {
|
|
284
|
+
write(`[${name}] ${args[0]}`, ...args.slice(1));
|
|
285
|
+
} else {
|
|
286
|
+
write(`[${name}]`, ...args);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
package/src/siblings.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RepoConfig } from "@automerge/automerge-repo/slim";
|
|
2
|
+
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
|
|
3
|
+
import { storagePrefix } from "./storage.js";
|
|
4
|
+
|
|
5
|
+
type SubductionAdapters = NonNullable<RepoConfig["subductionAdapters"]>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every Repo on this origin — each tab's, and the automerge protocol handler
|
|
9
|
+
* worker's — is a full Subduction node with its own storage and its own
|
|
10
|
+
* sync-server socket. Siblings would still meet through the server,
|
|
11
|
+
* eventually; this meets them over a BroadcastChannel so an edit in one tab
|
|
12
|
+
* lands in the others in the time it takes to post a message, online or not.
|
|
13
|
+
*
|
|
14
|
+
* What crosses the channel is Subduction: transport frames between two nodes,
|
|
15
|
+
* authenticated by each one's signer. No classic automerge sync runs here, so
|
|
16
|
+
* the adapters go to `new Repo({ subductionAdapters })` rather than to the
|
|
17
|
+
* network subsystem.
|
|
18
|
+
*
|
|
19
|
+
* A BroadcastChannel is a mesh in which every node sees every other one, and
|
|
20
|
+
* Subduction's handshake has an initiator and a responder, so the role is
|
|
21
|
+
* "mesh": for each pair, the node whose peer id sorts lower speaks first.
|
|
22
|
+
*/
|
|
23
|
+
export function siblingAdapters(): SubductionAdapters {
|
|
24
|
+
const serviceName = `${storagePrefix}-siblings`;
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
adapter: new BroadcastChannelNetworkAdapter({ channelName: serviceName }),
|
|
28
|
+
serviceName,
|
|
29
|
+
role: "mesh",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -6,90 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const HANDOFF_CHANNEL = "@patchwork/handoff";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* BroadcastChannel on which the automerge shared worker announces remote
|
|
11
|
-
* heads it learns about from the sync server. Any tab can listen to stay
|
|
12
|
-
* informed of sync progress without repo-to-repo gossiping.
|
|
13
|
-
*/
|
|
14
|
-
export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Worker → tabs: the worker's Subduction link to the sync server flipped.
|
|
18
|
-
* `serverPeerIds` are the directly-connected sync-server peer ids (their
|
|
19
|
-
* verifying keys), so a tab can tell which peer rows are *the server* and
|
|
20
|
-
* judge "synced" against them specifically.
|
|
21
|
-
*/
|
|
22
|
-
export interface SyncStateConnectionMessage {
|
|
23
|
-
type: "connection";
|
|
24
|
-
connected: boolean;
|
|
25
|
-
serverPeerIds: string[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Worker → tabs: the shared worker's own Subduction identity, so a tab can
|
|
30
|
-
* tell which peer rows are "us". `peerId` is `signer.peerId().toString()` (the
|
|
31
|
-
* value that shows up as a peer id); `verifyingKey` is its hex Ed25519 key.
|
|
32
|
-
*/
|
|
33
|
-
export interface SyncStateWhoAmIMessage {
|
|
34
|
-
type: "whoami";
|
|
35
|
-
peerId: string;
|
|
36
|
-
verifyingKey: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// What the worker broadcasts on SYNCSTATE_CHANNEL: only the *global* signals
|
|
40
|
-
// now. Per-document heads are addressed to subscribers over the control port
|
|
41
|
-
// instead (see SyncStateDocMessage) rather than fanned out to every tab.
|
|
42
|
-
export type SyncStateBroadcast =
|
|
43
|
-
| SyncStateConnectionMessage
|
|
44
|
-
| SyncStateWhoAmIMessage;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Tab → worker: please replay the current global sync signals (whoami +
|
|
48
|
-
* connection) so a freshly-opened tab can orient immediately. Per-document
|
|
49
|
-
* heads are no longer replayed here — a tab subscribes to the specific docs it
|
|
50
|
-
* cares about over its control port instead (see {@link SyncSubscribeMessage}).
|
|
51
|
-
*/
|
|
52
|
-
export interface SyncStateRequestMessage {
|
|
53
|
-
type: "request";
|
|
54
|
-
/** @deprecated ignored — per-doc state is delivered via sync-sub now. */
|
|
55
|
-
documentId?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Per-tab sync-state subscription (over the SharedWorker control port) ──
|
|
59
|
-
//
|
|
60
|
-
// The broadcast SyncState* messages above are global (connection/whoami).
|
|
61
|
-
// Per-document heads, by contrast, are addressed: a tab subscribes its control
|
|
62
|
-
// port to just the documents it cares about and the worker pushes only those
|
|
63
|
-
// docs' heads back down that port. The worker drops a port's whole
|
|
64
|
-
// subscription set automatically when the port closes (the tab went away), so
|
|
65
|
-
// there's no reference counting or heartbeat to leak.
|
|
66
|
-
|
|
67
|
-
/** Tab → worker: start pushing me this document's heads (replays current state). */
|
|
68
|
-
export interface SyncSubscribeMessage {
|
|
69
|
-
type: "sync-sub";
|
|
70
|
-
documentId: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Tab → worker: stop pushing me this document's heads. */
|
|
74
|
-
export interface SyncUnsubscribeMessage {
|
|
75
|
-
type: "sync-unsub";
|
|
76
|
-
documentId: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Worker → tab (control port): a peer's heads for a subscribed document — the
|
|
81
|
-
* worker's own (keyed by its peerId) or a Subduction peer's (keyed by its
|
|
82
|
-
* verifying-key storageId). Same payload as the old broadcast remote-heads
|
|
83
|
-
* message, but delivered only to the tabs that asked for this document.
|
|
84
|
-
*/
|
|
85
|
-
export interface SyncStateDocMessage {
|
|
86
|
-
type: "sync-state";
|
|
87
|
-
documentId: string;
|
|
88
|
-
storageId: string;
|
|
89
|
-
heads: string[];
|
|
90
|
-
timestamp: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
9
|
/**
|
|
94
10
|
* The special URL to resolve, plus enough of the {@link Request} the service
|
|
95
11
|
* worker is holding that the automerge worker can construct one that
|
|
@@ -180,9 +96,7 @@ export interface HandoffAbortMessage {
|
|
|
180
96
|
}
|
|
181
97
|
|
|
182
98
|
export type HandoffReplyMessage =
|
|
183
|
-
|
|
|
184
|
-
| HandoffResponseMessage
|
|
185
|
-
| HandoffAbortMessage;
|
|
99
|
+
HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage;
|
|
186
100
|
|
|
187
101
|
/**
|
|
188
102
|
* Automerge worker → world: broadcast once on startup so the service worker
|
|
@@ -200,34 +114,14 @@ export type SetupServiceWorkerOptions = {
|
|
|
200
114
|
path?: string;
|
|
201
115
|
/**
|
|
202
116
|
* The public path to the automerge shared worker file.
|
|
203
|
-
* Defaults to `/automerge-worker.js`
|
|
117
|
+
* Defaults to `/automerge-protocol-handler-worker.js`
|
|
204
118
|
*/
|
|
205
119
|
workerPath?: string;
|
|
206
120
|
};
|
|
207
121
|
|
|
208
|
-
export type ServiceWorkerRepoChannelListener = (
|
|
209
|
-
port: MessagePort
|
|
210
|
-
) => void | Promise<void>;
|
|
211
|
-
|
|
212
122
|
export type SetupServiceWorkerResult = {
|
|
213
123
|
shared?: SharedWorker;
|
|
214
124
|
kill?: () => void;
|
|
215
125
|
/** Open a classic Automerge sync WebSocket from the automerge worker. */
|
|
216
126
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
217
|
-
subscribeToRepoChannel: (
|
|
218
|
-
listener: ServiceWorkerRepoChannelListener
|
|
219
|
-
) => Promise<() => void>;
|
|
220
|
-
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
221
|
-
getRepoChannel: () => MessagePort;
|
|
222
|
-
/**
|
|
223
|
-
* Watch one document's sync heads (this tab's own and each Subduction peer's,
|
|
224
|
-
* as the worker learns them). Calls `listener` on every update for that doc,
|
|
225
|
-
* replaying the current state on subscribe. Returns an unsubscribe function;
|
|
226
|
-
* the worker stops pushing the doc once the last local watcher drops it (and
|
|
227
|
-
* automatically if this tab goes away).
|
|
228
|
-
*/
|
|
229
|
-
subscribeSyncState: (
|
|
230
|
-
documentId: string,
|
|
231
|
-
listener: (update: SyncStateDocMessage) => void
|
|
232
|
-
) => () => void;
|
|
233
127
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The control protocol every patchwork SharedWorker speaks with its tabs:
|
|
2
|
+
// console forwarding, a `hello` on connect, ping/pong for the tab's death
|
|
3
|
+
// detection, and a debug toggle. Everything else is the worker's own business
|
|
4
|
+
// and arrives through `onMessage`.
|
|
5
|
+
|
|
6
|
+
/** A fresh instance means cold in-memory state, so tabs watch this. */
|
|
7
|
+
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
8
|
+
const WORKER_BOOT_TIME = Date.now();
|
|
9
|
+
|
|
10
|
+
const MAX_BUFFER = 200;
|
|
11
|
+
|
|
12
|
+
export function postToPort(port: MessagePort, message: unknown): void {
|
|
13
|
+
try {
|
|
14
|
+
port.postMessage(message);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.warn("sending failed", error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function serializeArg(arg: unknown): string {
|
|
21
|
+
if (typeof arg === "string") return arg;
|
|
22
|
+
if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(arg);
|
|
25
|
+
} catch {
|
|
26
|
+
return String(arg);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function startWorkerControl(
|
|
31
|
+
name: string,
|
|
32
|
+
handlers: {
|
|
33
|
+
onConnect?: (port: MessagePort) => void;
|
|
34
|
+
onMessage?: (data: any, port: MessagePort, event: MessageEvent) => void;
|
|
35
|
+
onClose?: (port: MessagePort) => void;
|
|
36
|
+
} = {}
|
|
37
|
+
): { log: (...args: unknown[]) => void } {
|
|
38
|
+
const ports = new Set<MessagePort>();
|
|
39
|
+
// Logs emitted before any tab connects (wasm boot) would otherwise be lost.
|
|
40
|
+
const preConnect: Array<{ level: string; args: string[] }> = [];
|
|
41
|
+
// `debug` reads localStorage, which a SharedWorker doesn't have, so this is
|
|
42
|
+
// toggled by a control message from a tab instead.
|
|
43
|
+
let debugging = false;
|
|
44
|
+
|
|
45
|
+
// A SharedWorker's own console is buried in chrome://inspect, so mirror
|
|
46
|
+
// everything over each connected tab's control port.
|
|
47
|
+
const forward = (level: string, rawArgs: unknown[]) => {
|
|
48
|
+
const args = rawArgs.map(serializeArg);
|
|
49
|
+
if (!ports.size) {
|
|
50
|
+
if (preConnect.length < MAX_BUFFER) preConnect.push({ level, args });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const port of ports)
|
|
54
|
+
postToPort(port, { type: "console", level, args });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const level of ["log", "info", "warn", "error", "debug"] as const) {
|
|
58
|
+
const original = console[level].bind(console);
|
|
59
|
+
console[level] = (...args: unknown[]) => {
|
|
60
|
+
original(...args);
|
|
61
|
+
forward(level, args);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
self.addEventListener("error", (event) => {
|
|
66
|
+
const e = event as ErrorEvent;
|
|
67
|
+
forward("error", [
|
|
68
|
+
`uncaught error: ${e.message}`,
|
|
69
|
+
e.error instanceof Error ? e.error.stack : undefined,
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
74
|
+
const reason = (event as PromiseRejectionEvent).reason;
|
|
75
|
+
forward("error", [
|
|
76
|
+
"unhandled rejection:",
|
|
77
|
+
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
self.addEventListener("connect", (event) => {
|
|
82
|
+
const port = (event as MessageEvent).ports[0];
|
|
83
|
+
handlers.onConnect?.(port);
|
|
84
|
+
|
|
85
|
+
port.addEventListener("message", (messageEvent) => {
|
|
86
|
+
const data = (messageEvent as MessageEvent).data;
|
|
87
|
+
if (data?.type === "ping") {
|
|
88
|
+
postToPort(port, {
|
|
89
|
+
type: "pong",
|
|
90
|
+
id: data.id,
|
|
91
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (data?.type === "debug") {
|
|
96
|
+
debugging = data.debug;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
handlers.onMessage?.(data, port, messageEvent as MessageEvent);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Fires when the owning page is destroyed.
|
|
103
|
+
port.addEventListener("close", () => {
|
|
104
|
+
ports.delete(port);
|
|
105
|
+
handlers.onClose?.(port);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
port.start();
|
|
109
|
+
postToPort(port, {
|
|
110
|
+
type: "hello",
|
|
111
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
112
|
+
bootTime: WORKER_BOOT_TIME,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
ports.add(port);
|
|
116
|
+
for (const { level, args } of preConnect.splice(0)) {
|
|
117
|
+
postToPort(port, { type: "console", level, args });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
console.warn(
|
|
122
|
+
`[lifecycle] ${name} SharedWorker started (instance ${WORKER_INSTANCE_ID})`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
log: (...args: unknown[]) => {
|
|
127
|
+
if (debugging) console.log(`[${name}]`, ...args);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|