@inkandswitch/patchwork-bootloader 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,7 @@ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
23
23
  import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
24
24
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
25
25
  import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
26
- import { initializeAutomergeRepoKeyhiveRust, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
26
+ import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
27
27
  import { HANDOFF_CHANNEL, } from "./types.js";
28
28
  let debugging = false;
29
29
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
@@ -37,9 +37,6 @@ if (useKeyhiveSyncServer) {
37
37
  KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
38
38
  };
39
39
  }
40
- // keyhive.sync.automerge.org's keyhive identity (issuer d7f41e6f…).
41
- const KEYHIVE_SYNC_SERVER_PEER_ID = "1/Qebw9O69oH8T/ejYMhFup0tNBh69I3ytGqsmIl358=";
42
- const KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON = '{"Rotate":{"payload":{"old":[73,163,230,244,111,233,153,119,133,211,134,237,111,36,52,131,22,50,54,144,150,45,227,235,128,36,33,217,190,198,55,75],"new":[109,115,204,144,178,114,182,238,113,124,4,139,249,76,220,44,128,104,194,68,187,184,82,241,94,145,104,198,159,122,186,43]},"issuer":[215,244,30,111,15,78,235,218,7,241,63,222,141,131,33,22,234,116,180,208,97,235,210,55,202,209,170,178,98,37,223,159],"signature":[178,64,85,76,51,199,196,151,129,14,191,53,127,191,34,223,97,238,95,109,118,179,152,17,205,188,204,177,116,166,147,231,192,201,48,137,19,214,180,45,108,104,34,8,14,63,115,139,215,142,4,179,233,89,150,218,174,168,107,23,8,109,228,6]}}';
43
40
  const SUBDUCTION_ENDPOINTS = [
44
41
  useKeyhiveSyncServer
45
42
  ? "wss://keyhive.sync.automerge.org"
@@ -122,40 +119,23 @@ function getRepoHive() {
122
119
  return { repo };
123
120
  }
124
121
  initKeyhiveWasm();
125
- const keyhiveStorage = new IndexedDBStorageAdapter(`${siteName}-keyhive`);
126
- // Keyhive bootstrap needs to run before Repo creation but
127
- // the adapter needs the subduction instance from the Repo.
128
- // A deferred promise breaks the cycle.
129
- let resolveRepoSubduction;
130
- const repoSubductionPromise = new Promise((resolve) => {
131
- resolveRepoSubduction = resolve;
132
- });
133
- // We use the Rust variant of Keyhive initialization to talk
134
- // to the Rust keyhive-enabled subduction sync server.
135
- const hive = await initializeAutomergeRepoKeyhiveRust({
136
- storage: keyhiveStorage,
122
+ // ARK variant for talking to the keyhive-enabled subduction sync server.
123
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
124
+ createRepo: (config) => new Repo(config),
125
+ storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
137
126
  peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
138
- subduction: repoSubductionPromise,
139
127
  automaticArchiveIngestion: true,
140
128
  cachingMode: "periodic",
141
- ...(useKeyhiveSyncServer
142
- ? {
143
- serverPeerId: KEYHIVE_SYNC_SERVER_PEER_ID,
144
- serverContactCardJson: KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON,
145
- }
146
- : {}),
147
- });
148
- const signer = await hive.constructSubductionSigner();
149
- const repo = new Repo({
150
- storage: new IndexedDBStorageAdapter(),
151
- signer,
152
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
153
- peerId: hive.peerId,
154
- enableRemoteHeadsGossiping: true,
155
- idFactory: hive.idFactory,
129
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
130
+ // which pairs the contact card with the matching peer id. Omitting it
131
+ // defaults to "subduction".
132
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
133
+ repo: {
134
+ storage: new IndexedDBStorageAdapter(),
135
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
136
+ enableRemoteHeadsGossiping: true,
137
+ },
156
138
  });
157
- repo.subduction.then(resolveRepoSubduction);
158
- hive.linkRepo(repo);
159
139
  self.repo = repo;
160
140
  self.hive = hive;
161
141
  log("repo constructed, waiting for network subsystem");
@@ -239,6 +219,7 @@ async function connectPort(port, connection) {
239
219
  }
240
220
  });
