@inkandswitch/patchwork-bootloader 0.3.0 → 0.3.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.
@@ -0,0 +1,85 @@
1
+ // Main-thread client for the module-loader worker (see module-loader-worker.ts).
2
+ //
3
+ // `importAutomergeModuleViaWorker` is wired into the ModuleWatcher in place of
4
+ // its default (direct, main-thread) package import. It asks the worker to
5
+ // import the package entry point and report which plugins it exports, then
6
+ // returns the same `{ plugins }` shape the watcher already feeds to
7
+ // `registerPlugins` — except each plugin's `load()` re-imports the package
8
+ // (pinned to the same heads) on this thread and runs the real plugin loader.
9
+
10
+ import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
11
+ import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
12
+
13
+ type Descriptor = Record<string, unknown> & { id?: string; type?: string };
14
+
15
+ type WorkerReply =
16
+ | { type: "descriptors"; id: number; descriptors: Descriptor[] }
17
+ | { type: "error"; id: number; error: string };
18
+
19
+ const WORKER_PATH = "/module-loader-worker.js";
20
+
21
+ let worker: Worker | undefined;
22
+ let nextRequestId = 1;
23
+ const pending = new Map<
24
+ number,
25
+ { resolve: (d: Descriptor[]) => void; reject: (e: Error) => void }
26
+ >();
27
+
28
+ function getWorker(): Worker {
29
+ if (worker) return worker;
30
+ worker = new Worker(WORKER_PATH, {
31
+ type: "module",
32
+ name: "patchwork-module-loader",
33
+ });
34
+ worker.addEventListener("message", (event: MessageEvent<WorkerReply>) => {
35
+ const data = event.data;
36
+ if (!data || (data.type !== "descriptors" && data.type !== "error")) return;
37
+ const entry = pending.get(data.id);
38
+ if (!entry) return;
39
+ pending.delete(data.id);
40
+ if (data.type === "descriptors") entry.resolve(data.descriptors);
41
+ else entry.reject(new Error(data.error));
42
+ });
43
+ worker.addEventListener("error", (event) => {
44
+ // An uncaught worker error can't be tied to a single request — fail every
45
+ // outstanding one so callers don't hang.
46
+ const error = new Error(
47
+ `module-loader worker error: ${event.message ?? "unknown"}`
48
+ );
49
+ for (const [, entry] of pending) entry.reject(error);
50
+ pending.clear();
51
+ });
52
+ return worker;
53
+ }
54
+
55
+ /** Ask the worker which plugins the package at `urlAtHeads` exports. */
56
+ function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
57
+ const id = nextRequestId++;
58
+ return new Promise<Descriptor[]>((resolve, reject) => {
59
+ pending.set(id, { resolve, reject });
60
+ getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
66
+ * worker, then return the `{ plugins }` shape with a main-thread `load()` per
67
+ * plugin that imports the package at heads and calls its real loader.
68
+ */
69
+ export async function importAutomergeModuleViaWorker(
70
+ urlAtHeads: string
71
+ ): Promise<{ plugins: Descriptor[] }> {
72
+ const url = urlAtHeads as AutomergeUrl;
73
+ const descriptors = await discoverDescriptors(url);
74
+ const plugins = descriptors.map((descriptor) => {
75
+ const { id, type } = descriptor;
76
+ // A plugin id is only unique within a plugin type, so both are needed to
77
+ // re-select the right plugin when its load() re-imports the package.
78
+ if (typeof id !== "string" || typeof type !== "string") return descriptor;
79
+ return {
80
+ ...descriptor,
81
+ load: () => importPluginFromFolderDocUrl(url, type, id),
82
+ };
83
+ });
84
+ return { plugins };
85
+ }
@@ -33,7 +33,49 @@ function log(...args: any[]) {
33
33
  );
34
34
  }
35
35
 
36
+ // ── Lifecycle diagnostics ──────────────────────────────────────────────
37
+ // [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
38
+ // handoffs. The SW can't read localStorage, so it always emits and forwards to
39
+ // the tab, which gates rendering on the live toggle. The SW holds no sync
40
+ // socket — observability only.
41
+
42
+ async function postToClients(message: unknown) {
43
+ const clients = await self.clients.matchAll({
44
+ type: "window",
45
+ includeUncontrolled: true,
46
+ });
47
+ for (const client of clients) client.postMessage(message);
48
+ }
49
+
50
+ function lifecycle(level: "info" | "warn", text: string) {
51
+ const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
52
+ console[level](msg);
53
+ void postToClients({ type: "sw-lifecycle", level, msg });
54
+ }
55
+
56
+ lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
57
+
58
+ self.addEventListener("error", (event) => {
59
+ const e = event as ErrorEvent;
60
+ lifecycle(
61
+ "warn",
62
+ `uncaught error: ${e.message}` +
63
+ (e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : "")
64
+ );
65
+ });
66
+
67
+ self.addEventListener("unhandledrejection", (event) => {
68
+ const reason = (event as PromiseRejectionEvent).reason;
69
+ lifecycle(
70
+ "warn",
71
+ `unhandled rejection: ${
72
+ reason instanceof Error ? reason.stack || reason.message : String(reason)
73
+ }`
74
+ );
75
+ });
76
+
36
77
  self.addEventListener("install", (event) => {
78
+ lifecycle("info", "install (skipWaiting)");
37
79
  // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
38
80
  // installed SW reliably jumps the "waiting" queue instead of stalling until
39
81
  // every old tab closes.
@@ -52,13 +94,30 @@ async function clearOldCaches() {
52
94
  }
53
95
 
54
96
  self.addEventListener("activate", (event) => {
55
- // Without waitUntil the activate event settles immediately and clients.claim()
56
- // runs detached — the new worker can be killed before it takes control, so
57
- // existing tabs keep talking to the old SW. Extend the event instead.
97
+ lifecycle("info", "activate (claiming clients)");
58
98
  (event as ExtendableEvent).waitUntil(
59
99
  (async () => {
60
100
  await clearOldCaches();
61
101
  await self.clients.claim();
102
+ // Pre-cache pages of already-open clients so they survive going offline
103
+ // before the next navigation.
104
+ const allClients = await self.clients.matchAll({ type: "window" });
105
+ const cache = await caches.open(cachename);
106
+ await Promise.all(
107
+ allClients.map(async (client) => {
108
+ try {
109
+ const existing = await cache.match(client.url);
110
+ if (!existing) {
111
+ const response = await fetch(client.url);
112
+ if (cacheableStatuses.includes(response.status)) {
113
+ await cache.put(client.url, response);
114
+ }
115
+ }
116
+ } catch {
117
+ // Network may be unavailable during activation
118
+ }
119
+ })
120
+ );
62
121
  })()
63
122
  );
64
123
  });
@@ -102,7 +161,15 @@ handoffChannel.addEventListener("message", (event) => {
102
161
  } else if (data?.type === "online") {
103
162
  // The automerge worker (re)started — re-broadcast anything still in
104
163
  // flight so requests that raced its boot aren't stranded.
105
- for (const { message } of pendingHandoffs.values()) {
164
+ const stranded = [...pendingHandoffs.values()];
165
+ if (stranded.length > 0) {
166
+ lifecycle(
167
+ "info",
168
+ `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
169
+ `in-flight asset handoff(s)`
170
+ );
171
+ }
172
+ for (const { message } of stranded) {
106
173
  log(`re-broadcasting handoff ${message.id} to the fresh worker`);
107
174
  handoffChannel.postMessage(message);
108
175
  }
@@ -132,6 +199,11 @@ function handoff(
132
199
  log(`broadcasting handoff request for cache ${cachename}`, message);
133
200
  handoffChannel.postMessage(message);
134
201
  const timeout = setTimeout(() => {
202
+ lifecycle(
203
+ "warn",
204
+ `asset handoff ${id} stranded: no reply from the automerge worker after ` +
205
+ `${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`
206
+ );
135
207
  resolvers.reject(
136
208
  new Error(
137
209
  `no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`
@@ -208,7 +280,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
208
280
  if (!cached) {
209
281
  return new Response(
210
282
  `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
211
- { status: 500 }
283
+ { status: 555 }
212
284
  );
213
285
  }
214
286
  log(`serving ${handoffURL} from cache ${cachename} after handoff`);
@@ -245,7 +317,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
245
317
  if (match) return match;
246
318
 
247
319
  return new Response(message, {
248
- status: 500,
320
+ status: 556,
249
321
  headers: { "content-type": "text/plain" },
250
322
  });
251
323
  }
package/src/setup.ts CHANGED
@@ -12,6 +12,34 @@ import debug from "debug";
12
12
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
13
13
  const workerDebugging = debug.enabled("patchwork:automergeworker");
14
14
 
15
+ // Diagnostic [lifecycle] logging, on by default. Disable via
16
+ // localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
17
+ const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
18
+ export function lifecycleLoggingEnabled(): boolean {
19
+ try {
20
+ const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
21
+ return v !== "off" && v !== "false" && v !== "0" && v !== "no";
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
27
+ // The SW can't read localStorage, so it always emits [lifecycle] markers and
28
+ // forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
29
+ let swLifecycleListenerInstalled = false;
30
+ function installServiceWorkerLogForwarding(): void {
31
+ if (swLifecycleListenerInstalled) return;
32
+ if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
33
+ swLifecycleListenerInstalled = true;
34
+ navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
35
+ const data = event.data;
36
+ if (data?.type !== "sw-lifecycle") return;
37
+ if (!lifecycleLoggingEnabled()) return;
38
+ const fn = (console as any)[data.level] ?? console.log;
39
+ fn(`[service-worker] ${data.msg}`);
40
+ });
41
+ }
42
+
15
43
  const key = "patchworkServiceWorkerCacheVersion";
16
44
  let nextRepoChannelId = 0;
17
45
 
@@ -67,7 +95,7 @@ function configureServiceWorker(sw: ServiceWorker | null) {
67
95
  let automergeWorkerPath = "/automerge-worker.js";
68
96
  let automergeWorker: SharedWorker | undefined;
69
97
 
70
- function getAutomergeWorker(): SharedWorker {
98
+ export function getAutomergeWorker(): SharedWorker {
71
99
  if (!automergeWorker) {
72
100
  automergeWorker = new SharedWorker(automergeWorkerPath, {
73
101
  name: "patchwork-automerge",
@@ -76,11 +104,107 @@ function getAutomergeWorker(): SharedWorker {
76
104
  // Control replies (port-ready &c) come back on this port, so it needs
77
105
  // start() — we listen with addEventListener, not onmessage.
78
106
  automergeWorker.port.start();
107
+ // Surface the SharedWorker's console output and uncaught errors in this
108
+ // tab's console (it has its own console that's awkward to find otherwise).
109
+ automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
110
+ if (event.data?.type !== "console") return;
111
+ const { level, args } = event.data;
112
+ // Gate forwarded [lifecycle] logs on the toggle too.
113
+ if (
114
+ !lifecycleLoggingEnabled() &&
115
+ typeof args?.[0] === "string" &&
116
+ args[0].includes("[lifecycle]")
117
+ ) {
118
+ return;
119
+ }
120
+ const fn = (console as any)[level] ?? console.log;
121
+ // The worker's logs (debug library, the worker's own log()) carry %c
122
+ // format directives in args[0] with CSS in the following args. Prefix
123
+ // the tag into the format string rather than as a separate positional,
124
+ // or the %c would no longer be in arg 0 and the CSS would print raw.
125
+ if (typeof args[0] === "string") {
126
+ fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
127
+ } else {
128
+ fn("[automerge-worker]", ...args);
129
+ }
130
+ });
79
131
  automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
132
+
133
+ installWorkerDeathDetection(automergeWorker);
80
134
  }
81
135
  return automergeWorker;
82
136
  }
83
137
 
138
+ /**
139
+ * Detect when the automerge SharedWorker dies or restarts: control-port close,
140
+ * worker error, changed instance id, or an unanswered heartbeat while the tab
141
+ * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
142
+ */
143
+ function installWorkerDeathDetection(worker: SharedWorker): void {
144
+ const stamp = () => new Date().toISOString();
145
+ const warn = (msg: string) => {
146
+ if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
147
+ };
148
+ const info = (msg: string) => {
149
+ if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
150
+ };
151
+
152
+ let instanceId: string | undefined;
153
+ let lastPongAt = Date.now();
154
+ let warnedUnresponsive = false;
155
+
156
+ worker.port.addEventListener("message", (event: MessageEvent) => {
157
+ const data = event.data;
158
+ if (data?.type !== "hello" && data?.type !== "pong") return;
159
+ if (data.type === "pong") {
160
+ lastPongAt = Date.now();
161
+ warnedUnresponsive = false;
162
+ }
163
+ if (instanceId === undefined) {
164
+ instanceId = data.instanceId;
165
+ info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
166
+ } else if (data.instanceId && data.instanceId !== instanceId) {
167
+ warn(
168
+ `automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
169
+ `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`
170
+ );
171
+ instanceId = data.instanceId;
172
+ }
173
+ });
174
+
175
+ // Fires when the SharedWorker is destroyed (where supported).
176
+ worker.port.addEventListener("close", () => {
177
+ warn("automerge SharedWorker control port CLOSED — worker terminated");
178
+ });
179
+
180
+ worker.addEventListener("error", event => {
181
+ warn(`automerge SharedWorker error: ${(event as ErrorEvent).message || event}`);
182
+ });
183
+
184
+ // A missed pong while the tab is visible means the worker likely died (an
185
+ // active tab keeps it alive); a miss while hidden is more likely suspension.
186
+ const HEARTBEAT_MS = 10_000;
187
+ const HEARTBEAT_TIMEOUT_MS = 25_000;
188
+ let seq = 0;
189
+ setInterval(() => {
190
+ try {
191
+ worker.port.postMessage({ type: "ping", id: ++seq });
192
+ } catch {
193
+ // Port already torn down — the "close" handler covers that case.
194
+ }
195
+ const silentMs = Date.now() - lastPongAt;
196
+ const visible =
197
+ typeof document === "undefined" || document.visibilityState === "visible";
198
+ if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
199
+ warnedUnresponsive = true;
200
+ warn(
201
+ `automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
202
+ `while tab visible — likely died/crashed`
203
+ );
204
+ }
205
+ }, HEARTBEAT_MS);
206
+ }
207
+
84
208
  export function connectClassicSync(
85
209
  server: string = readClassicSyncServer()
86
210
  ): Promise<void> {
@@ -104,11 +228,7 @@ export function connectClassicSync(
104
228
  if (event.data?.type === "connect-classic-sync-ready") {
105
229
  resolve();
106
230
  } else {
107
- reject(
108
- new Error(
109
- event.data?.error ?? "connect-classic-sync failed"
110
- )
111
- );
231
+ reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
112
232
  }
113
233
  };
114
234
  worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
@@ -188,11 +308,16 @@ function getRepoChannel(): MessagePort {
188
308
  export default async function setupServiceWorker(
189
309
  options?: SetupServiceWorkerOptions
190
310
  ): Promise<SetupServiceWorkerResult> {
311
+ // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
312
+ // install / activate markers from the controlling worker are rendered here.
313
+ installServiceWorkerLogForwarding();
314
+
191
315
  if (options?.workerPath) automergeWorkerPath = options.workerPath;
192
316
 
193
317
  // Start the automerge worker right away so it boots (wasm, repo) while the
194
318
  // service worker installs.
195
- getAutomergeWorker();
319
+ const shared = getAutomergeWorker();
320
+ // todo delete
196
321
 
197
322
  const path = options?.path ?? "/service-worker.js";
198
323
  // No controller at this point means the page loaded without a service
@@ -230,7 +355,16 @@ export default async function setupServiceWorker(
230
355
  "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
231
356
  );
232
357
 
358
+ // todon't
359
+ (window as any).killsw = () => {
360
+ if (automergeWorker) {
361
+ automergeWorker.port.close();
362
+ automergeWorker = undefined;
363
+ }
364
+ };
365
+
233
366
  return {
367
+ shared,
234
368
  connectClassicSync,
235
369
  getRepoChannel,
236
370
  async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
package/src/site.ts CHANGED
@@ -13,7 +13,6 @@
13
13
  */
14
14
  import {
15
15
  type DocHandle,
16
- IndexedDBStorageAdapter,
17
16
  initializeWasm,
18
17
  isValidAutomergeUrl,
19
18
  isValidDocumentId,
@@ -23,9 +22,9 @@ import {
23
22
  stringifyAutomergeUrl,
24
23
  type AutomergeUrl,
25
24
  type DocumentId,
26
- type StorageId,
27
25
  type UrlHeads,
28
26
  } from "@automerge/vanillajs/slim";
27
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
29
28
  import * as Automerge from "@automerge/automerge/slim";
30
29
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
31
30
  import {
@@ -50,6 +49,7 @@ const useKeyhiveSyncServer =
50
49
  typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
51
50
 
52
51
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
52
+ import { importAutomergeModuleViaWorker } from "./module-loader.js";
53
53
  import {
54
54
  openDocument,
55
55
  registerPatchworkViewElement,
@@ -66,7 +66,10 @@ import {
66
66
  } from "@inkandswitch/patchwork-plugins";
67
67
  import * as plugins from "@inkandswitch/patchwork-plugins";
68
68
 
69
- import setupServiceWorker from "./setup.js";
69
+ import setupServiceWorker, {
70
+ getAutomergeWorker,
71
+ lifecycleLoggingEnabled,
72
+ } from "./setup.js";
70
73
  import type { ServiceWorkerRepoChannelListener } from "./types.js";
71
74
  import debug from "debug";
72
75
  const log = debug("patchwork:bootloader:site");
@@ -144,12 +147,6 @@ export interface SiteConfig {
144
147
  */
145
148
  rootElementId?: string;
146
149
 
147
- /**
148
- * Storage IDs to subscribe to for remote-heads gossiping. Defaults to
149
- * Ink & Switch's production Subduction storage.
150
- */
151
- remoteStorageIds?: StorageId[];
152
-
153
150
  /**
154
151
  * When true, initialize keyhive for access control.
155
152
  * The Repo will use keyhive's network adapter, peerId, and idFactory
@@ -164,9 +161,6 @@ export interface BootResult {
164
161
  accountDocHandle: DocHandle<AccountDoc>;
165
162
  }
166
163
 
167
- const DEFAULT_REMOTE_STORAGE_ID =
168
- "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
169
-
170
164
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
171
165
  const BIG_PATCHWORK_HASH_REGEX =
172
166
  /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
@@ -191,59 +185,81 @@ export async function bootPatchworkSite(
191
185
  const defaultModuleSources = resolveDefaultModules(config);
192
186
  showLoadingAnimation();
193
187
  log(`booting`, config);
188
+ installLifecycleLogging();
194
189
  await initializeWasm(automergeWasm);
195
190
  initSubductionSync(subductionWasm);
196
191
 
192
+ log("enabling workers");
197
193
  const sw = await setupServiceWorker();
198
194
  if (!sw) throw new Error("Failed to set up service worker");
195
+ log("workers ready");
199
196
 
200
197
  let hive: AutomergeRepoKeyhive | undefined;
201
- // Get the initial automerge-worker port via subscribeToRepoChannel,
202
- // then pass it to keyhive init which wraps it in its own network adapter.
203
- let resolvePort!: (port: MessagePort) => void;
204
- const portPromise = new Promise<MessagePort>((r) => {
205
- resolvePort = r;
206
- });
207
- await sw.subscribeToRepoChannel(resolvePort);
208
- const workerPort = await portPromise;
209
-
210
198
  let repo: Repo;
211
- if (config.keyhive) {
212
- initKeyhiveWasm();
213
-
214
- ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
215
- createRepo: (config) => new Repo(config),
216
- storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
217
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
218
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
219
- automaticArchiveIngestion: true,
220
- cachingMode: "periodic",
221
- onlyShareWithHardcodedServerPeerId: false,
222
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
223
- // Defaults to "subduction".
224
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
225
- repo: {
226
- storage: new IndexedDBStorageAdapter(),
227
- enableRemoteHeadsGossiping: true,
228
- },
229
- }));
199
+
200
+ // If a Repo is already on `window` — an embedding context provided one before
201
+ // this entry ran — reuse it and its keyhive instead of standing up a fresh
202
+ // realm-local Repo, so we share the same documents and sync/keyhive context.
203
+ // Otherwise create our own below.
204
+ if (window.repo) {
205
+ log("using existing Repo from window");
206
+ repo = window.repo;
207
+ hive = window.hive;
230
208
  } else {
231
- repo = new Repo({
232
- network: [new MessageChannelNetworkAdapter(workerPort)],
233
- storage: new IndexedDBStorageAdapter(),
234
- async sharePolicy(peerId) {
235
- return peerId.includes("automerge-worker");
236
- },
237
- enableRemoteHeadsGossiping: true,
238
- peerId:
239
- `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
209
+ // Get the initial automerge-worker port via subscribeToRepoChannel,
210
+ // then pass it to keyhive init which wraps it in its own network adapter.
211
+ let resolvePort!: (port: MessagePort) => void;
212
+ const portPromise = new Promise<MessagePort>((r) => {
213
+ resolvePort = r;
240
214
  });
215
+ log("subscribing to repo channel");
216
+ await sw.subscribeToRepoChannel(resolvePort);
217
+ log("repo channel subscribed");
218
+ const workerPort = await portPromise;
219
+
220
+ if (config.keyhive) {
221
+ log("setting up keyhive");
222
+ initKeyhiveWasm();
223
+
224
+ ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
225
+ createRepo: (config) => new Repo(config),
226
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
227
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
228
+ networkAdapter: new MessageChannelNetworkAdapter(workerPort),
229
+ automaticArchiveIngestion: true,
230
+ cachingMode: "periodic",
231
+ onlyShareWithHardcodedServerPeerId: false,
232
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
233
+ // Defaults to "subduction".
234
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
235
+ repo: {
236
+ storage: new IndexedDBWorkerStorageAdapter(),
237
+ enableRemoteHeadsGossiping: true,
238
+ },
239
+ }));
240
+ log("keyhive setup complete");
241
+ } else {
242
+ log("creating repo");
243
+ repo = new Repo({
244
+ network: [new MessageChannelNetworkAdapter(workerPort)],
245
+ storage: new IndexedDBWorkerStorageAdapter(),
246
+ async sharePolicy(peerId) {
247
+ return peerId.includes("automerge-worker");
248
+ },
249
+ enableRemoteHeadsGossiping: true,
250
+ peerId:
251
+ `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
252
+ });
253
+ log("repo created");
254
+ }
241
255
  }
242
- repo.subscribeToRemotes(
243
- config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
244
- );
256
+ log("popping repo on window");
257
+ window.repo = repo;
258
+ log("await repo.networkSubsystem.whenReady()");
245
259
 
246
260
  await repo.networkSubsystem.whenReady();
261
+ log("networkSubsystem ready");
262
+
247
263
  if (hive) {
248
264
  (hive.networkAdapter as any).syncKeyhive?.();
249
265
  }
@@ -274,7 +290,10 @@ export async function bootPatchworkSite(
274
290
  repo,
275
291
  buildSystemSources(defaultModuleSources),
276
292
  onModuleLoaded,
277
- unregisterPlugins
293
+ unregisterPlugins,
294
+ // Discover an Automerge package's plugin descriptors off the main thread;
295
+ // each plugin's load() re-imports the package (at heads) on this thread.
296
+ importAutomergeModuleViaWorker
278
297
  );
279
298
 
280
299
  const accountDocHandle = (await resolveAccountHandle(repo, {
@@ -336,10 +355,7 @@ function isValidModuleSource(source: string): boolean {
336
355
  * built-in default bundle).
337
356
  */
338
357
  function resolveDefaultModules(config: SiteConfig): string[] {
339
- const builtin =
340
- config.defaultModules ??
341
- config.defaultModulesUrl ??
342
- [];
358
+ const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
343
359
  const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
344
360
  Boolean
345
361
  );
@@ -396,6 +412,46 @@ function installDevConsoleGlobals(
396
412
  window.getRepoChannel = getRepoChannel;
397
413
  }
398
414
 
415
+ /**
416
+ * Log this tab's Page Lifecycle + connectivity transitions (visibility,
417
+ * freeze, bfcache, online/offline) so they line up against the SharedWorker's
418
+ * sync-socket reaps. [lifecycle]-tagged, on by default.
419
+ */
420
+ function installLifecycleLogging(): void {
421
+ if (typeof document === "undefined") return;
422
+ const opts = { capture: true } as const;
423
+ const note = (label: string, extra?: unknown) => {
424
+ if (!lifecycleLoggingEnabled()) return;
425
+ const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
426
+ if (extra === undefined) console.info(msg);
427
+ else console.info(msg, extra);
428
+ };
429
+
430
+ document.addEventListener(
431
+ "visibilitychange",
432
+ () => note(`visibilitychange → ${document.visibilityState}`),
433
+ opts
434
+ );
435
+ document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
436
+ document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
437
+ window.addEventListener(
438
+ "pageshow",
439
+ e => note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
440
+ opts
441
+ );
442
+ window.addEventListener(
443
+ "pagehide",
444
+ e => note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
445
+ opts
446
+ );
447
+ window.addEventListener("online", () => note("online"), opts);
448
+ window.addEventListener("offline", () => note("offline"), opts);
449
+
450
+ note(
451
+ `lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`
452
+ );
453
+ }
454
+
399
455
  function onModuleLoaded(name: string, mod: any): void {
400
456
  if (Array.isArray(mod.plugins)) {
401
457
  log(
@@ -637,10 +693,7 @@ function installHashRouting(params: HashRoutingParams): void {
637
693
  if (isValidAutomergeUrl(hash as AutomergeUrl)) {
638
694
  const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
639
695
  window.location.hash = "";
640
- openDocument(
641
- rootElement,
642
- stringifyAutomergeUrl({ documentId, heads })
643
- );
696
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
644
697
  return;
645
698
  }
646
699
 
@@ -652,7 +705,8 @@ function installHashRouting(params: HashRoutingParams): void {
652
705
  const type = params.get("type");
653
706
  const frame = params.get("frame");
654
707
  if (frame) {
655
- const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
708
+ const docUrl =
709
+ params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
656
710
  if (
657
711
  rootElement.getAttribute("tool-id") !== frame ||
658
712
  rootElement.getAttribute("doc-url") !== docUrl