@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/dist/setup.js CHANGED
@@ -2,6 +2,37 @@ import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-conf
2
2
  import debug from "debug";
3
3
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
4
4
  const workerDebugging = debug.enabled("patchwork:automergeworker");
5
+ // Diagnostic [lifecycle] logging, on by default. Disable via
6
+ // localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
7
+ const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
8
+ export function lifecycleLoggingEnabled() {
9
+ try {
10
+ const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
11
+ return v !== "off" && v !== "false" && v !== "0" && v !== "no";
12
+ }
13
+ catch {
14
+ return true;
15
+ }
16
+ }
17
+ // The SW can't read localStorage, so it always emits [lifecycle] markers and
18
+ // forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
19
+ let swLifecycleListenerInstalled = false;
20
+ function installServiceWorkerLogForwarding() {
21
+ if (swLifecycleListenerInstalled)
22
+ return;
23
+ if (typeof navigator === "undefined" || !navigator.serviceWorker)
24
+ return;
25
+ swLifecycleListenerInstalled = true;
26
+ navigator.serviceWorker.addEventListener("message", (event) => {
27
+ const data = event.data;
28
+ if (data?.type !== "sw-lifecycle")
29
+ return;
30
+ if (!lifecycleLoggingEnabled())
31
+ return;
32
+ const fn = console[data.level] ?? console.log;
33
+ fn(`[service-worker] ${data.msg}`);
34
+ });
35
+ }
5
36
  const key = "patchworkServiceWorkerCacheVersion";
6
37
  let nextRepoChannelId = 0;
