@inkandswitch/patchwork-bootloader 0.0.8 → 0.1.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/src/setup.ts CHANGED
@@ -1,9 +1,15 @@
1
- import type { SetupServiceWorkerOptions } from "./types.js";
1
+ import type {
2
+ ServiceWorkerRepoChannelListener,
3
+ SetupServiceWorkerOptions,
4
+ SetupServiceWorkerResult,
5
+ } from "./types.js";
2
6
  import debug from "debug";
3
7
 
4
8
  const debugging = debug.enabled("patchwork:serviceworker");
5
9
 
6
10
  const key = "patchworkServiceWorkerCacheVersion";
11
+ let nextRepoChannelId = 0;
12
+ let serviceWorkerInstanceId: string | undefined;
7
13
 
8
14
  function bumpServiceWorkerCacheVersion() {
9
15
  const version = new Date().valueOf().toString(36);
@@ -40,6 +46,21 @@ export function bumpServiceWorkerCache(
40
46
 
41
47
  (window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;
42
48
 
49
+ function configureServiceWorker(sw: ServiceWorker | null) {
50
+ if (!sw) return;
51
+ sw.postMessage({ type: "debug", debug: debugging });
52
+ const cachename = getServiceWorkerCacheVersion();
53
+ if (cachename) sw.postMessage({ type: "cachename", cachename });
54
+ }
55
+
56
+ function updateServiceWorkerInstanceId(next: unknown) {
57
+ if (typeof next !== "string") return false;
58
+ const changed =
59
+ serviceWorkerInstanceId != null && serviceWorkerInstanceId !== next;
60
+ serviceWorkerInstanceId = next;
61
+ return changed;
62
+ }
63
+
43
64
  /** Wait for a registration to have an active worker */
44
65
  function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
45
66
  if (reg.active) return Promise.resolve(reg.active);
@@ -53,35 +74,136 @@ function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
53
74
  });
54
75
  }
55
76
 
