@inkandswitch/patchwork-bootloader 0.2.8 → 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.
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,14 +22,14 @@ 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 {
32
31
  initKeyhiveWasm,
33
- initializeAutomergeRepoKeyhive,
32
+ initializeAutomergeRepoKeyhiveWithRepo,
34
33
  type AutomergeRepoKeyhive,
35
34
  } from "@automerge/automerge-repo-keyhive";
36
35
  // eslint-disable-next-line
@@ -41,7 +40,16 @@ declare const __SITE_NAME__: string;
41
40
  const siteName =
42
41
  typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
43
42
 
43
+ // Sync-server selection for keyhive. Defaults to "subduction". Build with
44
+ // KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
45
+ // the automerge-worker (SharedWorker) selection so the tab and the SW grant relay
46
+ // access to the same server.
47
+ declare const __KEYHIVE_SYNC_SERVER__: boolean;
48
+ const useKeyhiveSyncServer =
49
+ typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
50
+
44
51
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
52
+ import { importAutomergeModuleViaWorker } from "./module-loader.js";
45
53
  import {
46
54
  openDocument,
47
55
  registerPatchworkViewElement,
@@ -58,7 +66,10 @@ import {
58
66
  } from "@inkandswitch/patchwork-plugins";
59
67
  import * as plugins from "@inkandswitch/patchwork-plugins";
60
68
 
61
- import setupServiceWorker from "./setup.js";
69
+ import setupServiceWorker, {
70
+ getAutomergeWorker,
71
+ lifecycleLoggingEnabled,
72
+ } from "./setup.js";
62
73
  import type { ServiceWorkerRepoChannelListener } from "./types.js";
63
74
  import debug from "debug";
64
75
  const log = debug("patchwork:bootloader:site");
@@ -89,16 +100,33 @@ declare global {
89
100
 
90
101
  export interface SiteConfig {
91
102
  /**
92
- * Automerge URL of the site's default module-settings document — the bundle
93
- * of tools every user of this site gets out of the box. Must contribute at
94
- * least a `patchwork:datatype` registration for `"account"` (typically the
95
- * one supplied by `@inkandswitch/patchwork-frame`).
103
+ * The site's default tool bundle — the tools every user of this site gets
104
+ * out of the box. Must collectively contribute at least a
105
+ * `patchwork:datatype` registration for `"account"` (typically the one
106
+ * supplied by `@inkandswitch/patchwork-frame`).
107
+ *
108
+ * Each entry is a *module-list source* and may be either:
109
+ * - an Automerge module-settings doc URL (`automerge:...`), which is
110
+ * live-reloaded, or
111
+ * - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
112
+ * static JSON manifest of the shape `{ modules: string[], branches? }`,
113
+ * fetched once at boot.
114
+ *
115
+ * The module URLs *inside* either kind of source may themselves be Automerge
116
+ * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
117
+ * mixed.
96
118
  *
97
119
  * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
98
- * another automerge: URL — useful for local development against an
99
- * unpublished tool set.
120
+ * another `automerge:` URL or manifest URL — useful for local development
121
+ * against an unpublished tool set.
122
+ */
123
+ defaultModules?: string | string[];
124
+
125
+ /**
126
+ * @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
127
+ * compatibility with existing sites.
100
128
  */
101
- defaultModulesUrl: AutomergeUrl;
129
+ defaultModulesUrl?: AutomergeUrl;
102
130
 
103
131
  /**
104
132
  * `localStorage` key under which this site remembers which account document
@@ -119,12 +147,6 @@ export interface SiteConfig {
119
147
  */
120
148
  rootElementId?: string;
121
149
 
122
- /**
123
- * Storage IDs to subscribe to for remote-heads gossiping. Defaults to
124
- * Ink & Switch's production Subduction storage.
125
- */
126
- remoteStorageIds?: StorageId[];
127
-
128
150
  /**
129
151
  * When true, initialize keyhive for access control.
130
152
  * The Repo will use keyhive's network adapter, peerId, and idFactory
@@ -139,9 +161,6 @@ export interface BootResult {
139
161
  accountDocHandle: DocHandle<AccountDoc>;
140
162
  }
141
163
 
142
- const DEFAULT_REMOTE_STORAGE_ID =
143
- "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
144
-
145
164
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
146
165
  const BIG_PATCHWORK_HASH_REGEX =
147
166
  /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
@@ -163,49 +182,67 @@ const [automergeWasm, subductionWasm] = await Promise.all([
163
182
  export async function bootPatchworkSite(
164
183
  config: SiteConfig
165
184
  ): Promise<BootResult> {
166
- const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
185
+ const defaultModuleSources = resolveDefaultModules(config);
167
186
  showLoadingAnimation();
168
187
  log(`booting`, config);
188
+ installLifecycleLogging();
169
189
  await initializeWasm(automergeWasm);
170
190
  initSubductionSync(subductionWasm);
171
191
 
192
+ log("enabling workers");
172
193
  const sw = await setupServiceWorker();
173
194
  if (!sw) throw new Error("Failed to set up service worker");
195
+ log("workers ready");
174
196
 
175
197
  let hive: AutomergeRepoKeyhive | undefined;
176
- // Get the initial automerge-worker port via subscribeToRepoChannel,
177
- // then pass it to keyhive init which wraps it in its own network adapter.
178
- let resolvePort!: (port: MessagePort) => void;
179
- const portPromise = new Promise<MessagePort>((r) => {
180
- resolvePort = r;
181
- });
182
- await sw.subscribeToRepoChannel(resolvePort);
183
- const workerPort = await portPromise;
184
-
185
- if (config.keyhive) {
186
- initKeyhiveWasm();
187
-
188
- hive = await initializeAutomergeRepoKeyhive({
189
- storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
190
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
191
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
192
- automaticArchiveIngestion: true,
193
- cachingMode: "periodic",
194
- onlyShareWithHardcodedServerPeerId: false,
198
+ let repo: Repo;
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;
208
+ } else {
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;
195
214
  });
196
- }
197
-
198
- const repo = hive
199
- ? new Repo({
200
- storage: new IndexedDBStorageAdapter(),
201
- enableRemoteHeadsGossiping: true,
202
- network: [hive.networkAdapter],
203
- peerId: hive.peerId,
204
- idFactory: hive.idFactory,
205
- })
206
- : new Repo({
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({
207
244
  network: [new MessageChannelNetworkAdapter(workerPort)],
208
- storage: new IndexedDBStorageAdapter(),
245
+ storage: new IndexedDBWorkerStorageAdapter(),
209
246
  async sharePolicy(peerId) {
210
247
  return peerId.includes("automerge-worker");
211
248
  },
@@ -213,11 +250,16 @@ export async function bootPatchworkSite(
213
250
  peerId:
214
251
  `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
215
252
  });
216
- repo.subscribeToRemotes(
217
- config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
218
- );
253
+ log("repo created");
254
+ }
255
+ }
256
+ log("popping repo on window");
257
+ window.repo = repo;
258
+ log("await repo.networkSubsystem.whenReady()");
219
259
 
220
260
  await repo.networkSubsystem.whenReady();
261
+ log("networkSubsystem ready");
262
+
221
263
  if (hive) {
222
264
  (hive.networkAdapter as any).syncKeyhive?.();
223
265
  }
@@ -246,9 +288,12 @@ export async function bootPatchworkSite(
246
288
  // is added lazily once it appears on the account doc — see below.
247
289
  const moduleWatcher = new ModuleWatcher(
248
290
  repo,
249
- { system: defaultModulesUrl },
291
+ buildSystemSources(defaultModuleSources),
250
292
  onModuleLoaded,
251
- 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
252
297
  );
253
298
 
254
299
  const accountDocHandle = (await resolveAccountHandle(repo, {
@@ -290,21 +335,67 @@ export async function bootPatchworkSite(
290
335
 
291
336
  // ─── Internals ──────────────────────────────────────────────────────────
292
337
 
293
- function resolveDefaultModulesUrl(builtin: AutomergeUrl): AutomergeUrl {
338
+ /**
339
+ * A module-list source is valid if it is an Automerge URL or looks like an
340
+ * HTTP(S)/site-relative manifest URL.
341
+ */
342
+ function isValidModuleSource(source: string): boolean {
343
+ if (isValidAutomergeUrl(source)) return true;
344
+ return (
345
+ source.startsWith("/") ||
346
+ source.startsWith("http://") ||
347
+ source.startsWith("https://") ||
348
+ source.startsWith("./")
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Resolve the site's default module-list sources, honouring the
354
+ * `localStorage.defaultToolsUrl` dev override (which replaces the entire
355
+ * built-in default bundle).
356
+ */
357
+ function resolveDefaultModules(config: SiteConfig): string[] {
358
+ const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
359
+ const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
360
+ Boolean
361
+ );
362
+
294
363
  const override = globalThis.localStorage?.getItem("defaultToolsUrl");
295
- if (!override) return builtin;
296
- if (isValidAutomergeUrl(override)) {
297
- if (override !== builtin) {
298
- console.info(
299
- `using defaultToolsUrl override from localStorage: ${override}`
300
- );
364
+ if (override) {
365
+ if (isValidModuleSource(override)) {
366
+ if (!builtinList.includes(override)) {
367
+ console.info(
368
+ `using defaultToolsUrl override from localStorage: ${override}`
369
+ );
370
+ }
371
+ return [override];
301
372
  }
302
- return override;
373
+ console.warn(
374
+ `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
375
+ );
303
376
  }
304
- console.warn(
305
- `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
306
- );
307
- return builtin;
377
+
378
+ if (builtinList.length === 0) {
379
+ throw new Error(
380
+ "bootPatchworkSite: no default module sources configured (set `defaultModules`)"
381
+ );
382
+ }
383
+ return builtinList;
384
+ }
385
+
386
+ /**
387
+ * Turn an ordered list of module-list sources into the name-keyed map the
388
+ * ModuleWatcher expects. The first source keeps the canonical `system` name;
389
+ * additional sources get suffixed names. None may be `user` (reserved for the
390
+ * per-account settings doc, which has branch-override precedence).
391
+ */
392
+ function buildSystemSources(sources: string[]): Record<string, string> {
393
+ const map: Record<string, string> = {};
394
+ sources.forEach((source, index) => {
395
+ const name = index === 0 ? "system" : `system-${index}`;
396
+ map[name] = source;
397
+ });
398
+ return map;
308
399
  }
309
400
 
310
401
  function installDevConsoleGlobals(
@@ -321,6 +412,46 @@ function installDevConsoleGlobals(
321
412
  window.getRepoChannel = getRepoChannel;
322
413
  }
323
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
+
324
455
  function onModuleLoaded(name: string, mod: any): void {
325
456
  if (Array.isArray(mod.plugins)) {
326
457
  log(
@@ -372,7 +503,7 @@ function primeRootElement(
372
503
  const initialParams = new URLSearchParams(location.hash.slice(1));
373
504
  if (initialParams.has("frame")) {
374
505
  rootElement.setAttribute("tool-id", initialParams.get("frame")!);
375
- const docId = initialParams.get("doc");
506
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
376
507
  const docUrl = docId
377
508
  ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
378
509
  : accountDocHandle.url;
@@ -558,15 +689,24 @@ function installHashRouting(params: HashRoutingParams): void {
558
689
  return;
559
690
  }
560
691
 
692
+ // Bare automerge URL in hash: /#automerge:<documentId>
693
+ if (isValidAutomergeUrl(hash as AutomergeUrl)) {
694
+ const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
695
+ window.location.hash = "";
696
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
697
+ return;
698
+ }
699
+
561
700
  const params = new URLSearchParams(hash);
562
- const documentId = params.get("doc");
701
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
563
702
  const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
564
703
  const toolId = params.get("tool");
565
704
  const title = params.get("title");
566
705
  const type = params.get("type");
567
706
  const frame = params.get("frame");
568
707
  if (frame) {
569
- const docUrl = params.get("doc") ?? accountDocHandle.url;
708
+ const docUrl =
709
+ params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
570
710
  if (
571
711
  rootElement.getAttribute("tool-id") !== frame ||
572
712
  rootElement.getAttribute("doc-url") !== docUrl
package/src/types.ts CHANGED
@@ -6,6 +6,13 @@
6
6
  */
7
7
  export const HANDOFF_CHANNEL = "@patchwork/handoff";
8
8
 
9
+ /**
10
+ * BroadcastChannel on which the automerge shared worker announces remote
11
+ * heads it learns about from the sync server. Any tab can listen to stay
12
+ * informed of sync progress without repo-to-repo gossiping.
13
+ */
14
+ export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
15
+
9
16
  /**
10
17
  * The special URL to resolve, plus enough of the {@link Request} the service
11
18
  * worker is holding that the automerge worker can construct one that
@@ -105,6 +112,8 @@ export type ServiceWorkerRepoChannelListener = (
105
112
  ) => void | Promise<void>;
106
113
 
107
114
  export type SetupServiceWorkerResult = {
115
+ shared?: SharedWorker;
116
+ kill?: () => void;
108
117
  /** Open a classic Automerge sync WebSocket from the automerge worker. */
109
118
  connectClassicSync: (server?: string) => Promise<void>;
110
119
  subscribeToRepoChannel: (
@@ -14,6 +14,10 @@ const workers = [
14
14
  specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
15
15
  fileName: "automerge-worker.js",
16
16
  },
17
+ {
18
+ specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
19
+ fileName: "module-loader-worker.js",
20
+ },
17
21
  ];
18
22
 
19
23
  export function serviceworker(): Plugin {