7
38
  function bumpServiceWorkerCacheVersion() {
@@ -48,7 +79,7 @@ function configureServiceWorker(sw) {
48
79
  // port; it talks to the service worker over a BroadcastChannel.
49
80
  let automergeWorkerPath = "/automerge-worker.js";
50
81
  let automergeWorker;
51
- function getAutomergeWorker() {
82
+ export function getAutomergeWorker() {
52
83
  if (!automergeWorker) {
53
84
  automergeWorker = new SharedWorker(automergeWorkerPath, {
54
85
  name: "patchwork-automerge",
@@ -57,10 +88,99 @@ function getAutomergeWorker() {
57
88
  // Control replies (port-ready &c) come back on this port, so it needs
58
89
  // start() — we listen with addEventListener, not onmessage.
59
90
  automergeWorker.port.start();
91
+ // Surface the SharedWorker's console output and uncaught errors in this
92
+ // tab's console (it has its own console that's awkward to find otherwise).
93
+ automergeWorker.port.addEventListener("message", (event) => {
94
+ if (event.data?.type !== "console")
95
+ return;
96
+ const { level, args } = event.data;
97
+ // Gate forwarded [lifecycle] logs on the toggle too.
98
+ if (!lifecycleLoggingEnabled() &&
99
+ typeof args?.[0] === "string" &&
100
+ args[0].includes("[lifecycle]")) {
101
+ return;
102
+ }
103
+ const fn = console[level] ?? console.log;
104
+ // The worker's logs (debug library, the worker's own log()) carry %c
105
+ // format directives in args[0] with CSS in the following args. Prefix
106
+ // the tag into the format string rather than as a separate positional,
107
+ // or the %c would no longer be in arg 0 and the CSS would print raw.
108
+ if (typeof args[0] === "string") {
109
+ fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
110
+ }
111
+ else {
112
+ fn("[automerge-worker]", ...args);
113
+ }
114
+ });
60
115
  automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
116
+ installWorkerDeathDetection(automergeWorker);
61
117
  }
62
118
  return automergeWorker;
63
119
  }
120
+ /**
121
+ * Detect when the automerge SharedWorker dies or restarts: control-port close,
122
+ * worker error, changed instance id, or an unanswered heartbeat while the tab
123
+ * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
124
+ */
125
+ function installWorkerDeathDetection(worker) {
126
+ const stamp = () => new Date().toISOString();
127
+ const warn = (msg) => {
128
+ if (lifecycleLoggingEnabled())
129
+ console.warn(`[lifecycle] ${stamp()} ${msg}`);
130
+ };
131
+ const info = (msg) => {
132
+ if (lifecycleLoggingEnabled())
133
+ console.info(`[lifecycle] ${stamp()} ${msg}`);
134
+ };
135
+ let instanceId;
136
+ let lastPongAt = Date.now();
137
+ let warnedUnresponsive = false;
138
+ worker.port.addEventListener("message", (event) => {
139
+ const data = event.data;
140
+ if (data?.type !== "hello" && data?.type !== "pong")
141
+ return;
142
+ if (data.type === "pong") {
143
+ lastPongAt = Date.now();
144
+ warnedUnresponsive = false;
145
+ }
146
+ if (instanceId === undefined) {
147
+ instanceId = data.instanceId;
148
+ info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
149
+ }
150
+ else if (data.instanceId && data.instanceId !== instanceId) {
151
+ warn(`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
152
+ `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
153
+ instanceId = data.instanceId;
154
+ }
155
+ });
156
+ // Fires when the SharedWorker is destroyed (where supported).
157
+ worker.port.addEventListener("close", () => {
158
+ warn("automerge SharedWorker control port CLOSED — worker terminated");
159
+ });
160
+ worker.addEventListener("error", event => {
161
+ warn(`automerge SharedWorker error: ${event.message || event}`);
162
+ });
163
+ // A missed pong while the tab is visible means the worker likely died (an
164
+ // active tab keeps it alive); a miss while hidden is more likely suspension.
165
+ const HEARTBEAT_MS = 10_000;
166
+ const HEARTBEAT_TIMEOUT_MS = 25_000;
167
+ let seq = 0;
168
+ setInterval(() => {
169
+ try {
170
+ worker.port.postMessage({ type: "ping", id: ++seq });
171
+ }
172
+ catch {
173
+ // Port already torn down — the "close" handler covers that case.
174
+ }
175
+ const silentMs = Date.now() - lastPongAt;
176
+ const visible = typeof document === "undefined" || document.visibilityState === "visible";
177
+ if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
178
+ warnedUnresponsive = true;
179
+ warn(`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
180
+ `while tab visible — likely died/crashed`);
181
+ }
182
+ }, HEARTBEAT_MS);
183
+ }
64
184
  export function connectClassicSync(server = readClassicSyncServer()) {
65
185
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
66
186
  if (!/^wss?:\/\//.test(url)) {
@@ -155,11 +275,15 @@ function getRepoChannel() {
155
275
  return port1;
156
276
  }
157
277
  export default async function setupServiceWorker(options) {
278
+ // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
279
+ // install / activate markers from the controlling worker are rendered here.
280
+ installServiceWorkerLogForwarding();
158
281
  if (options?.workerPath)
159
282
  automergeWorkerPath = options.workerPath;
160
283
  // Start the automerge worker right away so it boots (wasm, repo) while the
161
284
  // service worker installs.
162
- getAutomergeWorker();
285
+ const shared = getAutomergeWorker();
286
+ // todo delete
163
287
  const path = options?.path ?? "/service-worker.js";
164
288
  // No controller at this point means the page loaded without a service
165
289
  // worker — i.e. this is a first-time install (or a hard reload). Wait for
@@ -183,7 +307,15 @@ export default async function setupServiceWorker(options) {
183
307
  configureServiceWorker(navigator.serviceWorker.controller);
184
308
  });
185
309
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
310
+ // todon't
311
+ window.killsw = () => {
312
+ if (automergeWorker) {
313
+ automergeWorker.port.close();
314
+ automergeWorker = undefined;
315
+ }
316
+ };
186
317
  return {
318
+ shared,
187
319
  connectClassicSync,
188
320
  getRepoChannel,
189
321
  async subscribeToRepoChannel(listener) {
package/dist/site.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * site's `main.ts`. Non-UI consumers should import the package default (which
12
12
  * only does SW registration and the automerge-worker handoff).
13
13
  */
14
- import { type DocHandle, Repo, type AutomergeUrl, type StorageId } from "@automerge/vanillajs/slim";
14
+ import { type DocHandle, Repo, type AutomergeUrl } from "@automerge/vanillajs/slim";
15
15
  import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
16
16
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
17
17
  import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
@@ -40,16 +40,32 @@ declare global {
40
40
  }
41
41
  export interface SiteConfig {
42
42
  /**
43
- * Automerge URL of the site's default module-settings document — the bundle
44
- * of tools every user of this site gets out of the box. Must contribute at
45
- * least a `patchwork:datatype` registration for `"account"` (typically the
46
- * one supplied by `@inkandswitch/patchwork-frame`).
43
+ * The site's default tool bundle — the tools every user of this site gets
44
+ * out of the box. Must collectively contribute at least a
45
+ * `patchwork:datatype` registration for `"account"` (typically the one
46
+ * supplied by `@inkandswitch/patchwork-frame`).
47
+ *
48
+ * Each entry is a *module-list source* and may be either:
49
+ * - an Automerge module-settings doc URL (`automerge:...`), which is
50
+ * live-reloaded, or
51
+ * - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
52
+ * static JSON manifest of the shape `{ modules: string[], branches? }`,
53
+ * fetched once at boot.
54
+ *
55
+ * The module URLs *inside* either kind of source may themselves be Automerge
56
+ * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
57
+ * mixed.
47
58
  *
48
59
  * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
49
- * another automerge: URL — useful for local development against an
50
- * unpublished tool set.
60
+ * another `automerge:` URL or manifest URL — useful for local development
61
+ * against an unpublished tool set.
62
+ */
63
+ defaultModules?: string | string[];
64
+ /**
65
+ * @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
66
+ * compatibility with existing sites.
51
67
  */
52
- defaultModulesUrl: AutomergeUrl;
68
+ defaultModulesUrl?: AutomergeUrl;
53
69
  /**
54
70
  * `localStorage` key under which this site remembers which account document
55
71
  * belongs to the current user. Sites sharing an origin MUST use distinct
@@ -66,11 +82,6 @@ export interface SiteConfig {
66
82
  * Defaults to `"root"`.
67
83
  */
68
84
  rootElementId?: string;
69
- /**
70
- * Storage IDs to subscribe to for remote-heads gossiping. Defaults to
71
- * Ink & Switch's production Subduction storage.
72
- */
73
- remoteStorageIds?: StorageId[];
74
85
  /**
75
86
  * When true, initialize keyhive for access control.
76
87
  * The Repo will use keyhive's network adapter, peerId, and idFactory
package/dist/site.js CHANGED
@@ -11,23 +11,25 @@
11
11
  * site's `main.ts`. Non-UI consumers should import the package default (which
12
12
  * only does SW registration and the automerge-worker handoff).
13
13
  */
14
- import { IndexedDBStorageAdapter, initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
14
+ import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
15
16
  import * as Automerge from "@automerge/automerge/slim";
16
17
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
17
- import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
18
+ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@automerge/automerge-repo-keyhive";
18
19
  // eslint-disable-next-line
19
20
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
20
21
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
21
22
  const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
23
+ const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
22
24
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
25
+ import { importAutomergeModuleViaWorker } from "./module-loader.js";
23
26
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
24
27
  import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
25
28
  import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
26
29
  import * as plugins from "@inkandswitch/patchwork-plugins";
27
- import setupServiceWorker from "./setup.js";
30
+ import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
28
31
  import debug from "debug";
29
32
  const log = debug("patchwork:bootloader:site");
30
- const DEFAULT_REMOTE_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d";
31
33
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
32
34
  const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
33
35
  const [automergeWasm, subductionWasm] = await Promise.all([
@@ -44,53 +46,79 @@ const [automergeWasm, subductionWasm] = await Promise.all([
44
46
  * do additional wiring after boot.
45
47
  */
46
48
  export async function bootPatchworkSite(config) {
47
- const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
49
+ const defaultModuleSources = resolveDefaultModules(config);
48
50
  showLoadingAnimation();
49
51
  log(`booting`, config);
52
+ installLifecycleLogging();
50
53
  await initializeWasm(automergeWasm);
51
54
  initSubductionSync(subductionWasm);
55
+ log("enabling workers");
52
56
  const sw = await setupServiceWorker();
53
57
  if (!sw)
54
58
  throw new Error("Failed to set up service worker");
59
+ log("workers ready");
55
60
  let hive;
56
- // Get the initial automerge-worker port via subscribeToRepoChannel,
57
- // then pass it to keyhive init which wraps it in its own network adapter.
58
- let resolvePort;
59
- const portPromise = new Promise((r) => {
60
- resolvePort = r;
61
- });
62
- await sw.subscribeToRepoChannel(resolvePort);
63
- const workerPort = await portPromise;
64
- if (config.keyhive) {
65
- initKeyhiveWasm();
66
- hive = await initializeAutomergeRepoKeyhive({
67
- storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
68
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
69
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
70
- automaticArchiveIngestion: true,
71
- cachingMode: "periodic",
72
- onlyShareWithHardcodedServerPeerId: false,
73
- });
61
+ let repo;
62
+ // If a Repo is already on `window` an embedding context provided one before
63
+ // this entry ran — reuse it and its keyhive instead of standing up a fresh
64
+ // realm-local Repo, so we share the same documents and sync/keyhive context.
65
+ // Otherwise create our own below.
66
+ if (window.repo) {
67
+ log("using existing Repo from window");
68
+ repo = window.repo;
69
+ hive = window.hive;
74
70
  }
75
- const repo = hive
76
- ? new Repo({
77
- storage: new IndexedDBStorageAdapter(),
78
- enableRemoteHeadsGossiping: true,
79
- network: [hive.networkAdapter],
80
- peerId: hive.peerId,
81
- idFactory: hive.idFactory,
82
- })
83
- : new Repo({
84
- network: [new MessageChannelNetworkAdapter(workerPort)],
85
- storage: new IndexedDBStorageAdapter(),
86
- async sharePolicy(peerId) {
87
- return peerId.includes("automerge-worker");
88
- },
89
- enableRemoteHeadsGossiping: true,
90
- peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
71
+ else {
72
+ // Get the initial automerge-worker port via subscribeToRepoChannel,
73
+ // then pass it to keyhive init which wraps it in its own network adapter.
74
+ let resolvePort;
75
+ const portPromise = new Promise((r) => {
76
+ resolvePort = r;
91
77
  });
92
- repo.subscribeToRemotes(config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]);
78
+ log("subscribing to repo channel");
79
+ await sw.subscribeToRepoChannel(resolvePort);
80
+ log("repo channel subscribed");
81
+ const workerPort = await portPromise;
82
+ if (config.keyhive) {
83
+ log("setting up keyhive");
84
+ initKeyhiveWasm();
85
+ ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
86
+ createRepo: (config) => new Repo(config),
87
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
88
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
89
+ networkAdapter: new MessageChannelNetworkAdapter(workerPort),
90
+ automaticArchiveIngestion: true,
91
+ cachingMode: "periodic",
92
+ onlyShareWithHardcodedServerPeerId: false,
93
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
94
+ // Defaults to "subduction".
95
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
96
+ repo: {
97
+ storage: new IndexedDBWorkerStorageAdapter(),
98
+ enableRemoteHeadsGossiping: true,
99
+ },
100
+ }));
101
+ log("keyhive setup complete");
102
+ }
103
+ else {
104
+ log("creating repo");
105
+ repo = new Repo({
106
+ network: [new MessageChannelNetworkAdapter(workerPort)],
107
+ storage: new IndexedDBWorkerStorageAdapter(),
108
+ async sharePolicy(peerId) {
109
+ return peerId.includes("automerge-worker");
110
+ },
111
+ enableRemoteHeadsGossiping: true,
112
+ peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
113
+ });
114
+ log("repo created");
115
+ }
116
+ }
117
+ log("popping repo on window");
118
+ window.repo = repo;
119
+ log("await repo.networkSubsystem.whenReady()");
93
120
  await repo.networkSubsystem.whenReady();
121
+ log("networkSubsystem ready");
94
122
  if (hive) {
95
123
  hive.networkAdapter.syncKeyhive?.();
96
124
  }
@@ -110,7 +138,10 @@ export async function bootPatchworkSite(config) {
110
138
  // `resolveAccountHandle` below has something to await on (the `account`
111
139
  // datatype lives in that bundle today). The user's own module-settings URL
112
140
  // is added lazily once it appears on the account doc — see below.
113
- const moduleWatcher = new ModuleWatcher(repo, { system: defaultModulesUrl }, onModuleLoaded, unregisterPlugins);
141
+ const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
142
+ // Discover an Automerge package's plugin descriptors off the main thread;
143
+ // each plugin's load() re-imports the package (at heads) on this thread.
144
+ importAutomergeModuleViaWorker);
114
145
  const accountDocHandle = (await resolveAccountHandle(repo, {
115
146
  storageKey: config.accountStorageKey,
116
147
  hive,
@@ -142,18 +173,54 @@ export async function bootPatchworkSite(config) {
142
173
  return { repo, moduleWatcher, accountDocHandle };
143
174
  }
144
175
  // ─── Internals ──────────────────────────────────────────────────────────
145
- function resolveDefaultModulesUrl(builtin) {
176
+ /**
177
+ * A module-list source is valid if it is an Automerge URL or looks like an
178
+ * HTTP(S)/site-relative manifest URL.
179
+ */
180
+ function isValidModuleSource(source) {
181
+ if (isValidAutomergeUrl(source))
182
+ return true;
183
+ return (source.startsWith("/") ||
184
+ source.startsWith("http://") ||
185
+ source.startsWith("https://") ||
186
+ source.startsWith("./"));
187
+ }
188
+ /**
189
+ * Resolve the site's default module-list sources, honouring the
190
+ * `localStorage.defaultToolsUrl` dev override (which replaces the entire
191
+ * built-in default bundle).
192
+ */
193
+ function resolveDefaultModules(config) {
194
+ const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
195
+ const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
146
196
  const override = globalThis.localStorage?.getItem("defaultToolsUrl");
147
- if (!override)
148
- return builtin;
149
- if (isValidAutomergeUrl(override)) {
150
- if (override !== builtin) {
151
- console.info(`using defaultToolsUrl override from localStorage: ${override}`);
197
+ if (override) {
198
+ if (isValidModuleSource(override)) {
199
+ if (!builtinList.includes(override)) {
200
+ console.info(`using defaultToolsUrl override from localStorage: ${override}`);
201
+ }
202
+ return [override];
152
203
  }
153
- return override;
204
+ console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
205
+ }
206
+ if (builtinList.length === 0) {
207
+ throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
154
208
  }
155
- console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
156
- return builtin;
209
+ return builtinList;
210
+ }
211
+ /**
212
+ * Turn an ordered list of module-list sources into the name-keyed map the
213
+ * ModuleWatcher expects. The first source keeps the canonical `system` name;
214
+ * additional sources get suffixed names. None may be `user` (reserved for the
215
+ * per-account settings doc, which has branch-override precedence).
216
+ */
217
+ function buildSystemSources(sources) {
218
+ const map = {};
219
+ sources.forEach((source, index) => {
220
+ const name = index === 0 ? "system" : `system-${index}`;
221
+ map[name] = source;
222
+ });
223
+ return map;
157
224
  }
158
225
  function installDevConsoleGlobals(repo, hive, getRepoChannel) {
159
226
  window.repo = repo;
@@ -164,6 +231,33 @@ function installDevConsoleGlobals(repo, hive, getRepoChannel) {
164
231
  }
165
232
  window.getRepoChannel = getRepoChannel;
166
233
  }
234
+ /**
235
+ * Log this tab's Page Lifecycle + connectivity transitions (visibility,
236
+ * freeze, bfcache, online/offline) so they line up against the SharedWorker's
237
+ * sync-socket reaps. [lifecycle]-tagged, on by default.
238
+ */
239
+ function installLifecycleLogging() {
240
+ if (typeof document === "undefined")
241
+ return;
242
+ const opts = { capture: true };
243
+ const note = (label, extra) => {
244
+ if (!lifecycleLoggingEnabled())
245
+ return;
246
+ const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
247
+ if (extra === undefined)
248
+ console.info(msg);
249
+ else
250
+ console.info(msg, extra);
251
+ };
252
+ document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
253
+ document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
254
+ document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
255
+ window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
256
+ window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
257
+ window.addEventListener("online", () => note("online"), opts);
258
+ window.addEventListener("offline", () => note("offline"), opts);
259
+ note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
260
+ }
167
261
  function onModuleLoaded(name, mod) {
168
262
  if (Array.isArray(mod.plugins)) {
169
263
  log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
@@ -202,7 +296,7 @@ function primeRootElement(rootElement, accountDocHandle) {
202
296
  const initialParams = new URLSearchParams(location.hash.slice(1));
203
297
  if (initialParams.has("frame")) {
204
298
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
205
- const docId = initialParams.get("doc");
299
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
206
300
  const docUrl = docId
207
301
  ? stringifyAutomergeUrl({ documentId: docId })
208
302
  : accountDocHandle.url;
@@ -368,15 +462,22 @@ function installHashRouting(params) {
368
462
  }
369
463
  return;
370
464
  }
465
+ // Bare automerge URL in hash: /#automerge:<documentId>
466
+ if (isValidAutomergeUrl(hash)) {
467
+ const { documentId, heads } = parseAutomergeUrl(hash);
468
+ window.location.hash = "";
469
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
470
+ return;
471
+ }
371
472
  const params = new URLSearchParams(hash);
372
- const documentId = params.get("doc");
473
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
373
474
  const heads = params.get("heads")?.split("|");
374
475
  const toolId = params.get("tool");
375
476
  const title = params.get("title");
376
477
  const type = params.get("type");
377
478
  const frame = params.get("frame");
378
479
  if (frame) {
379
- const docUrl = params.get("doc") ?? accountDocHandle.url;
480
+ const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
380
481
  if (rootElement.getAttribute("tool-id") !== frame ||
381
482
  rootElement.getAttribute("doc-url") !== docUrl) {
382
483
  rootElement.setAttribute("tool-id", frame);
package/dist/types.d.ts CHANGED
@@ -5,6 +5,12 @@
5
5
  * reintroduced when either of them restarts — and so tabs can listen in.
6
6
  */
7
7
  export declare const HANDOFF_CHANNEL = "@patchwork/handoff";
8
+ /**
9
+ * BroadcastChannel on which the automerge shared worker announces remote
10
+ * heads it learns about from the sync server. Any tab can listen to stay
11
+ * informed of sync progress without repo-to-repo gossiping.
12
+ */
13
+ export declare const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
8
14
  /**
9
15
  * The special URL to resolve, plus enough of the {@link Request} the service
10
16
  * worker is holding that the automerge worker can construct one that
@@ -93,6 +99,8 @@ export type SetupServiceWorkerOptions = {
93
99
  };
94
100
  export type ServiceWorkerRepoChannelListener = (port: MessagePort) => void | Promise<void>;
95
101
  export type SetupServiceWorkerResult = {
102
+ shared?: SharedWorker;
103
+ kill?: () => void;
96
104
  /** Open a classic Automerge sync WebSocket from the automerge worker. */
97
105
  connectClassicSync: (server?: string) => Promise<void>;
98
106
  subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
package/dist/types.js CHANGED
@@ -5,3 +5,9 @@
5
5
  * reintroduced when either of them restarts — and so tabs can listen in.
6
6
  */
7
7
  export const HANDOFF_CHANNEL = "@patchwork/handoff";
8
+ /**
9
+ * BroadcastChannel on which the automerge shared worker announces remote
10
+ * heads it learns about from the sync server. Any tab can listen to stay
11
+ * informed of sync progress without repo-to-repo gossiping.
12
+ */
13
+ export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
@@ -12,6 +12,10 @@ const workers = [
12
12
  specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
13
13
  fileName: "automerge-worker.js",
14
14
  },
15
+ {
16
+ specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
17
+ fileName: "module-loader-worker.js",
18
+ },
15
19
  ];
16
20
  export function serviceworker() {
17
21
  const entryIds = new Set();
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "devDependencies": {
8
- "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.1c",
8
+ "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
9
9
  "esbuild": "^0.23.1",
10
10
  "rollup": "^4.61.1"
11
11
  },
@@ -30,6 +30,10 @@
30
30
  "import": "./dist/automerge-worker.js",
31
31
  "types": "./dist/automerge-worker.d.ts"
32
32
  },
33
+ "./module-loader-worker": {
34
+ "import": "./dist/module-loader-worker.js",
35
+ "types": "./dist/module-loader-worker.d.ts"
36
+ },
33
37
  "./site": {
34
38
  "import": "./dist/site.js",
35
39
  "types": "./dist/site.d.ts"
@@ -41,28 +45,28 @@
41
45
  },
42
46
  "dependencies": {
43
47
  "@automerge/automerge": "3.3.0-fragments.1",
44
- "@automerge/automerge-repo": "2.6.0-subduction.29",
45
- "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.29",
46
- "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.29",
47
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.29",
48
- "@automerge/automerge-subduction": "0.15.0",
49
- "@automerge/vanillajs": "2.6.0-subduction.29",
50
- "@keyhive/keyhive": "0.0.0-alpha.56",
48
+ "@automerge/automerge-repo": "2.6.0-subduction.39",
49
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.39",
50
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.39",
51
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.39",
52
+ "@automerge/automerge-subduction": "0.16.0",
53
+ "@automerge/vanillajs": "2.6.0-subduction.39",
54
+ "@keyhive/keyhive": "0.1.0-alpha.5",
51
55
  "@types/debug": "^4.1.13",
52
56
  "debug": "^4.4.3",
53
57
  "resolve.exports": "^2.0.3",
54
58
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
55
59
  "tinyargs": "^0.1.4",
56
- "@inkandswitch/patchwork-filesystem": "^0.0.8",
57
- "@inkandswitch/patchwork-plugins": "^0.0.11",
60
+ "@inkandswitch/patchwork-filesystem": "^0.1.1",
61
+ "@inkandswitch/patchwork-elements": "^2.0.0",
58
62
  "@inkandswitch/patchwork-providers": "^0.3.0",
59
- "@inkandswitch/patchwork-elements": "^2.0.0"
63
+ "@inkandswitch/patchwork-plugins": "^0.0.11"
60
64
  },
61
65
  "peerDependencies": {
62
66
  "@automerge/automerge": "3.3.0-fragments.1",
63
- "@automerge/automerge-repo": "2.6.0-subduction.29",
64
- "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.1c",
65
- "@automerge/vanillajs": "2.6.0-subduction.29"
67
+ "@automerge/automerge-repo": "2.6.0-subduction.39",
68
+ "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
69
+ "@automerge/vanillajs": "2.6.0-subduction.39"
66
70
  },
67
71
  "scripts": {
68
72
  "build": "tsc",