@inkandswitch/patchwork-bootloader 0.0.9 → 0.1.1

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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e6afa48: Add `@inkandswitch/patchwork-bootloader/site` entry point exporting
8
+ `bootPatchworkSite(config)`, a full browser-app boot sequence that constructs
9
+ the Repo, wires the service-worker port, loads plugins via the ModuleWatcher,
10
+ resolves the user's account, and installs URL-hash routing + dev globals. This
11
+ lets per-site `main.ts` collapse to a ~10-line config object and keeps two
12
+ sibling sites from drifting apart.
13
+
14
+ Also removes the unused `@inkandswitch/patchwork-bootloader` devDependency from
15
+ `@inkandswitch/patchwork-plugins`, which eliminated a cyclic workspace edge.
16
+
17
+ ### Patch Changes
18
+
19
+ - a847c4f: release
20
+ - Updated dependencies [e6afa48]
21
+ - Updated dependencies [a847c4f]
22
+ - @inkandswitch/patchwork-plugins@0.0.8
23
+ - @inkandswitch/patchwork-elements@0.0.8
24
+ - @inkandswitch/patchwork-filesystem@0.0.6
25
+
3
26
  ## 0.0.4
4
27
 
5
28
  ### Patch Changes
package/dist/externals.js CHANGED
@@ -9,6 +9,9 @@ const externals = [
9
9
  "@automerge/automerge-repo-network-messagechannel",
10
10
  "@automerge/automerge-repo-storage-indexeddb",
11
11
  "@automerge/automerge-repo-keyhive",
12
+ "@automerge/automerge-repo-react-hooks",
13
+ "@automerge/automerge-repo-network-messagechannel",
14
+ "@automerge/automerge-repo-storage-indexeddb",
12
15
  "@automerge/automerge-subduction",
13
16
  "@automerge/automerge-subduction/slim",
14
17
  "@keyhive/keyhive",
@@ -6,21 +6,22 @@ import { SwLogger } from "./sw-logger.js";
6
6
  // Uses /slim to avoid top-level await (disallowed in service workers).
7
7
  // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
8
8
  // of bundling the ~3MB base64 string.
9
- import { initializeWasm } from "@automerge/automerge/slim";
9
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
10
10
  // eslint-disable-next-line
11
11
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
12
12
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
13
13
  import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
14
14
  import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
15
- import { findHandleInFolderHandle, resolvePackageExport, } from "@inkandswitch/patchwork-filesystem";
15
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
16
16
  // Small adapters — bundled directly into the SW
17
17
  import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
18
18
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
19
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
20
19
  // TEMPORARY: enable debug npm module in SW context (no localStorage available)
21
20
  let cachename = "default";
22
21
  let debugging = false;
22
+ const workerInstanceId = crypto.randomUUID();
23
23
  const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
24
+ const RESOLVE_TIMEOUT_MS = 30_000;
24
25
  // ── Persistent logger ───────────────────────────────────────────────────
25
26
  // Initialized eagerly so it's available for the entire SW lifetime.
26
27
  // Access from the SW inspector console via self.printLogs(), self.tailLogs(),
@@ -46,9 +47,7 @@ const slog = SwLogger.open().then((logger) => {
46
47
  logger.info("sw-logger initialized");
47
48
  return logger;
48
49
  });
49
- const cacheableStatuses = [
50
- 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501,
51
- ];
50
+ const cacheableStatuses = [200, 203, 204, 206];
52
51
  function log(...args) {
53
52
  if (!debugging)
54
53
  return;
@@ -72,11 +71,12 @@ self.addEventListener("activate", async () => {
72
71
  let repoPromise = null;
73
72
  function getRepo() {
74
73
  if (!repoPromise) {
75
- repoPromise = (async () => {
74
+ const p = (async () => {
76
75
  const logger = await slog;
76
+ logger.info("getRepo: starting");
77
77
  logger.info("fetching wasm modules");
78
78
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
79
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
79
+ fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
80
80
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
81
81
  ]);
82
82
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -93,7 +93,7 @@ function getRepo() {
93
93
  },
94
94
  enableRemoteHeadsGossiping: true,
95
95
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
96
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
96
+ //network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
97
97
  });
98
98
  self.repo = repo;
99
99
  logger.info("repo constructed, waiting for network subsystem");
@@ -106,6 +106,14 @@ function getRepo() {
106
106
  });
107
107
  return repo;
108
108
  })();
109
+ // If construction fails (e.g. wasm fetch errors out because the SW was
110
+ // terminated mid-flight), don't permanently cache the rejection — clear
111
+ // the slot so the next caller can retry from scratch.
112
+ p.catch(() => {
113
+ if (repoPromise === p)
114
+ repoPromise = null;
115
+ });
116
+ repoPromise = p;
109
117
  }
110
118
  return repoPromise;
111
119
  }
@@ -122,19 +130,36 @@ self.addEventListener("message", async (event) => {
122
130
  const [pongPort] = event.ports;
123
131
  log("ping");
124
132
  if (pongPort) {
125
- pongPort.postMessage({ type: "pong" });
133
+ pongPort.postMessage({ type: "pong", workerInstanceId });
126
134
  log("pong");
127
135
  pongPort.close();
128
136
  }
129
137
  else if (event.source) {
130
- event.source.postMessage({ type: "pong" });
138
+ event.source.postMessage({
139
+ type: "pong",
140
+ workerInstanceId,
141
+ });
131
142
  log("pong");
132
143
  }
133
144
  }
134
145
  else if (event.data.type == "port") {
135
146
  log("received messagechannel");
136
147
  const [port] = event.ports;
137
- connectPort(port);
148
+ const source = event.source;
149
+ const id = event.data.id;
150
+ // event.waitUntil keeps the SW alive until the work completes. Without
151
+ // it, the browser can terminate the SW the moment this synchronous block
152
+ // returns, killing the in-flight wasm fetch.
153
+ event.waitUntil(connectPort(port).then(() => source?.postMessage({ type: "port-ready", id, workerInstanceId }), (err) => {
154
+ console.error("connectPort failed", err);
155
+ // Tell the client we failed so it doesn't hang forever.
156
+ source?.postMessage({
157
+ type: "port-failed",
158
+ id,
159
+ error: String(err),
160
+ workerInstanceId,
161
+ });
162
+ }));
138
163
  }
139
164
  else if (event.data.type == "cachename") {
140
165
  const nextCachename = event.data.cachename;
@@ -161,10 +186,10 @@ async function resolveAutomergeUrl(automergeURL) {
161
186
  // Trim trailing empty path segment
162
187
  if (path.length && !path[path.length - 1])
163
188
  path.pop();
164
- const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
189
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
190
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
165
191
  if (!heads) {
166
- // Redirect to pinned-heads URL
167
- const folder = await repo.find(maybeAutomergeUrl);
192
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
168
193
  const latestHeads = folder.heads();
169
194
  const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
170
195
  let location = `/${encodeURIComponent(url)}`;
@@ -172,67 +197,24 @@ async function resolveAutomergeUrl(automergeURL) {
172
197
  location += `/${path.join("/")}`;
173
198
  return Response.redirect(location, 307);
174
199
  }
175
- // If no path, check if this is a package with exports to resolve
176
- // e.g. /automerge%3Adocid/abc resolve "abc" via package.json exports
177
- const folderHandle = await repo.find(maybeAutomergeUrl);
178
- let fileHandle;
179
- if (path.length) {
180
- // Try direct file navigation first
181
- fileHandle = await findHandleInFolderHandle(repo, folderHandle, path.map(decodeURIComponent));
182
- // If not found as a direct path, try resolving as a package subpath export
183
- // e.g. /automerge%3Adocid/abc → exports["./abc"] → "./dist/abc.js"
184
- if (!fileHandle) {
185
- const subpath = "./" + path.map(decodeURIComponent).join("/");
186
- const pkgFileHandle = await findHandleInFolderHandle(repo, folderHandle, ["package.json"]);
187
- if (pkgFileHandle) {
188
- const pkgDoc = pkgFileHandle.doc();
189
- if (pkgDoc?.content) {
190
- const pkgJson = JSON.parse(String(pkgDoc.content));
191
- try {
192
- const resolved = resolvePackageExport(pkgJson, subpath);
193
- if (resolved) {
194
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
195
- fileHandle = await findHandleInFolderHandle(repo, folderHandle, resolvedPath);
196
- }
197
- }
198
- catch {
199
- // not a valid export subpath, fall through to error
200
- }
201
- }
202
- }
203
- }
204
- }
205
- else {
206
- // No path — resolve the root export (like "." in package.json)
207
- const pkgFileHandle = await findHandleInFolderHandle(repo, folderHandle, ["package.json"]);
208
- if (pkgFileHandle) {
209
- const pkgDoc = pkgFileHandle.doc();
210
- if (pkgDoc?.content) {
211
- const pkgJson = JSON.parse(String(pkgDoc.content));
212
- try {
213
- const resolved = resolvePackageExport(pkgJson);
214
- if (resolved) {
215
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
216
- fileHandle = await findHandleInFolderHandle(repo, folderHandle, resolvedPath);
217
- }
218
- }
219
- catch { }
220
- }
221
- }
200
+ // Load by documentId only so we can verify the requested heads are actually
201
+ // in our local history. repo.find with a heads-bearing URL returns a view
202
+ // at those heads, which silently materializes garbage if we never synced them.
203
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
204
+ signal,
205
+ });
206
+ if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
207
+ return new Response("heads not found", { status: 404 });
222
208
  }
223
- if (!fileHandle) {
209
+ const rootHandle = baseHandle.view(heads);
210
+ const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
211
+ if (!resolved) {
224
212
  throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
225
213
  }
226
- const fileDoc = fileHandle.doc();
227
- const content = fileDoc?.content;
228
- if (!content) {
229
- throw new Error(`file at ${href} has no content`);
230
- }
231
- let body = content instanceof Uint8Array
232
- ? new Uint8Array(content)
233
- : String(content);
234
- const mimeType = fileDoc.mimeType ?? "text/plain";
235
- const headers = new Headers({ "content-type": mimeType });
214
+ const body = resolved.content instanceof Uint8Array
215
+ ? new Uint8Array(resolved.content)
216
+ : resolved.content;
217
+ const headers = new Headers({ "content-type": resolved.type });
236
218
  headers.set("cross-origin-embedder-policy", "credentialless");
237
219
  headers.set("cross-origin-resource-policy", "cross-origin");
238
220
  return new Response(body, { status: 200, headers });
@@ -269,7 +251,10 @@ self.addEventListener("fetch", (fetchEvent) => {
269
251
  headers,
270
252
  });
271
253
  }
272
- const response = await resolveAutomergeUrl(specialURL);
254
+ const response = await Promise.race([
255
+ resolveAutomergeUrl(specialURL),
256
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)), RESOLVE_TIMEOUT_MS)),
257
+ ]);
273
258
  if (response.status === 307) {
274
259
  return response;
275
260
  }
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { SetupServiceWorkerOptions } from "./types.js";
1
+ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
2
2
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
3
- export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<{
4
- port: MessagePort;
5
- }>;
3
+ export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
package/dist/setup.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import debug from "debug";
2
2
  const debugging = debug.enabled("patchwork:serviceworker");