241
221
  keyhiveNetworkAdapter.on("ingest-remote", () => {
222
+ hive.notifySameAgentKeyhiveChange();
242
223
  hive.networkAdapter.syncKeyhive?.();
243
224
  repo.shareConfigChanged();
244
225
  });
package/dist/externals.js CHANGED
@@ -19,6 +19,7 @@ const externals = [
19
19
  "@inkandswitch/patchwork-elements",
20
20
  "@inkandswitch/patchwork-filesystem",
21
21
  "@inkandswitch/patchwork-plugins",
22
+ "@inkandswitch/patchwork-providers",
22
23
  // sad
23
24
  "@codemirror/state",
24
25
  "@codemirror/view",
@@ -163,7 +163,9 @@ self.addEventListener("fetch", (fetchEvent) => {
163
163
  if (response) {
164
164
  if (cacheableStatuses.includes(response.status) &&
165
165
  response.url.match(/^https?\:/)) {
166
- await cache.put(request, response.clone());
166
+ await cache.put(request, response.clone()).catch((error) => {
167
+ log(`error caching ${request.url} in ${cachename}`, error);
168
+ });
167
169
  }
168
170
  else {
169
171
  log(`skipping uncacheable response code from cache: ${response.status} for ${response.url}`);
package/dist/site.d.ts CHANGED
@@ -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
package/dist/site.js CHANGED
@@ -14,11 +14,12 @@
14
14
  import { IndexedDBStorageAdapter, initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
15
  import * as Automerge from "@automerge/automerge/slim";
16
16
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
17
- import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
17
+ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@automerge/automerge-repo-keyhive";
18
18
  // eslint-disable-next-line
19
19
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
20
20
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
21
21
  const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
22
+ const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
22
23
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
23
24
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
24
25
  import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
@@ -44,7 +45,7 @@ const [automergeWasm, subductionWasm] = await Promise.all([
44
45
  * do additional wiring after boot.
45
46
  */
46
47
  export async function bootPatchworkSite(config) {
47
- const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
48
+ const defaultModuleSources = resolveDefaultModules(config);
48
49
  showLoadingAnimation();
49
50
  log(`booting`, config);
50
51
  await initializeWasm(automergeWasm);
@@ -61,26 +62,28 @@ export async function bootPatchworkSite(config) {
61
62
  });
62
63
  await sw.subscribeToRepoChannel(resolvePort);
63
64
  const workerPort = await portPromise;
65
+ let repo;
64
66
  if (config.keyhive) {
65
67
  initKeyhiveWasm();
66
- hive = await initializeAutomergeRepoKeyhive({
68
+ ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
69
+ createRepo: (config) => new Repo(config),
67
70
  storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
68
71
  peerIdSuffix: siteName + Math.random().toString(36).slice(2),
69
72
  networkAdapter: new MessageChannelNetworkAdapter(workerPort),
70
73
  automaticArchiveIngestion: true,
71
74
  cachingMode: "periodic",
72
75
  onlyShareWithHardcodedServerPeerId: false,
73
- });
76
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
77
+ // Defaults to "subduction".
78
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
79
+ repo: {
80
+ storage: new IndexedDBStorageAdapter(),
81
+ enableRemoteHeadsGossiping: true,
82
+ },
83
+ }));
74
84
  }
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({
85
+ else {
86
+ repo = new Repo({
84
87
  network: [new MessageChannelNetworkAdapter(workerPort)],
85
88
  storage: new IndexedDBStorageAdapter(),
86
89
  async sharePolicy(peerId) {
@@ -89,6 +92,7 @@ export async function bootPatchworkSite(config) {
89
92
  enableRemoteHeadsGossiping: true,
90
93
  peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
91
94
  });
95
+ }
92
96
  repo.subscribeToRemotes(config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]);
93
97
  await repo.networkSubsystem.whenReady();
94
98
  if (hive) {
@@ -110,7 +114,7 @@ export async function bootPatchworkSite(config) {
110
114
  // `resolveAccountHandle` below has something to await on (the `account`
111
115
  // datatype lives in that bundle today). The user's own module-settings URL
112
116
  // is added lazily once it appears on the account doc — see below.
113
- const moduleWatcher = new ModuleWatcher(repo, { system: defaultModulesUrl }, onModuleLoaded, unregisterPlugins);
117
+ const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins);
114
118
  const accountDocHandle = (await resolveAccountHandle(repo, {
115
119
  storageKey: config.accountStorageKey,
116
120
  hive,
@@ -142,18 +146,56 @@ export async function bootPatchworkSite(config) {
142
146
  return { repo, moduleWatcher, accountDocHandle };
143
147
  }
144
148
  // ─── Internals ──────────────────────────────────────────────────────────
145
- function resolveDefaultModulesUrl(builtin) {
149
+ /**
150
+ * A module-list source is valid if it is an Automerge URL or looks like an
151
+ * HTTP(S)/site-relative manifest URL.
152
+ */
153
+ function isValidModuleSource(source) {
154
+ if (isValidAutomergeUrl(source))
155
+ return true;
156
+ return (source.startsWith("/") ||
157
+ source.startsWith("http://") ||
158
+ source.startsWith("https://") ||
159
+ source.startsWith("./"));
160
+ }
161
+ /**
162
+ * Resolve the site's default module-list sources, honouring the
163
+ * `localStorage.defaultToolsUrl` dev override (which replaces the entire
164
+ * built-in default bundle).
165
+ */
166
+ function resolveDefaultModules(config) {
167
+ const builtin = config.defaultModules ??
168
+ config.defaultModulesUrl ??
169
+ [];
170
+ const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
146
171
  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}`);
172
+ if (override) {
173
+ if (isValidModuleSource(override)) {
174
+ if (!builtinList.includes(override)) {
175
+ console.info(`using defaultToolsUrl override from localStorage: ${override}`);
176
+ }
177
+ return [override];
152
178
  }
153
- return override;
179
+ console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
180
+ }
181
+ if (builtinList.length === 0) {
182
+ throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
154
183
  }
155
- console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
156
- return builtin;
184
+ return builtinList;
185
+ }
186
+ /**
187
+ * Turn an ordered list of module-list sources into the name-keyed map the
188
+ * ModuleWatcher expects. The first source keeps the canonical `system` name;
189
+ * additional sources get suffixed names. None may be `user` (reserved for the
190
+ * per-account settings doc, which has branch-override precedence).
191
+ */
192
+ function buildSystemSources(sources) {
193
+ const map = {};
194
+ sources.forEach((source, index) => {
195
+ const name = index === 0 ? "system" : `system-${index}`;
196
+ map[name] = source;
197
+ });
198
+ return map;
157
199
  }
158
200
  function installDevConsoleGlobals(repo, hive, getRepoChannel) {
159
201
  window.repo = repo;
@@ -202,7 +244,7 @@ function primeRootElement(rootElement, accountDocHandle) {
202
244
  const initialParams = new URLSearchParams(location.hash.slice(1));
203
245
  if (initialParams.has("frame")) {
204
246
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
205
- const docId = initialParams.get("doc");
247
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
206
248
  const docUrl = docId
207
249
  ? stringifyAutomergeUrl({ documentId: docId })
208
250
  : accountDocHandle.url;
@@ -368,15 +410,22 @@ function installHashRouting(params) {
368
410
  }
369
411
  return;
370
412
  }
413
+ // Bare automerge URL in hash: /#automerge:<documentId>
414
+ if (isValidAutomergeUrl(hash)) {
415
+ const { documentId, heads } = parseAutomergeUrl(hash);
416
+ window.location.hash = "";
417
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
418
+ return;
419
+ }
371
420
  const params = new URLSearchParams(hash);
372
- const documentId = params.get("doc");
421
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
373
422
  const heads = params.get("heads")?.split("|");
374
423
  const toolId = params.get("tool");
375
424
  const title = params.get("title");
376
425
  const type = params.get("type");
377
426
  const frame = params.get("frame");
378
427
  if (frame) {
379
- const docUrl = params.get("doc") ?? accountDocHandle.url;
428
+ const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
380
429
  if (rootElement.getAttribute("tool-id") !== frame ||
381
430
  rootElement.getAttribute("doc-url") !== docUrl) {
382
431
  rootElement.setAttribute("tool-id", frame);
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.0",
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.6c",
9
9
  "esbuild": "^0.23.1",
10
10
  "rollup": "^4.61.1"
11
11
  },
@@ -41,28 +41,28 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@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",
44
+ "@automerge/automerge-repo": "2.6.0-subduction.34",
45
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.34",
46
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.34",
47
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.34",
48
+ "@automerge/automerge-subduction": "0.16.0",
49
+ "@automerge/vanillajs": "2.6.0-subduction.34",
50
+ "@keyhive/keyhive": "0.1.0-alpha.3",
51
51
  "@types/debug": "^4.1.13",
52
52
  "debug": "^4.4.3",
53
53
  "resolve.exports": "^2.0.3",
54
54
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
55
55
  "tinyargs": "^0.1.4",
56
- "@inkandswitch/patchwork-filesystem": "^0.0.8",
56
+ "@inkandswitch/patchwork-filesystem": "^0.1.0",
57
57
  "@inkandswitch/patchwork-plugins": "^0.0.11",
58
58
  "@inkandswitch/patchwork-providers": "^0.3.0",
59
59
  "@inkandswitch/patchwork-elements": "^2.0.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@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"
63
+ "@automerge/automerge-repo": "2.6.0-subduction.34",
64
+ "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.6c",
65
+ "@automerge/vanillajs": "2.6.0-subduction.34"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsc",
@@ -34,7 +34,7 @@ import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-index
34
34
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
35
35
  import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
36
36
  import {
37
- initializeAutomergeRepoKeyhiveRust,
37
+ initializeAutomergeRepoKeyhiveRustWithRepo,
38
38
  initKeyhiveWasm,
39
39
  type AutomergeRepoKeyhiveRust,
40
40
  } from "@automerge/automerge-repo-keyhive";
@@ -68,12 +68,6 @@ if (useKeyhiveSyncServer) {
68
68
  };
69
69
  }
70
70
 
71
- // keyhive.sync.automerge.org's keyhive identity (issuer d7f41e6f…).
72
- const KEYHIVE_SYNC_SERVER_PEER_ID =
73
- "1/Qebw9O69oH8T/ejYMhFup0tNBh69I3ytGqsmIl358=";
74
- const KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON =
75
- '{"Rotate":{"payload":{"old":[73,163,230,244,111,233,153,119,133,211,134,237,111,36,52,131,22,50,54,144,150,45,227,235,128,36,33,217,190,198,55,75],"new":[109,115,204,144,178,114,182,238,113,124,4,139,249,76,220,44,128,104,194,68,187,184,82,241,94,145,104,198,159,122,186,43]},"issuer":[215,244,30,111,15,78,235,218,7,241,63,222,141,131,33,22,234,116,180,208,97,235,210,55,202,209,170,178,98,37,223,159],"signature":[178,64,85,76,51,199,196,151,129,14,191,53,127,191,34,223,97,238,95,109,118,179,152,17,205,188,204,177,116,166,147,231,192,201,48,137,19,214,180,45,108,104,34,8,14,63,115,139,215,142,4,179,233,89,150,218,174,168,107,23,8,109,228,6]}}';
76
-
77
71
  const SUBDUCTION_ENDPOINTS = [
78
72
  useKeyhiveSyncServer
79
73
  ? "wss://keyhive.sync.automerge.org"
@@ -183,48 +177,26 @@ function getRepoHive() {
183
177
  }
184
178
 
185
179
  initKeyhiveWasm();
186
- const keyhiveStorage = new IndexedDBStorageAdapter(`${siteName}-keyhive`);
187
-
188
- // Keyhive bootstrap needs to run before Repo creation but
189
- // the adapter needs the subduction instance from the Repo.
190
- // A deferred promise breaks the cycle.
191
- let resolveRepoSubduction!: (s: any) => void;
192
- const repoSubductionPromise = new Promise((resolve) => {
193
- resolveRepoSubduction = resolve;
194
- });
195
180
 
196
- // We use the Rust variant of Keyhive initialization to talk
197
- // to the Rust keyhive-enabled subduction sync server.
198
- const hive = await initializeAutomergeRepoKeyhiveRust({
199
- storage: keyhiveStorage,
181
+ // ARK variant for talking to the keyhive-enabled subduction sync server.
182
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
183
+ createRepo: (config) => new Repo(config),
184
+ storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
200
185
  peerIdSuffix:
201
186
  `${siteName}-worker` + Math.random().toString(36).slice(2),
202
- subduction: repoSubductionPromise as any,
203
187
  automaticArchiveIngestion: true,
204
188
  cachingMode: "periodic",
205
- ...(useKeyhiveSyncServer
206
- ? {
207
- serverPeerId: KEYHIVE_SYNC_SERVER_PEER_ID as any,
208
- serverContactCardJson: KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON,
209
- }
210
- : {}),
211
- });
212
-
213
- const signer = await hive.constructSubductionSigner();
214
-
215
- const repo = new Repo({
216
- storage: new IndexedDBStorageAdapter(),
217
- signer,
218
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
219
- peerId: hive.peerId,
220
- enableRemoteHeadsGossiping: true,
221
- idFactory: hive.idFactory,
189
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
190
+ // which pairs the contact card with the matching peer id. Omitting it
191
+ // defaults to "subduction".
192
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
193
+ repo: {
194
+ storage: new IndexedDBStorageAdapter(),
195
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
196
+ enableRemoteHeadsGossiping: true,
197
+ },
222
198
  });
223
199
 
224
- repo.subduction.then(resolveRepoSubduction);
225
-
226
- hive.linkRepo(repo);
227
-
228
200
  (self as any).repo = repo;
229
201
  (self as any).hive = hive;
230
202
  log("repo constructed, waiting for network subsystem");
@@ -336,6 +308,7 @@ async function connectPort(port: MessagePort, connection: Connection) {
336
308
  });
337
309
 
338
310
  (keyhiveNetworkAdapter as any).on("ingest-remote", () => {
311
+ hive.notifySameAgentKeyhiveChange();
339
312
  (hive.networkAdapter as any).syncKeyhive?.();
340
313
  repo.shareConfigChanged();
341
314
  });
package/src/externals.ts CHANGED
@@ -19,6 +19,7 @@ const externals = [
19
19
  "@inkandswitch/patchwork-elements",
20
20
  "@inkandswitch/patchwork-filesystem",
21
21
  "@inkandswitch/patchwork-plugins",
22
+ "@inkandswitch/patchwork-providers",
22
23
 
23
24
  // sad
24
25
  "@codemirror/state",
@@ -220,7 +220,9 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
220
220
  cacheableStatuses.includes(response.status) &&
221
221
  response.url.match(/^https?\:/)
222
222
  ) {
223
- await cache.put(request, response.clone());
223
+ await cache.put(request, response.clone()).catch((error) => {
224
+ log(`error caching ${request.url} in ${cachename}`, error);
225
+ });
224
226
  } else {
225
227
  log(
226
228
  `skipping uncacheable response code from cache: ${response.status} for ${response.url}`
package/src/site.ts CHANGED
@@ -30,7 +30,7 @@ import * as Automerge from "@automerge/automerge/slim";
30
30
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
31
31
  import {
32
32
  initKeyhiveWasm,
33
- initializeAutomergeRepoKeyhive,
33
+ initializeAutomergeRepoKeyhiveWithRepo,
34
34
  type AutomergeRepoKeyhive,
35
35
  } from "@automerge/automerge-repo-keyhive";
36
36
  // eslint-disable-next-line
@@ -41,6 +41,14 @@ declare const __SITE_NAME__: string;
41
41
  const siteName =
42
42
  typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
43
43
 
44
+ // Sync-server selection for keyhive. Defaults to "subduction". Build with
45
+ // KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
46
+ // the automerge-worker (SharedWorker) selection so the tab and the SW grant relay
47
+ // access to the same server.
48
+ declare const __KEYHIVE_SYNC_SERVER__: boolean;
49
+ const useKeyhiveSyncServer =
50
+ typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
51
+
44
52
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
45
53
  import {
46
54
  openDocument,
@@ -89,16 +97,33 @@ declare global {
89
97
 
90
98
  export interface SiteConfig {
91
99
  /**
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`).
100
+ * The site's default tool bundle — the tools every user of this site gets
101
+ * out of the box. Must collectively contribute at least a
102
+ * `patchwork:datatype` registration for `"account"` (typically the one
103
+ * supplied by `@inkandswitch/patchwork-frame`).
104
+ *
105
+ * Each entry is a *module-list source* and may be either:
106
+ * - an Automerge module-settings doc URL (`automerge:...`), which is
107
+ * live-reloaded, or
108
+ * - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
109
+ * static JSON manifest of the shape `{ modules: string[], branches? }`,
110
+ * fetched once at boot.
111
+ *
112
+ * The module URLs *inside* either kind of source may themselves be Automerge
113
+ * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
114
+ * mixed.
96
115
  *
97
116
  * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
98
- * another automerge: URL — useful for local development against an
99
- * unpublished tool set.
117
+ * another `automerge:` URL or manifest URL — useful for local development
118
+ * against an unpublished tool set.
119
+ */
120
+ defaultModules?: string | string[];
121
+
122
+ /**
123
+ * @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
124
+ * compatibility with existing sites.
100
125
  */
101
- defaultModulesUrl: AutomergeUrl;
126
+ defaultModulesUrl?: AutomergeUrl;
102
127
 
103
128
  /**
104
129
  * `localStorage` key under which this site remembers which account document
@@ -163,7 +188,7 @@ const [automergeWasm, subductionWasm] = await Promise.all([
163
188
  export async function bootPatchworkSite(
164
189
  config: SiteConfig
165
190
  ): Promise<BootResult> {
166
- const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
191
+ const defaultModuleSources = resolveDefaultModules(config);
167
192
  showLoadingAnimation();
168
193
  log(`booting`, config);
169
194
  await initializeWasm(automergeWasm);
@@ -182,37 +207,38 @@ export async function bootPatchworkSite(
182
207
  await sw.subscribeToRepoChannel(resolvePort);
183
208
  const workerPort = await portPromise;
184
209
 
210
+ let repo: Repo;
185
211
  if (config.keyhive) {
186
212
  initKeyhiveWasm();
187
213
 
188
- hive = await initializeAutomergeRepoKeyhive({
214
+ ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
215
+ createRepo: (config) => new Repo(config),
189
216
  storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
190
217
  peerIdSuffix: siteName + Math.random().toString(36).slice(2),
191
218
  networkAdapter: new MessageChannelNetworkAdapter(workerPort),
192
219
  automaticArchiveIngestion: true,
193
220
  cachingMode: "periodic",
194
221
  onlyShareWithHardcodedServerPeerId: false,
195
- });
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({
207
- network: [new MessageChannelNetworkAdapter(workerPort)],
222
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
223
+ // Defaults to "subduction".
224
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
225
+ repo: {
208
226
  storage: new IndexedDBStorageAdapter(),
209
- async sharePolicy(peerId) {
210
- return peerId.includes("automerge-worker");
211
- },
212
227
  enableRemoteHeadsGossiping: true,
213
- peerId:
214
- `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
215
- });
228
+ },
229
+ }));
230
+ } 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,
240
+ });
241
+ }
216
242
  repo.subscribeToRemotes(
217
243
  config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
218
244
  );
@@ -246,7 +272,7 @@ export async function bootPatchworkSite(
246
272
  // is added lazily once it appears on the account doc — see below.
247
273
  const moduleWatcher = new ModuleWatcher(
248
274
  repo,
249
- { system: defaultModulesUrl },
275
+ buildSystemSources(defaultModuleSources),
250
276
  onModuleLoaded,
251
277
  unregisterPlugins
252
278
  );
@@ -290,21 +316,70 @@ export async function bootPatchworkSite(
290
316
 
291
317
  // ─── Internals ──────────────────────────────────────────────────────────
292
318
 
293
- function resolveDefaultModulesUrl(builtin: AutomergeUrl): AutomergeUrl {
319
+ /**
320
+ * A module-list source is valid if it is an Automerge URL or looks like an
321
+ * HTTP(S)/site-relative manifest URL.
322
+ */
323
+ function isValidModuleSource(source: string): boolean {
324
+ if (isValidAutomergeUrl(source)) return true;
325
+ return (
326
+ source.startsWith("/") ||
327
+ source.startsWith("http://") ||
328
+ source.startsWith("https://") ||
329
+ source.startsWith("./")
330
+ );
331
+ }
332
+
333
+ /**
334
+ * Resolve the site's default module-list sources, honouring the
335
+ * `localStorage.defaultToolsUrl` dev override (which replaces the entire
336
+ * built-in default bundle).
337
+ */
338
+ function resolveDefaultModules(config: SiteConfig): string[] {
339
+ const builtin =
340
+ config.defaultModules ??
341
+ config.defaultModulesUrl ??
342
+ [];
343
+ const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
344
+ Boolean
345
+ );
346
+
294
347
  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
- );
348
+ if (override) {
349
+ if (isValidModuleSource(override)) {
350
+ if (!builtinList.includes(override)) {
351
+ console.info(
352
+ `using defaultToolsUrl override from localStorage: ${override}`
353
+ );
354
+ }
355
+ return [override];
301
356
  }
302
- return override;
357
+ console.warn(
358
+ `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
359
+ );
303
360
  }
304
- console.warn(
305
- `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
306
- );
307
- return builtin;
361
+
362
+ if (builtinList.length === 0) {
363
+ throw new Error(
364
+ "bootPatchworkSite: no default module sources configured (set `defaultModules`)"
365
+ );
366
+ }
367
+ return builtinList;
368
+ }
369
+
370
+ /**
371
+ * Turn an ordered list of module-list sources into the name-keyed map the
372
+ * ModuleWatcher expects. The first source keeps the canonical `system` name;
373
+ * additional sources get suffixed names. None may be `user` (reserved for the
374
+ * per-account settings doc, which has branch-override precedence).
375
+ */
376
+ function buildSystemSources(sources: string[]): Record<string, string> {
377
+ const map: Record<string, string> = {};
378
+ sources.forEach((source, index) => {
379
+ const name = index === 0 ? "system" : `system-${index}`;
380
+ map[name] = source;
381
+ });
382
+ return map;
308
383
  }
309
384
 
310
385
  function installDevConsoleGlobals(
@@ -372,7 +447,7 @@ function primeRootElement(
372
447
  const initialParams = new URLSearchParams(location.hash.slice(1));
373
448
  if (initialParams.has("frame")) {
374
449
  rootElement.setAttribute("tool-id", initialParams.get("frame")!);
375
- const docId = initialParams.get("doc");
450
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
376
451
  const docUrl = docId
377
452
  ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
378
453
  : accountDocHandle.url;
@@ -558,15 +633,26 @@ function installHashRouting(params: HashRoutingParams): void {
558
633
  return;
559
634
  }
560
635
 
636
+ // Bare automerge URL in hash: /#automerge:<documentId>
637
+ if (isValidAutomergeUrl(hash as AutomergeUrl)) {
638
+ const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
639
+ window.location.hash = "";
640
+ openDocument(
641
+ rootElement,
642
+ stringifyAutomergeUrl({ documentId, heads })
643
+ );
644
+ return;
645
+ }
646
+
561
647
  const params = new URLSearchParams(hash);
562
- const documentId = params.get("doc");
648
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
563
649
  const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
564
650
  const toolId = params.get("tool");
565
651
  const title = params.get("title");
566
652
  const type = params.get("type");
567
653
  const frame = params.get("frame");
568
654
  if (frame) {
569
- const docUrl = params.get("doc") ?? accountDocHandle.url;
655
+ const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
570
656
  if (
571
657
  rootElement.getAttribute("tool-id") !== frame ||
572
658
  rootElement.getAttribute("doc-url") !== docUrl