@inkandswitch/patchwork-bootloader 0.2.7 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.2.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 48e4391: Pass the realm-local `repo` to `<patchwork-view>` registration so booted views
8
+ resolve their document handles through it — via the per-view `OverlayRepo` and
9
+ the root `<repo-provider>` fallback for `repo:handle-descriptor`.
10
+ - Updated dependencies [48e4391]
11
+ - Updated dependencies [48e4391]
12
+ - @inkandswitch/patchwork-elements@2.0.0
13
+ - @inkandswitch/patchwork-providers@0.3.0
14
+
3
15
  ## 0.2.7
4
16
 
5
17
  ### Patch Changes
@@ -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"
@@ -79,7 +76,7 @@ async function connectClassicSyncNetwork(server) {
79
76
  }
80
77
  }
81
78
  const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
82
- const cacheableStatuses = [200, 203, 204, 206];
79
+ const cacheableStatuses = [200, 203, 204];
83
80
  function log(...args) {
84
81
  if (!debugging)
85
82
  return;
@@ -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",
@@ -8,7 +8,7 @@
8
8
  import { HANDOFF_CHANNEL, } from "./types.js";
9
9
  let cachename = "default";
10
10
  let debugging = false;
11
- const cacheableStatuses = [200, 203, 204, 206];
11
+ const cacheableStatuses = [200, 203, 204];
12
12
  // The automerge worker times its own resolution out after 30s and replies
13
13
  // with an error, so this only fires when nobody is listening at all.
14
14
  const HANDOFF_TIMEOUT_MS = 35_000;
@@ -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) {
@@ -100,15 +104,17 @@ export async function bootPatchworkSite(config) {
100
104
  if (!rootElement) {
101
105
  throw new Error(`bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`);
102
106
  }
107
+ // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
108
+ // for any view outside a remapper (resolving to the requested url unchanged).
103
109
  const repoProvider = document.createElement("repo-provider");
104
110
  rootElement.parentElement.insertBefore(repoProvider, rootElement);
105
111
  repoProvider.appendChild(rootElement);
106
- registerPatchworkViewElement(hive ? { hive } : {});
112
+ registerPatchworkViewElement({ hive, repo });
107
113
  // The watcher is started with the site's default-tools bundle alone so that
108
114
  // `resolveAccountHandle` below has something to await on (the `account`
109
115
  // datatype lives in that bundle today). The user's own module-settings URL
110
116
  // is added lazily once it appears on the account doc — see below.
111
- const moduleWatcher = new ModuleWatcher(repo, { system: defaultModulesUrl }, onModuleLoaded, unregisterPlugins);
117
+ const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins);
112
118
  const accountDocHandle = (await resolveAccountHandle(repo, {
113
119
  storageKey: config.accountStorageKey,
114
120
  hive,
@@ -140,18 +146,56 @@ export async function bootPatchworkSite(config) {
140
146
  return { repo, moduleWatcher, accountDocHandle };
141
147
  }
142
148
  // ─── Internals ──────────────────────────────────────────────────────────
143
- 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);
144
171
  const override = globalThis.localStorage?.getItem("defaultToolsUrl");
145
- if (!override)
146
- return builtin;
147
- if (isValidAutomergeUrl(override)) {
148
- if (override !== builtin) {
149
- 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];
150
178
  }
151
- 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`)");
152
183
  }
153
- console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
154
- 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;
155
199
  }
156
200
  function installDevConsoleGlobals(repo, hive, getRepoChannel) {
157
201
  window.repo = repo;
@@ -200,7 +244,7 @@ function primeRootElement(rootElement, accountDocHandle) {
200
244
  const initialParams = new URLSearchParams(location.hash.slice(1));
201
245
  if (initialParams.has("frame")) {
202
246
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
203
- const docId = initialParams.get("doc");
247
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
204
248
  const docUrl = docId
205
249
  ? stringifyAutomergeUrl({ documentId: docId })
206
250
  : accountDocHandle.url;
@@ -366,15 +410,22 @@ function installHashRouting(params) {
366
410
  }
367
411
  return;
368
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
+ }
369
420
  const params = new URLSearchParams(hash);
370
- const documentId = params.get("doc");
421
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
371
422
  const heads = params.get("heads")?.split("|");
372
423
  const toolId = params.get("tool");
373
424
  const title = params.get("title");
374
425
  const type = params.get("type");
375
426
  const frame = params.get("frame");
376
427
  if (frame) {
377
- const docUrl = params.get("doc") ?? accountDocHandle.url;
428
+ const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
378
429
  if (rootElement.getAttribute("tool-id") !== frame ||
379
430
  rootElement.getAttribute("doc-url") !== docUrl) {
380
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.7",
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-elements": "^1.0.0",
57
- "@inkandswitch/patchwork-filesystem": "^0.0.8",
56
+ "@inkandswitch/patchwork-filesystem": "^0.1.0",
58
57
  "@inkandswitch/patchwork-plugins": "^0.0.11",
59
- "@inkandswitch/patchwork-providers": "^0.2.2"
58
+ "@inkandswitch/patchwork-providers": "^0.3.0",
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"
@@ -121,7 +115,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
121
115
  const siteName =
122
116
  typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
123
117
 
124
- const cacheableStatuses = [200, 203, 204, 206];
118
+ const cacheableStatuses = [200, 203, 204];
125
119
 
126
120
  function log(...args: any[]) {
127
121
  if (!debugging) return;
@@ -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",
@@ -16,7 +16,7 @@ import {
16
16
  let cachename = "default";
17
17
  let debugging = false;
18
18
 
19
- const cacheableStatuses = [200, 203, 204, 206];
19
+ const cacheableStatuses = [200, 203, 204];
20
20
 
21
21
  // The automerge worker times its own resolution out after 30s and replies
22
22
  // with an error, so this only fires when nobody is listening at all.
@@ -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);
@@ -173,53 +198,53 @@ export async function bootPatchworkSite(
173
198
  if (!sw) throw new Error("Failed to set up service worker");
174
199
 
175
200
  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;
201
+ // Get the initial automerge-worker port via subscribeToRepoChannel,
202
+ // then pass it to keyhive init which wraps it in its own network adapter.
203
+ let resolvePort!: (port: MessagePort) => void;
204
+ const portPromise = new Promise<MessagePort>((r) => {
205
+ resolvePort = r;
206
+ });
207
+ await sw.subscribeToRepoChannel(resolvePort);
208
+ const workerPort = await portPromise;
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({
222
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
223
+ // Defaults to "subduction".
224
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
225
+ repo: {
200
226
  storage: new IndexedDBStorageAdapter(),
201
227
  enableRemoteHeadsGossiping: true,
202
- network: [hive.networkAdapter],
203
- peerId: hive.peerId,
204
- idFactory: hive.idFactory,
205
- })
206
- : new Repo({
207
- network: [new MessageChannelNetworkAdapter(workerPort)],
208
- storage: new IndexedDBStorageAdapter(),
209
- async sharePolicy(peerId) {
210
- return peerId.includes("automerge-worker");
211
- },
212
- 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
  );
219
245
 
220
246
  await repo.networkSubsystem.whenReady();
221
247
  if (hive) {
222
-
223
248
  (hive.networkAdapter as any).syncKeyhive?.();
224
249
  }
225
250
 
@@ -233,11 +258,13 @@ export async function bootPatchworkSite(
233
258
  `bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`
234
259
  );
235
260
  }
261
+ // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
262
+ // for any view outside a remapper (resolving to the requested url unchanged).
236
263
  const repoProvider = document.createElement("repo-provider");
237
264
  rootElement.parentElement!.insertBefore(repoProvider, rootElement);
238
265
  repoProvider.appendChild(rootElement);
239
266
 
240
- registerPatchworkViewElement(hive ? { hive } : {});
267
+ registerPatchworkViewElement({ hive, repo });
241
268
 
242
269
  // The watcher is started with the site's default-tools bundle alone so that
243
270
  // `resolveAccountHandle` below has something to await on (the `account`
@@ -245,7 +272,7 @@ export async function bootPatchworkSite(
245
272
  // is added lazily once it appears on the account doc — see below.
246
273
  const moduleWatcher = new ModuleWatcher(
247
274
  repo,
248
- { system: defaultModulesUrl },
275
+ buildSystemSources(defaultModuleSources),
249
276
  onModuleLoaded,
250
277
  unregisterPlugins
251
278
  );
@@ -289,21 +316,70 @@ export async function bootPatchworkSite(
289
316
 
290
317
  // ─── Internals ──────────────────────────────────────────────────────────
291
318
 
292
- 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
+
293
347
  const override = globalThis.localStorage?.getItem("defaultToolsUrl");
294
- if (!override) return builtin;
295
- if (isValidAutomergeUrl(override)) {
296
- if (override !== builtin) {
297
- console.info(
298
- `using defaultToolsUrl override from localStorage: ${override}`
299
- );
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];
300
356
  }
301
- return override;
357
+ console.warn(
358
+ `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
359
+ );
302
360
  }
303
- console.warn(
304
- `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
305
- );
306
- 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;
307
383
  }
308
384
 
309
385
  function installDevConsoleGlobals(
@@ -371,7 +447,7 @@ function primeRootElement(
371
447
  const initialParams = new URLSearchParams(location.hash.slice(1));
372
448
  if (initialParams.has("frame")) {
373
449
  rootElement.setAttribute("tool-id", initialParams.get("frame")!);
374
- const docId = initialParams.get("doc");
450
+ const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
375
451
  const docUrl = docId
376
452
  ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
377
453
  : accountDocHandle.url;
@@ -557,15 +633,26 @@ function installHashRouting(params: HashRoutingParams): void {
557
633
  return;
558
634
  }
559
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
+
560
647
  const params = new URLSearchParams(hash);
561
- const documentId = params.get("doc");
648
+ const documentId = params.get("doc")?.replace(/^automerge:/, "");
562
649
  const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
563
650
  const toolId = params.get("tool");
564
651
  const title = params.get("title");
565
652
  const type = params.get("type");
566
653
  const frame = params.get("frame");
567
654
  if (frame) {
568
- const docUrl = params.get("doc") ?? accountDocHandle.url;
655
+ const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
569
656
  if (
570
657
  rootElement.getAttribute("tool-id") !== frame ||
571
658
  rootElement.getAttribute("doc-url") !== docUrl