3
3
  const key = "patchworkServiceWorkerCacheVersion";
4
+ let nextRepoChannelId = 0;
5
+ let serviceWorkerInstanceId;
4
6
  function bumpServiceWorkerCacheVersion() {
5
7
  const version = new Date().valueOf().toString(36);
6
8
  localStorage.setItem(key, version);
@@ -29,6 +31,21 @@ export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller)
29
31
  setServiceWorkerCacheName(sw);
30
32
  }
31
33
  window.bumpServiceWorkerCache = bumpServiceWorkerCache;
34
+ function configureServiceWorker(sw) {
35
+ if (!sw)
36
+ return;
37
+ sw.postMessage({ type: "debug", debug: debugging });
38
+ const cachename = getServiceWorkerCacheVersion();
39
+ if (cachename)
40
+ sw.postMessage({ type: "cachename", cachename });
41
+ }
42
+ function updateServiceWorkerInstanceId(next) {
43
+ if (typeof next !== "string")
44
+ return false;
45
+ const changed = serviceWorkerInstanceId != null && serviceWorkerInstanceId !== next;
46
+ serviceWorkerInstanceId = next;
47
+ return changed;
48
+ }
32
49
  /** Wait for a registration to have an active worker */
33
50
  function waitForActive(reg) {
34
51
  if (reg.active)
@@ -43,51 +60,154 @@ function waitForActive(reg) {
43
60
  });