77
+ async function openRepoChannel(): Promise<{
78
+ port: MessagePort;
79
+ workerInstanceChanged: boolean;
80
+ }> {
81
+ const controller = navigator.serviceWorker.controller;
82
+ if (!controller) {
83
+ throw new Error("no service worker controller");
84
+ }
85
+
86
+ // Send a MessagePort so the SW's repo can sync with clients, and wait for
87
+ // the SW to confirm its repo is constructed before returning. The
88
+ // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
89
+ // of the other end's state, so it can't be used as a real readiness signal
90
+ // on first install (when the SW still has to fetch wasm and build its repo).
91
+ const id = ++nextRepoChannelId;
92
+ let workerInstanceChanged = false;
93
+ const { port1, port2 } = new MessageChannel();
94
+ const swReady = new Promise<void>((resolve, reject) => {
95
+ let timeout: ReturnType<typeof setTimeout>;
96
+ const cleanup = () => {
97
+ clearTimeout(timeout);
98
+ navigator.serviceWorker.removeEventListener("message", listener);
99
+ };
100
+ const listener = (event: MessageEvent) => {
101
+ if (event.data?.id != null && event.data.id !== id) return;
102
+ if (event.data?.type === "port-ready") {
103
+ workerInstanceChanged = updateServiceWorkerInstanceId(
104
+ event.data.workerInstanceId
105
+ );
106
+ cleanup();
107
+ resolve();
108
+ } else if (event.data?.type === "port-failed") {
109
+ workerInstanceChanged = updateServiceWorkerInstanceId(
110
+ event.data.workerInstanceId
111
+ );
112
+ cleanup();
113
+ reject(new Error(`service worker init failed: ${event.data.error}`));
114
+ }
115
+ };
116
+ navigator.serviceWorker.addEventListener("message", listener);
117
+ // Failsafe: don't block boot forever if the SW never replies. Surface the
118
+ // issue and let the rest of the site come up rather than hanging on a
119
+ // blank page.
120
+ timeout = setTimeout(() => {
121
+ cleanup();
122
+ reject(new Error("service worker port-ready timeout"));
123
+ }, 30_000);
124
+ });
125
+ controller.postMessage({ type: "port", id }, [port2]);
126
+ try {
127
+ await swReady;
128
+ } catch (err) {
129
+ console.warn(
130
+ "proceeding without SW ready ack:",
131
+ err instanceof Error ? err.message : err
132
+ );
133
+ }
134
+ return { port: port1, workerInstanceChanged };
135
+ }
136
+
56
137
  export default async function setupServiceWorker(
57
138
  options?: SetupServiceWorkerOptions
58
- ) {
59
- // Backwards compat: if an old service worker sends handoff "request" messages,
60
- // immediately reject them so its fetch handler doesn't hang forever.
61
- navigator.serviceWorker.addEventListener("message", (event) => {
62
- if (event.data?.type === "request" && event.data.id != null) {
63
- navigator.serviceWorker.controller?.postMessage({
64
- type: "response",
65
- id: event.data.id,
66
- response: {
67
- body: "service worker upgraded, please refresh",
68
- status: 503,
69
- headers: { "content-type": "text/plain" },
70
- },
71
- });
139
+ ): Promise<SetupServiceWorkerResult> {
140
+ const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
141
+ let reconnectPromise: Promise<void> | null = null;
142
+
143
+ const reconnectRepoChannels = (reason: string) => {
144
+ if (reconnectPromise) return reconnectPromise;
145
+ reconnectPromise = (async () => {
146
+ console.info(
147
+ `%cservice worker ${reason}, reconnecting repo channels...`,
148
+ "color: pink; font-weight: bold"
149
+ );
150
+ configureServiceWorker(navigator.serviceWorker.controller);
151
+ for (const listener of repoChannelListeners) {
152
+ try {
153
+ const { port } = await openRepoChannel();
154
+ await listener(port);
155
+ } catch (err) {
156
+ console.error("service worker repo channel listener failed", err);
157
+ }
158
+ }
159
+ })().finally(() => {
160
+ reconnectPromise = null;
161
+ });
162
+ return reconnectPromise;
163
+ };
164
+
165
+ const pingServiceWorker = async () => {
166
+ const controller = navigator.serviceWorker.controller;
167
+ if (!controller) return;
168
+ const { port1, port2 } = new MessageChannel();
169
+ const pong = new Promise<unknown>((resolve, reject) => {
170
+ const timeout = setTimeout(() => {
171
+ port1.close();
172
+ reject(new Error("service worker pong timeout"));
173
+ }, 5_000);
174
+ port1.onmessage = (event) => {
175
+ clearTimeout(timeout);
176
+ port1.close();
177
+ resolve(event.data?.workerInstanceId);
178
+ };
179
+ });
180
+ controller.postMessage({ type: "ping" }, [port2]);
181
+ try {
182
+ const restarted = updateServiceWorkerInstanceId(await pong);
183
+ if (restarted) {
184
+ await reconnectRepoChannels("restarted");
185
+ }
186
+ } catch (err) {
187
+ console.warn(
188
+ "service worker ping failed:",
189
+ err instanceof Error ? err.message : err
190
+ );
72
191
  }
73
- });
192
+ };
74
193
 
75
194
  const path = options?.path ?? "/service-worker.js";
195
+ // No controller at this point means the page loaded without a service
196
+ // worker — i.e. this is a first-time install (or a hard reload). Wait for
197
+ // activation so the app boots with the SW in control of generated fetches.
76
198
  const reg = await navigator.serviceWorker.register(path, { type: "module" });
77
199
 
78
200
  // If there's an update waiting or installing, wait for it to activate
201
+ let active = reg.active;
79
202
  if (reg.installing || reg.waiting) {
80
- await waitForActive(reg);
203
+ active = await waitForActive(reg);
81
204
  }
82
205
 
83
- const active = reg.active!;
84
- active.postMessage({ type: "debug", debug: debugging });
206
+ configureServiceWorker(active);
85
207
 
86
208
  // Wait for the controller to be available
87
209
  if (!navigator.serviceWorker.controller) {
@@ -94,25 +216,20 @@ export default async function setupServiceWorker(
94
216
  });
95
217
  }
96
218
 
97
- // Send a MessagePort so the SW's repo can sync with clients
98
- const { port1, port2 } = new MessageChannel();
99
- navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
100
-
101
219
  // Keepalive — Chromium idles out service workers after ~30s of inactivity,
102
220
  // which tears down the in-memory Repo and forces a cold restart on the next
103
- // fetch. Send a ping every 20s while the page is visible to keep it warm.
221
+ // fetch. Ping through a MessageChannel so we can detect when a restarted SW
222
+ // has a new in-memory Repo and reconnect all repo channels.
104
223
  setInterval(() => {
105
- navigator.serviceWorker.controller?.postMessage({ type: "ping" });
224
+ void pingServiceWorker();
106
225
  }, 20_000);
107
226
 
108
- // Reload on future SW updates (added after setup so the initial
109
- // activation doesn't trigger a reload loop).
227
+ // Reconnect on future SW updates (added after setup so the initial
228
+ // activation doesn't notify before callers subscribe).
110
229
  navigator.serviceWorker.addEventListener("controllerchange", function () {
111
- console.info(
112
- "%cnew service worker took control, reloading...",
113
- "color: pink; font-weight: bold"
114
- );
115
- location.reload();
230
+ void reconnectRepoChannels("took control").catch((err) => {
231
+ console.error("service worker reconnect failed", err);
232
+ });
116
233
  });
117
234
 
118
235
  console.log(
@@ -120,5 +237,20 @@ export default async function setupServiceWorker(
120
237
  "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
121
238
  );
122
239
 
123
- return { port: port1 };
240
+ return {
241
+ async subscribeToRepoChannel(listener) {
242
+ const { port, workerInstanceChanged } = await openRepoChannel();
243
+ if (workerInstanceChanged) {
244
+ await reconnectRepoChannels("restarted");
245
+ }
246
+ repoChannelListeners.add(listener);
247
+ try {
248
+ await listener(port);
249
+ } catch (err) {
250
+ repoChannelListeners.delete(listener);
251
+ throw err;
252
+ }
253
+ return () => repoChannelListeners.delete(listener);
254
+ },
255
+ };
124
256
  }
package/src/site.ts CHANGED
@@ -44,11 +44,15 @@ import {
44
44
  getRegistry,
45
45
  registerPlugins,
46
46
  resolveAccountHandle,
47
+ unregisterPlugins,
47
48
  } from "@inkandswitch/patchwork-plugins";
48
49
  import * as plugins from "@inkandswitch/patchwork-plugins";
49
50
 
50
51
  import setupServiceWorker from "./setup.js";
52
+ import type { ServiceWorkerRepoChannelListener } from "./types.js";
51
53
  import { SwLogReader } from "./sw-logger.js";
54
+ import debug from "debug";
55
+ const log = debug("patchwork:bootloader:site");
52
56
 
53
57
  declare global {
54
58
  interface Window {
@@ -56,10 +60,9 @@ declare global {
56
60
  Automerge: typeof import("@automerge/automerge");
57
61
  AutomergeRepo: typeof import("@automerge/automerge-repo");
58
62
  repo: Repo;
59
- getRepoChannel: () => MessagePort;
60
63
  patchwork: {
61
64
  repo: Repo;
62
- modules: ModuleWatcher;
65
+ packages: ModuleWatcher;
63
66
  plugins: typeof plugins;
64
67
  accountDocHandle: DocHandle<AccountDoc>;
65
68
  sw: {
@@ -67,6 +70,9 @@ declare global {
67
70
  tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
68
71
  exportLogs: () => Promise<string>;
69
72
  clearLogs: () => Promise<void>;
73
+ subscribeToRepoChannel: (
74
+ listener: ServiceWorkerRepoChannelListener
75
+ ) => Promise<() => void>;
70
76
  };
71
77
  };
72
78
  uncache: (match: string) => Promise<void>;
@@ -125,6 +131,11 @@ const DEFAULT_REMOTE_STORAGE_ID =
125
131
  const BIG_PATCHWORK_HASH_REGEX =
126
132
  /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
127
133
 
134
+ const [automergeWasm, subductionWasm] = await Promise.all([
135
+ fetch("/automerge.wasm?main").then((r) => r.bytes()),
136
+ fetch("/subduction.wasm").then((r) => r.bytes()),
137
+ ]);
138
+
128
139
  /**
129
140
  * Boot a Patchwork browser site.
130
141
  *
@@ -138,12 +149,8 @@ export async function bootPatchworkSite(
138
149
  config: SiteConfig
139
150
  ): Promise<BootResult> {
140
151
  const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
141
-
142
- // Fetch both Wasm binaries in parallel, then compile
143
- const [automergeWasm, subductionWasm] = await Promise.all([
144
- fetch("/automerge.wasm").then((r) => r.bytes()),
145
- fetch("/subduction.wasm").then((r) => r.bytes()),
146
- ]);
152
+ showLoadingAnimation();
153
+ log(`booting`, config);
147
154
  await initializeWasm(automergeWasm);
148
155
  initSubductionSync(subductionWasm);
149
156
 
@@ -153,16 +160,26 @@ export async function bootPatchworkSite(
153
160
  return peerId.includes("service-worker");
154
161
  },
155
162
  enableRemoteHeadsGossiping: true,
163
+ peerId:
164
+ `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
156
165
  });
166
+
157
167
  repo.subscribeToRemotes(
158
168
  config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
159
169
  );
160
170
 
161
171
  const sw = await setupServiceWorker();
162
172
  if (!sw) throw new Error("Failed to set up service worker");
163
- const net = new MessageChannelNetworkAdapter(sw.port);
164
- repo.networkSubsystem.addNetworkAdapter(net);
165
- await net.whenReady();
173
+ let activeServiceWorkerPort: MessagePort | undefined;
174
+ const connectServiceWorkerPort = async (port: MessagePort) => {
175
+ const previousPort = activeServiceWorkerPort;
176
+ activeServiceWorkerPort = port;
177
+ const net = new MessageChannelNetworkAdapter(port);
178
+ repo.networkSubsystem.addNetworkAdapter(net);
179
+ await net.whenReady();
180
+ previousPort?.close();
181
+ };
182
+ await sw.subscribeToRepoChannel(connectServiceWorkerPort);
166
183
 
167
184
  installDevConsoleGlobals(repo);
168
185
  registerPatchworkViewElement({ repo });
@@ -173,8 +190,9 @@ export async function bootPatchworkSite(
173
190
  // is added lazily once it appears on the account doc — see below.
174
191
  const moduleWatcher = new ModuleWatcher(
175
192
  repo,
176
- [defaultModulesUrl],
177
- onModuleLoaded
193
+ { system: defaultModulesUrl },
194
+ onModuleLoaded,
195
+ unregisterPlugins
178
196
  );
179
197
 
180
198
  const accountDocHandle = await resolveAccountHandle(repo, {
@@ -197,10 +215,13 @@ export async function bootPatchworkSite(
197
215
 
198
216
  window.patchwork = {
199
217
  repo,
200
- modules: moduleWatcher,
218
+ packages: moduleWatcher,
201
219
  plugins,
202
220
  accountDocHandle,
203
- sw: buildSwLogApi(),
221
+ sw: {
222
+ ...buildSwLogApi(),
223
+ subscribeToRepoChannel: sw.subscribeToRepoChannel,
224
+ },
204
225
  };
205
226
  window.uncache = uncache;
206
227
 
@@ -238,23 +259,18 @@ function installDevConsoleGlobals(repo: Repo): void {
238
259
  window.repo = repo;
239
260
  window.Automerge = Automerge;
240
261
  window.AutomergeRepo = AutomergeRepo;
241
- window.getRepoChannel = () => {
242
- const { port1, port2 } = new MessageChannel();
243
- navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
244
- return port1;
245
- };
246
262
  }
247
263
 
248
264
  function onModuleLoaded(name: string, mod: any): void {
249
265
  if (Array.isArray(mod.plugins)) {
250
- console.log(
251
- `[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
266
+ log(
267
+ `registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
252
268
  mod.plugins.map((p: any) => `${p.type}:${p.id}`)
253
269
  );
254
270
  registerPlugins(mod.plugins, name);
255
271
  } else {
256
272
  console.warn(
257
- `[site] module ${name.slice(0, 30)}... has no plugins array`,
273
+ `module ${name.slice(0, 30)}... has no plugins array`,
258
274
  Object.keys(mod)
259
275
  );
260
276
  }
@@ -272,7 +288,7 @@ function wireModuleSettingsWhenReady(
272
288
  const wire = () => {
273
289
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
274
290
  if (!url) return;
275
- void moduleWatcher.addUrl(url);
291
+ void moduleWatcher.addUrl("user", url);
276
292
  accountDocHandle.off("change", wire);
277
293
  };
278
294
  wire();
@@ -291,7 +307,6 @@ function primeRootElement(
291
307
  accountDocHandle: DocHandle<AccountDoc>
292
308
  ): void {
293
309
  rootElement.style.visibility = "hidden";
294
- showLoadingAnimation();
295
310
 
296
311
  const initialParams = new URLSearchParams(location.hash.slice(1));
297
312
  if (initialParams.has("frame")) {
@@ -312,26 +327,29 @@ function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
312
327
  .then(() => {
313
328
  const toolReg = getRegistry("patchwork:tool");
314
329
  const tools = toolReg.all();
315
- console.log(
316
- `[site] doneLoading: ${tools.length} tools registered:`,
330
+ log(
331
+ `doneLoading: ${tools.length} tools registered:`,
317
332
  tools.map((t: any) => t.id)
318
333
  );
319
334
  })
320
335
  .catch((err: unknown) => {
321
- console.error("[site] doneLoading rejected:", err);
336
+ console.error("doneLoading rejected:", err);
322
337
  });
323
338
  }
324
339
 
325
- function buildSwLogApi(): Window["patchwork"]["sw"] {
340
+ function buildSwLogApi(): Omit<
341
+ Window["patchwork"]["sw"],
342
+ "subscribeToRepoChannel"
343
+ > {
326
344
  return {
327
345
  printLogs: async (n = 200) => {
328
346
  const entries = await SwLogReader.tail(n);
329
347
  for (const e of entries) {
330
348
  const prefix = `[${e.ts}] [${e.level}]`;
331
- if (e.data !== undefined) console.log(prefix, e.msg, e.data);
332
- else console.log(prefix, e.msg);
349
+ if (e.data !== undefined) log(prefix, e.msg, e.data);
350
+ else log(prefix, e.msg);
333
351
  }
334
- console.log(`--- ${entries.length} entries ---`);
352
+ log(`--- ${entries.length} entries ---`);
335
353
  },
336
354
  tailLogs: (n = 200) => SwLogReader.tail(n),
337
355
  exportLogs: () => SwLogReader.exportAll(),
@@ -363,6 +381,10 @@ function showLoadingAnimation(): void {
363
381
  radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
364
382
  animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
365
383
  transition: opacity 0.6s ease-out;
384
+ top: 0;
385
+ left: 0;
386
+ right: 0;
387
+ bottom: 0;
366
388
  }
367
389
  @media (prefers-color-scheme: dark) {
368
390
  #${LOADING_ELEMENT_ID} {
package/src/types.ts CHANGED
@@ -5,3 +5,13 @@ export type SetupServiceWorkerOptions = {
5
5
  */
6
6
  path?: string;
7
7
  };
8
+
9
+ export type ServiceWorkerRepoChannelListener = (
10
+ port: MessagePort
11
+ ) => void | Promise<void>;
12
+
13
+ export type SetupServiceWorkerResult = {
14
+ subscribeToRepoChannel: (
15
+ listener: ServiceWorkerRepoChannelListener
16
+ ) => Promise<() => void>;
17
+ };