@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.
@@ -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
+ }
@@ -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
- | HandoffCachedMessage
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
+ }