44
61
  });
45
62
  }
63
+ async function openRepoChannel() {
64
+ const controller = navigator.serviceWorker.controller;
65
+ if (!controller) {
66
+ throw new Error("no service worker controller");
67
+ }
68
+ // Send a MessagePort so the SW's repo can sync with clients, and wait for
69
+ // the SW to confirm its repo is constructed before returning. The
70
+ // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
71
+ // of the other end's state, so it can't be used as a real readiness signal
72
+ // on first install (when the SW still has to fetch wasm and build its repo).
73
+ const id = ++nextRepoChannelId;
74
+ let workerInstanceChanged = false;
75
+ const { port1, port2 } = new MessageChannel();
76
+ const swReady = new Promise((resolve, reject) => {
77
+ let timeout;
78
+ const cleanup = () => {
79
+ clearTimeout(timeout);
80
+ navigator.serviceWorker.removeEventListener("message", listener);
81
+ };
82
+ const listener = (event) => {
83
+ if (event.data?.id != null && event.data.id !== id)
84
+ return;
85
+ if (event.data?.type === "port-ready") {
86
+ workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
87
+ cleanup();
88
+ resolve();
89
+ }
90
+ else if (event.data?.type === "port-failed") {
91
+ workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
92
+ cleanup();
93
+ reject(new Error(`service worker init failed: ${event.data.error}`));
94
+ }
95
+ };
96
+ navigator.serviceWorker.addEventListener("message", listener);
97
+ // Failsafe: don't block boot forever if the SW never replies. Surface the
98
+ // issue and let the rest of the site come up rather than hanging on a
99
+ // blank page.
100
+ timeout = setTimeout(() => {
101
+ cleanup();
102
+ reject(new Error("service worker port-ready timeout"));
103
+ }, 30_000);
104
+ });
105
+ controller.postMessage({ type: "port", id }, [port2]);
106
+ try {
107
+ await swReady;
108
+ }
109
+ catch (err) {
110
+ console.warn("proceeding without SW ready ack:", err instanceof Error ? err.message : err);
111
+ }
112
+ return { port: port1, workerInstanceChanged };
113
+ }
46
114
  export default async function setupServiceWorker(options) {
47
- // Backwards compat: if an old service worker sends handoff "request" messages,
48
- // immediately reject them so its fetch handler doesn't hang forever.
49
- navigator.serviceWorker.addEventListener("message", (event) => {
50
- if (event.data?.type === "request" && event.data.id != null) {
51
- navigator.serviceWorker.controller?.postMessage({
52
- type: "response",
53
- id: event.data.id,
54
- response: {
55
- body: "service worker upgraded, please refresh",
56
- status: 503,
57
- headers: { "content-type": "text/plain" },
58
- },
59
- });
115
+ const repoChannelListeners = new Set();
116
+ let reconnectPromise = null;
117
+ const reconnectRepoChannels = (reason) => {
118
+ if (reconnectPromise)
119
+ return reconnectPromise;
120
+ reconnectPromise = (async () => {
121
+ console.info(`%cservice worker ${reason}, reconnecting repo channels...`, "color: pink; font-weight: bold");
122
+ configureServiceWorker(navigator.serviceWorker.controller);
123
+ for (const listener of repoChannelListeners) {
124
+ try {
125
+ const { port } = await openRepoChannel();
126
+ await listener(port);
127
+ }
128
+ catch (err) {
129
+ console.error("service worker repo channel listener failed", err);
130
+ }
131
+ }
132
+ })().finally(() => {
133
+ reconnectPromise = null;
134
+ });
135
+ return reconnectPromise;
136
+ };
137
+ const pingServiceWorker = async () => {
138
+ const controller = navigator.serviceWorker.controller;
139
+ if (!controller)
140
+ return;
141
+ const { port1, port2 } = new MessageChannel();
142
+ const pong = new Promise((resolve, reject) => {
143
+ const timeout = setTimeout(() => {
144
+ port1.close();
145
+ reject(new Error("service worker pong timeout"));
146
+ }, 5_000);
147
+ port1.onmessage = (event) => {
148
+ clearTimeout(timeout);
149
+ port1.close();
150
+ resolve(event.data?.workerInstanceId);
151
+ };
152
+ });
153
+ controller.postMessage({ type: "ping" }, [port2]);
154
+ try {
155
+ const restarted = updateServiceWorkerInstanceId(await pong);
156
+ if (restarted) {
157
+ await reconnectRepoChannels("restarted");
158
+ }
60
159
  }
61
- });
160
+ catch (err) {
161
+ console.warn("service worker ping failed:", err instanceof Error ? err.message : err);
162
+ }
163
+ };
62
164
  const path = options?.path ?? "/service-worker.js";
165
+ // No controller at this point means the page loaded without a service
166
+ // worker — i.e. this is a first-time install (or a hard reload). Wait for
167
+ // activation so the app boots with the SW in control of generated fetches.
63
168
  const reg = await navigator.serviceWorker.register(path, { type: "module" });
64
169
  // If there's an update waiting or installing, wait for it to activate
170
+ let active = reg.active;
65
171
  if (reg.installing || reg.waiting) {
66
- await waitForActive(reg);
172
+ active = await waitForActive(reg);
67
173
  }
68
- const active = reg.active;
69
- active.postMessage({ type: "debug", debug: debugging });
174
+ configureServiceWorker(active);
70
175
  // Wait for the controller to be available
71
176
  if (!navigator.serviceWorker.controller) {
72
177
  await new Promise((resolve) => {
73
178
  navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
74
179
  });
75
180
  }
76
- // Send a MessagePort so the SW's repo can sync with clients
77
- const { port1, port2 } = new MessageChannel();
78
- navigator.serviceWorker.controller.postMessage({ type: "port" }, [port2]);
79
181
  // Keepalive — Chromium idles out service workers after ~30s of inactivity,
80
182
  // which tears down the in-memory Repo and forces a cold restart on the next
81
- // fetch. Send a ping every 20s while the page is visible to keep it warm.
183
+ // fetch. Ping through a MessageChannel so we can detect when a restarted SW
184
+ // has a new in-memory Repo and reconnect all repo channels.
82
185
  setInterval(() => {
83
- navigator.serviceWorker.controller?.postMessage({ type: "ping" });
186
+ void pingServiceWorker();
84
187
  }, 20_000);
85
- // Reload on future SW updates (added after setup so the initial
86
- // activation doesn't trigger a reload loop).
188
+ // Reconnect on future SW updates (added after setup so the initial
189
+ // activation doesn't notify before callers subscribe).
87
190
  navigator.serviceWorker.addEventListener("controllerchange", function () {
88
- console.info("%cnew service worker took control, reloading...", "color: pink; font-weight: bold");
89
- location.reload();
191
+ void reconnectRepoChannels("took control").catch((err) => {
192
+ console.error("service worker reconnect failed", err);
193
+ });
90
194
  });
91
195
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
92
- return { port: port1 };
196
+ return {
197
+ async subscribeToRepoChannel(listener) {
198
+ const { port, workerInstanceChanged } = await openRepoChannel();
199
+ if (workerInstanceChanged) {
200
+ await reconnectRepoChannels("restarted");
201
+ }
202
+ repoChannelListeners.add(listener);
203
+ try {
204
+ await listener(port);
205
+ }
206
+ catch (err) {
207
+ repoChannelListeners.delete(listener);
208
+ throw err;
209
+ }
210
+ return () => repoChannelListeners.delete(listener);
211
+ },
212
+ };
93
213
  }
package/dist/site.d.ts CHANGED
@@ -15,6 +15,7 @@ import { type DocHandle, Repo, type AutomergeUrl, type StorageId } from "@autome
15
15
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
16
16
  import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
17
17
  import * as plugins from "@inkandswitch/patchwork-plugins";
18
+ import type { ServiceWorkerRepoChannelListener } from "./types.js";
18
19
  import { SwLogReader } from "./sw-logger.js";
19
20
  declare global {
20
21
  interface Window {
@@ -22,10 +23,9 @@ declare global {
22
23
  Automerge: typeof import("@automerge/automerge");
23
24
  AutomergeRepo: typeof import("@automerge/automerge-repo");
24
25
  repo: Repo;
25
- getRepoChannel: () => MessagePort;
26
26
  patchwork: {
27
27
  repo: Repo;
28
- modules: ModuleWatcher;
28
+ packages: ModuleWatcher;
29
29
  plugins: typeof plugins;
30
30
  accountDocHandle: DocHandle<AccountDoc>;
31
31
  sw: {
@@ -33,6 +33,7 @@ declare global {
33
33
  tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
34
34
  exportLogs: () => Promise<string>;
35
35
  clearLogs: () => Promise<void>;
36
+ subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
36
37
  };
37
38
  };
38
39
  uncache: (match: string) => Promise<void>;
package/dist/site.js CHANGED
@@ -19,13 +19,19 @@ import * as AutomergeRepo from "@automerge/automerge-repo/slim";
19
19
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
20
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
21
21
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
22
- import { getRegistry, registerPlugins, resolveAccountHandle, } from "@inkandswitch/patchwork-plugins";
22
+ import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
23
23
  import * as plugins from "@inkandswitch/patchwork-plugins";
24
24
  import setupServiceWorker from "./setup.js";
25
25
  import { SwLogReader } from "./sw-logger.js";
26
+ import debug from "debug";
27
+ const log = debug("patchwork:bootloader:site");
26
28
  const DEFAULT_REMOTE_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d";
27
29
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
28
30
  const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
31
+ const [automergeWasm, subductionWasm] = await Promise.all([
32
+ fetch("/automerge.wasm?main").then((r) => r.bytes()),
33
+ fetch("/subduction.wasm").then((r) => r.bytes()),
34
+ ]);
29
35
  /**
30
36
  * Boot a Patchwork browser site.
31
37
  *
@@ -37,11 +43,8 @@ const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-
37
43
  */
38
44
  export async function bootPatchworkSite(config) {
39
45
  const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
40
- // Fetch both Wasm binaries in parallel, then compile
41
- const [automergeWasm, subductionWasm] = await Promise.all([
42
- fetch("/automerge.wasm").then((r) => r.bytes()),
43
- fetch("/subduction.wasm").then((r) => r.bytes()),
44
- ]);
46
+ showLoadingAnimation();
47
+ log(`booting`, config);
45
48
  await initializeWasm(automergeWasm);
46
49
  initSubductionSync(subductionWasm);
47
50
  const repo = new Repo({
@@ -50,21 +53,29 @@ export async function bootPatchworkSite(config) {
50
53
  return peerId.includes("service-worker");
51
54
  },
52
55
  enableRemoteHeadsGossiping: true,
56
+ peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
53
57
  });
54
58
  repo.subscribeToRemotes(config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]);
55
59
  const sw = await setupServiceWorker();
56
60
  if (!sw)
57
61
  throw new Error("Failed to set up service worker");
58
- const net = new MessageChannelNetworkAdapter(sw.port);
59
- repo.networkSubsystem.addNetworkAdapter(net);
60
- await net.whenReady();
62
+ let activeServiceWorkerPort;
63
+ const connectServiceWorkerPort = async (port) => {
64
+ const previousPort = activeServiceWorkerPort;
65
+ activeServiceWorkerPort = port;
66
+ const net = new MessageChannelNetworkAdapter(port);
67
+ repo.networkSubsystem.addNetworkAdapter(net);
68
+ await net.whenReady();
69
+ previousPort?.close();
70
+ };
71
+ await sw.subscribeToRepoChannel(connectServiceWorkerPort);
61
72
  installDevConsoleGlobals(repo);
62
73
  registerPatchworkViewElement({ repo });
63
74
  // The watcher is started with the site's default-tools bundle alone so that
64
75
  // `resolveAccountHandle` below has something to await on (the `account`
65
76
  // datatype lives in that bundle today). The user's own module-settings URL
66
77
  // is added lazily once it appears on the account doc — see below.
67
- const moduleWatcher = new ModuleWatcher(repo, [defaultModulesUrl], onModuleLoaded);
78
+ const moduleWatcher = new ModuleWatcher(repo, { system: defaultModulesUrl }, onModuleLoaded, unregisterPlugins);
68
79
  const accountDocHandle = await resolveAccountHandle(repo, {
69
80
  storageKey: config.accountStorageKey,
70
81
  });
@@ -78,10 +89,13 @@ export async function bootPatchworkSite(config) {
78
89
  logToolRegistryWhenLoaded(moduleWatcher);
79
90
  window.patchwork = {
80
91
  repo,
81
- modules: moduleWatcher,
92
+ packages: moduleWatcher,
82
93
  plugins,
83
94
  accountDocHandle,
84
- sw: buildSwLogApi(),
95
+ sw: {
96
+ ...buildSwLogApi(),
97
+ subscribeToRepoChannel: sw.subscribeToRepoChannel,
98
+ },
85
99
  };
86
100
  window.uncache = uncache;
87
101
  installHashRouting({
@@ -111,19 +125,14 @@ function installDevConsoleGlobals(repo) {
111
125
  window.repo = repo;
112
126
  window.Automerge = Automerge;
113
127
  window.AutomergeRepo = AutomergeRepo;
114
- window.getRepoChannel = () => {
115
- const { port1, port2 } = new MessageChannel();
116
- navigator.serviceWorker.controller.postMessage({ type: "port" }, [port2]);
117
- return port1;
118
- };
119
128
  }
120
129
  function onModuleLoaded(name, mod) {
121
130
  if (Array.isArray(mod.plugins)) {
122
- console.log(`[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
131
+ log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
123
132
  registerPlugins(mod.plugins, name);
124
133
  }
125
134
  else {
126
- console.warn(`[site] module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
135
+ console.warn(`module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
127
136
  }
128
137
  }
129
138
  /**
@@ -136,7 +145,7 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
136
145
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
137
146
  if (!url)
138
147
  return;
139
- void moduleWatcher.addUrl(url);
148
+ void moduleWatcher.addUrl("user", url);
140
149
  accountDocHandle.off("change", wire);
141
150
  };
142
151
  wire();
@@ -151,7 +160,6 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
151
160
  */
152
161
  function primeRootElement(rootElement, accountDocHandle) {
153
162
  rootElement.style.visibility = "hidden";
154
- showLoadingAnimation();
155
163
  const initialParams = new URLSearchParams(location.hash.slice(1));
156
164
  if (initialParams.has("frame")) {
157
165
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
@@ -171,10 +179,10 @@ function logToolRegistryWhenLoaded(moduleWatcher) {
171
179
  .then(() => {
172
180
  const toolReg = getRegistry("patchwork:tool");
173
181
  const tools = toolReg.all();
174
- console.log(`[site] doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
182
+ log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
175
183
  })
176
184
  .catch((err) => {
177
- console.error("[site] doneLoading rejected:", err);
185
+ console.error("doneLoading rejected:", err);
178
186
  });
179
187
  }
180
188
  function buildSwLogApi() {
@@ -184,11 +192,11 @@ function buildSwLogApi() {
184
192
  for (const e of entries) {
185
193
  const prefix = `[${e.ts}] [${e.level}]`;
186
194
  if (e.data !== undefined)
187
- console.log(prefix, e.msg, e.data);
195
+ log(prefix, e.msg, e.data);
188
196
  else
189
- console.log(prefix, e.msg);
197
+ log(prefix, e.msg);
190
198
  }
191
- console.log(`--- ${entries.length} entries ---`);
199
+ log(`--- ${entries.length} entries ---`);
192
200
  },
193
201
  tailLogs: (n = 200) => SwLogReader.tail(n),
194
202
  exportLogs: () => SwLogReader.exportAll(),
@@ -218,6 +226,10 @@ function showLoadingAnimation() {
218
226
  radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
219
227
  animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
220
228
  transition: opacity 0.6s ease-out;
229
+ top: 0;
230
+ left: 0;
231
+ right: 0;
232
+ bottom: 0;
221
233
  }
222
234
  @media (prefers-color-scheme: dark) {
223
235
  #${LOADING_ELEMENT_ID} {
package/dist/types.d.ts CHANGED
@@ -5,3 +5,7 @@ export type SetupServiceWorkerOptions = {
5
5
  */
6
6
  path?: string;
7
7
  };
8
+ export type ServiceWorkerRepoChannelListener = (port: MessagePort) => void | Promise<void>;
9
+ export type SetupServiceWorkerResult = {
10
+ subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,27 +40,27 @@
40
40
  }
41
41
  },
42
42
  "dependencies": {
43
- "@automerge/automerge": "3.2.5",
44
- "@automerge/automerge-repo": "2.6.0-subduction.15",
45
- "@automerge/automerge-subduction": "0.8.1",
46
- "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.15",
47
- "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.15",
48
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.15",
49
- "@automerge/vanillajs": "2.6.0-subduction.15",
43
+ "@automerge/automerge": "3.2.6",
44
+ "@automerge/automerge-repo": "2.6.0-subduction.20",
45
+ "@automerge/automerge-subduction": "0.12.1",
46
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.20",
47
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.20",
48
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.20",
49
+ "@automerge/vanillajs": "2.6.0-subduction.20",
50
50
  "@types/debug": "^4.1.12",
51
51
  "debug": "^4.4.3",
52
52
  "resolve.exports": "^2.0.3",
53
53
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
54
54
  "tinyargs": "^0.1.4",
55
- "@inkandswitch/patchwork-elements": "^0.0.7",
56
- "@inkandswitch/patchwork-filesystem": "^0.0.5",
57
- "@inkandswitch/patchwork-plugins": "^0.0.7"
55
+ "@inkandswitch/patchwork-elements": "^0.0.8",
56
+ "@inkandswitch/patchwork-plugins": "^0.0.8",
57
+ "@inkandswitch/patchwork-filesystem": "^0.0.7"
58
58
  },
59
59
  "peerDependencies": {
60
- "@automerge/automerge": "3.2.5",
61
- "@automerge/automerge-repo": "2.6.0-subduction.15",
60
+ "@automerge/automerge": "3.2.6",
61
+ "@automerge/automerge-repo": "2.6.0-subduction.20",
62
62
  "@automerge/automerge-repo-keyhive": "0.2.0-alpha.1d",
63
- "@automerge/vanillajs": "2.6.0-subduction.15"
63
+ "@automerge/vanillajs": "2.6.0-subduction.20"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsc",
package/src/externals.ts CHANGED
@@ -9,6 +9,9 @@ const externals = [
9
9
  "@automerge/automerge-repo-network-messagechannel",
10
10
  "@automerge/automerge-repo-storage-indexeddb",
11
11
  "@automerge/automerge-repo-keyhive",
12
+ "@automerge/automerge-repo-react-hooks",
13
+ "@automerge/automerge-repo-network-messagechannel",
14
+ "@automerge/automerge-repo-storage-indexeddb",
12
15
  "@automerge/automerge-subduction",
13
16
  "@automerge/automerge-subduction/slim",
14
17
  "@keyhive/keyhive",
@@ -8,7 +8,7 @@ import { SwLogger } from "./sw-logger.js";
8
8
  // Uses /slim to avoid top-level await (disallowed in service workers).
9
9
  // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
10
10
  // of bundling the ~3MB base64 string.
11
- import { initializeWasm } from "@automerge/automerge/slim";
11
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
12
12
  // eslint-disable-next-line
13
13
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
14
14
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
@@ -31,6 +31,7 @@ import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websoc
31
31
  // TEMPORARY: enable debug npm module in SW context (no localStorage available)
32
32
  let cachename = "default";
33
33
  let debugging = false;
34
+ const workerInstanceId = crypto.randomUUID();
34
35
 
35
36
  const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
36
37
  const RESOLVE_TIMEOUT_MS = 30_000;
@@ -166,27 +167,36 @@ self.addEventListener("message", async (event) => {
166
167
  const [pongPort] = event.ports;
167
168
  log("ping");
168
169
  if (pongPort) {
169
- pongPort.postMessage({ type: "pong" });
170
+ pongPort.postMessage({ type: "pong", workerInstanceId });
170
171
  log("pong");
171
172
  pongPort.close();
172
173
  } else if (event.source) {
173
- (event.source as unknown as Client).postMessage({ type: "pong" });
174
+ (event.source as unknown as Client).postMessage({
175
+ type: "pong",
176
+ workerInstanceId,
177
+ });
174
178
  log("pong");
175
179
  }
176
180
  } else if (event.data.type == "port") {
177
181
  log("received messagechannel");
178
182
  const [port] = event.ports;
179
183
  const source = event.source as Client | null;
184
+ const id = event.data.id;
180
185
  // event.waitUntil keeps the SW alive until the work completes. Without
181
186
  // it, the browser can terminate the SW the moment this synchronous block
182
187
  // returns, killing the in-flight wasm fetch.
183
188
  (event as unknown as FetchEvent).waitUntil(
184
189
  connectPort(port).then(
185
- () => source?.postMessage({ type: "port-ready" }),
190
+ () => source?.postMessage({ type: "port-ready", id, workerInstanceId }),
186
191
  (err) => {
187
192
  console.error("connectPort failed", err);
188
193
  // Tell the client we failed so it doesn't hang forever.
189
- source?.postMessage({ type: "port-failed", error: String(err) });
194
+ source?.postMessage({
195
+ type: "port-failed",
196
+ id,
197
+ error: String(err),
198
+ workerInstanceId,
199
+ });
190
200
  }
191
201
  )
192
202
  );
@@ -220,7 +230,7 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
220
230
  // Trim trailing empty path segment
221
231
  if (path.length && !path[path.length - 1]) path.pop();
222
232
 
223
- const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
233
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
224
234
  const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
225
235
 
226
236
  if (!heads) {
@@ -232,7 +242,16 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
232
242
  return Response.redirect(location, 307);
233
243
  }
234
244
 
235
- const rootHandle = await repo.find(maybeAutomergeUrl, { signal });
245
+ // Load by documentId only so we can verify the requested heads are actually
246
+ // in our local history. repo.find with a heads-bearing URL returns a view
247
+ // at those heads, which silently materializes garbage if we never synced them.
248
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
249
+ signal,
250
+ });
251
+ if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
252
+ return new Response("heads not found", { status: 404 });
253
+ }
254
+ const rootHandle = baseHandle.view(heads);
236
255
 
237
256
  const resolved = await resolvePath(
238
257
  repo,
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,50 +74,13 @@ function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
53
74
  });
54
75
  }
55
76
 
56
- export default async function setupServiceWorker(
57
- 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
- });
72
- }
73
- });
74
-
75
- const path = options?.path ?? "/service-worker.js";
76
- // No controller at this point means the page loaded without a service
77
- // worker — i.e. this is a first-time install (or a hard reload). We'll
78
- // reload after activation so the page boots with the SW in control of its
79
- // initial fetches.
80
- //const isFirstInstall = !navigator.serviceWorker.controller;
81
- const reg = await navigator.serviceWorker.register(path, { type: "module" });
82
-
83
- // If there's an update waiting or installing, wait for it to activate
84
- if (reg.installing || reg.waiting) {
85
- await waitForActive(reg);
86
- }
87
-
88
- const active = reg.active!;
89
- active.postMessage({ type: "debug", debug: debugging });
90
-
91
- // Wait for the controller to be available
92
- if (!navigator.serviceWorker.controller) {
93
- await new Promise<void>((resolve) => {
94
- navigator.serviceWorker.addEventListener(
95
- "controllerchange",
96
- () => resolve(),
97
- { once: true }
98
- );
99
- });
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");
100
84
  }
101
85
 
102
86
  // Send a MessagePort so the SW's repo can sync with clients, and wait for
@@ -104,6 +88,8 @@ export default async function setupServiceWorker(
104
88
  // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
105
89
  // of the other end's state, so it can't be used as a real readiness signal
106
90
  // on first install (when the SW still has to fetch wasm and build its repo).
91
+ const id = ++nextRepoChannelId;
92
+ let workerInstanceChanged = false;
107
93
  const { port1, port2 } = new MessageChannel();
108
94
  const swReady = new Promise<void>((resolve, reject) => {
109
95
  let timeout: ReturnType<typeof setTimeout>;
@@ -112,10 +98,17 @@ export default async function setupServiceWorker(
112
98
  navigator.serviceWorker.removeEventListener("message", listener);
113
99
  };
114
100
  const listener = (event: MessageEvent) => {
101
+ if (event.data?.id != null && event.data.id !== id) return;
115
102
  if (event.data?.type === "port-ready") {
103
+ workerInstanceChanged = updateServiceWorkerInstanceId(
104
+ event.data.workerInstanceId
105
+ );
116
106
  cleanup();
117
107
  resolve();
118
108
  } else if (event.data?.type === "port-failed") {
109
+ workerInstanceChanged = updateServiceWorkerInstanceId(
110
+ event.data.workerInstanceId
111
+ );
119
112
  cleanup();
120
113
  reject(new Error(`service worker init failed: ${event.data.error}`));
121
114
  }
@@ -129,7 +122,7 @@ export default async function setupServiceWorker(
129
122
  reject(new Error("service worker port-ready timeout"));
130
123
  }, 30_000);
131
124
  });
132
- navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
125
+ controller.postMessage({ type: "port", id }, [port2]);
133
126
  try {
134
127
  await swReady;
135
128
  } catch (err) {
@@ -138,22 +131,105 @@ export default async function setupServiceWorker(
138
131
  err instanceof Error ? err.message : err
139
132
  );
140
133
  }
134
+ return { port: port1, workerInstanceChanged };
135
+ }
136
+
137
+ export default async function setupServiceWorker(
138
+ options?: SetupServiceWorkerOptions
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
+ );
191
+ }
192
+ };
193
+
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.
198
+ const reg = await navigator.serviceWorker.register(path, { type: "module" });
199
+
200
+ // If there's an update waiting or installing, wait for it to activate
201
+ let active = reg.active;
202
+ if (reg.installing || reg.waiting) {
203
+ active = await waitForActive(reg);
204
+ }
205
+
206
+ configureServiceWorker(active);
207
+
208
+ // Wait for the controller to be available
209
+ if (!navigator.serviceWorker.controller) {
210
+ await new Promise<void>((resolve) => {
211
+ navigator.serviceWorker.addEventListener(
212
+ "controllerchange",
213
+ () => resolve(),
214
+ { once: true }
215
+ );
216
+ });
217
+ }
141
218
 
142
219
  // Keepalive — Chromium idles out service workers after ~30s of inactivity,
143
220
  // which tears down the in-memory Repo and forces a cold restart on the next
144
- // 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.
145
223
  setInterval(() => {
146
- navigator.serviceWorker.controller?.postMessage({ type: "ping" });
224
+ void pingServiceWorker();
147
225
  }, 20_000);
148
226
 
149
- // Reload on future SW updates (added after setup so the initial
150
- // 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).
151
229
  navigator.serviceWorker.addEventListener("controllerchange", function () {
152
- console.info(
153
- "%cnew service worker took control, reloading...",
154
- "color: pink; font-weight: bold"
155
- );
156
- location.reload();
230
+ void reconnectRepoChannels("took control").catch((err) => {
231
+ console.error("service worker reconnect failed", err);
232
+ });
157
233
  });
158
234
 
159
235
  console.log(
@@ -161,5 +237,20 @@ export default async function setupServiceWorker(
161
237
  "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
162
238
  );
163
239
 
164
- 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
+ };
165
256
  }
package/src/site.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  import * as plugins from "@inkandswitch/patchwork-plugins";
50
50
 
51
51
  import setupServiceWorker from "./setup.js";
52
+ import type { ServiceWorkerRepoChannelListener } from "./types.js";
52
53
  import { SwLogReader } from "./sw-logger.js";
53
54
  import debug from "debug";
54
55
  const log = debug("patchwork:bootloader:site");
@@ -59,7 +60,6 @@ declare global {
59
60
  Automerge: typeof import("@automerge/automerge");
60
61
  AutomergeRepo: typeof import("@automerge/automerge-repo");
61
62
  repo: Repo;
62
- getRepoChannel: () => MessagePort;
63
63
  patchwork: {
64
64
  repo: Repo;
65
65
  packages: ModuleWatcher;
@@ -70,6 +70,9 @@ declare global {
70
70
  tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
71
71
  exportLogs: () => Promise<string>;
72
72
  clearLogs: () => Promise<void>;
73
+ subscribeToRepoChannel: (
74
+ listener: ServiceWorkerRepoChannelListener
75
+ ) => Promise<() => void>;
73
76
  };
74
77
  };
75
78
  uncache: (match: string) => Promise<void>;
@@ -167,9 +170,16 @@ export async function bootPatchworkSite(
167
170
 
168
171
  const sw = await setupServiceWorker();
169
172
  if (!sw) throw new Error("Failed to set up service worker");
170
- const net = new MessageChannelNetworkAdapter(sw.port);
171
- repo.networkSubsystem.addNetworkAdapter(net);
172
- 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);
173
183
 
174
184
  installDevConsoleGlobals(repo);
175
185
  registerPatchworkViewElement({ repo });
@@ -208,7 +218,10 @@ export async function bootPatchworkSite(
208
218
  packages: moduleWatcher,
209
219
  plugins,
210
220
  accountDocHandle,
211
- sw: buildSwLogApi(),
221
+ sw: {
222
+ ...buildSwLogApi(),
223
+ subscribeToRepoChannel: sw.subscribeToRepoChannel,
224
+ },
212
225
  };
213
226
  window.uncache = uncache;
214
227
 
@@ -246,11 +259,6 @@ function installDevConsoleGlobals(repo: Repo): void {
246
259
  window.repo = repo;
247
260
  window.Automerge = Automerge;
248
261
  window.AutomergeRepo = AutomergeRepo;
249
- window.getRepoChannel = () => {
250
- const { port1, port2 } = new MessageChannel();
251
- navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
252
- return port1;
253
- };
254
262
  }
255
263
 
256
264
  function onModuleLoaded(name: string, mod: any): void {
@@ -329,7 +337,10 @@ function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
329
337
  });
330
338
  }
331
339
 
332
- function buildSwLogApi(): Window["patchwork"]["sw"] {
340
+ function buildSwLogApi(): Omit<
341
+ Window["patchwork"]["sw"],
342
+ "subscribeToRepoChannel"
343
+ > {
333
344
  return {
334
345
  printLogs: async (n = 200) => {
335
346
  const entries = await SwLogReader.tail(n);
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
+ };