@inkandswitch/patchwork-bootloader 0.0.4 → 0.0.6

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/externals.js CHANGED
@@ -7,6 +7,8 @@ const externals = [
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
9
  "@automerge/automerge-repo-keyhive",
10
+ "@automerge/automerge-subduction",
11
+ "@automerge/automerge-subduction/slim",
10
12
  "@keyhive/keyhive",
11
13
  "@keyhive/keyhive/slim",
12
14
  "@inkandswitch/patchwork-bootloader",
@@ -16,5 +18,13 @@ const externals = [
16
18
  // sad
17
19
  "@codemirror/state",
18
20
  "@codemirror/view",
21
+ "@codemirror/language",
22
+ // rip
23
+ "solid-js",
24
+ "solid-js/html",
25
+ "solid-js/web",
26
+ "solid-js/h",
27
+ "solid-js/store",
28
+ "solid-js/jsx-runtime",
19
29
  ];
20
30
  export default externals;
@@ -1,6 +1,54 @@
1
1
  /// <reference types="service-worker-types" />
2
+ import { SwLogger } from "./sw-logger.js";
3
+ // Heavy imports — marked external by the service-worker vite plugin,
4
+ // resolved to /packages/... URLs at build time. The SW is registered with
5
+ // type:"module" so the browser fetches these as regular network requests.
6
+ // Uses /slim to avoid top-level await (disallowed in service workers).
7
+ // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
8
+ // of bundling the ~3MB base64 string.
9
+ import { initializeWasm } from "@automerge/automerge/slim";
10
+ // eslint-disable-next-line
11
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
12
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
13
+ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
14
+ import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
15
+ import { findHandleInFolderHandle, resolvePackageExport, } from "@inkandswitch/patchwork-filesystem";
16
+ // Small adapters — bundled directly into the SW
17
+ import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
18
+ import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
19
+ import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
20
+ // TEMPORARY: enable debug npm module in SW context (no localStorage available)
2
21
  let cachename = "default";
3
22
  let debugging = false;
23
+ const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
24
+ // ── Persistent logger ───────────────────────────────────────────────────
25
+ // Initialized eagerly so it's available for the entire SW lifetime.
26
+ // Access from the SW inspector console via self.printLogs(), self.tailLogs(),
27
+ // self.exportLogs(), self.clearLogs().
28
+ const slog = SwLogger.open().then((logger) => {
29
+ self.slog = logger;
30
+ self.printLogs = async (n = 200) => {
31
+ const entries = await logger.tail(n);
32
+ for (const e of entries) {
33
+ const prefix = `[${e.ts}] [${e.level}]`;
34
+ if (e.data !== undefined) {
35
+ console.log(prefix, e.msg, e.data);
36
+ }
37
+ else {
38
+ console.log(prefix, e.msg);
39
+ }
40
+ }
41
+ console.log(`--- ${entries.length} entries ---`);
42
+ };
43
+ self.tailLogs = (n = 200) => logger.tail(n);
44
+ self.exportLogs = () => logger.exportAll();
45
+ self.clearLogs = () => logger.clear();
46
+ logger.info("sw-logger initialized");
47
+ return logger;
48
+ });
49
+ const cacheableStatuses = [
50
+ 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501,
51
+ ];
4
52
  function log(...args) {
5
53
  if (!debugging)
6
54
  return;
@@ -21,33 +69,72 @@ self.addEventListener("activate", async () => {
21
69
  await clearOldCaches();
22
70
  clients.claim();
23
71
  });
24
- // a map of response promises by their id
25
- const responseResolvers = new Map();
26
- function accept(message) {
27
- const responseItem = responseResolvers.get(message.id);
28
- if (!responseItem) {
29
- return console.warn(`No read response found for id ${message.id}`);
72
+ let repoPromise = null;
73
+ function getRepo() {
74
+ if (!repoPromise) {
75
+ repoPromise = (async () => {
76
+ const logger = await slog;
77
+ logger.info("fetching wasm modules");
78
+ const [amWasmBuf, sdnWasmBuf] = await Promise.all([
79
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
80
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
81
+ ]);
82
+ initSubductionSync(new Uint8Array(sdnWasmBuf));
83
+ await initializeWasm(new Uint8Array(amWasmBuf));
84
+ logger.info("wasm initialized");
85
+ const signer = await WebCryptoSigner.setup();
86
+ const repo = new Repo({
87
+ storage: new IndexedDBStorageAdapter(),
88
+ signer,
89
+ peerId: ("service-worker-" +
90
+ (Math.random() * 10000).toString(36).slice(2)),
91
+ async sharePolicy(peerId) {
92
+ return peerId.includes("storage-server");
93
+ },
94
+ enableRemoteHeadsGossiping: true,
95
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
96
+ network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
97
+ });
98
+ self.repo = repo;
99
+ logger.info("repo constructed, waiting for network subsystem");
100
+ // Don't block getRepo() on whenReady() — the network subsystem starts
101
+ // with only the subduction adapter, and the MessageChannel adapter is
102
+ // added later via connectPort (which awaits getRepo). Blocking here
103
+ // would deadlock that path and starve the fetch handler.
104
+ repo.networkSubsystem.whenReady().then(() => {
105
+ logger.info("repo network subsystem ready");
106
+ });
107
+ return repo;
108
+ })();
30
109
  }
31
- return responseItem.resolve(message.response);
110
+ return repoPromise;
111
+ }
112
+ // Connect client MessagePorts to the repo for sync
113
+ async function connectPort(port) {
114
+ const repo = await getRepo();
115
+ repo.networkSubsystem.addNetworkAdapter(new MessageChannelNetworkAdapter(port, { useWeakRef: true }));
32
116
  }
33
- const bc = new BroadcastChannel("@patchwork/handoff");
34
- bc.addEventListener("message", (event) => {
35
- if (event.data.type == "response")
36
- accept(event.data);
37
- });
38
- // when we receive a `response` req, we resolve the promise with that id
39
117
  self.addEventListener("message", async (event) => {
40
- if (event.data.type == "response") {
41
- accept(event.data);
118
+ if (event.data.type == "ping") {
119
+ // Keepalive — Chromium idles out service workers after ~30s of inactivity.
120
+ // Reply via the provided port if any; the message event itself also resets
121
+ // the idle timer.
122
+ const [pongPort] = event.ports;
123
+ log("ping");
124
+ if (pongPort) {
125
+ pongPort.postMessage({ type: "pong" });
126
+ log("pong");
127
+ pongPort.close();
128
+ }
129
+ else if (event.source) {
130
+ event.source.postMessage({ type: "pong" });
131
+ log("pong");
132
+ }
42
133
  }
43
134
  else if (event.data.type == "port") {
44
- log("recieved messagechannel");
135
+ log("received messagechannel");
45
136
  const [port] = event.ports;
46
- port.addEventListener("message", (event) => {
47
- if (event.data.type == "response") {
48
- accept(event.data);
49
- }
50
- });
137
+ connectPort(port);
51
138
  }
52
139
  else if (event.data.type == "cachename") {
53
140
  const nextCachename = event.data.cachename;
@@ -63,22 +150,107 @@ self.addEventListener("message", async (event) => {
63
150
  log("serviceworker debugging enabled");
64
151
  }
65
152
  });
66
- // request ids are kept in a counter
67
- let reqcount = 0;
68
- self.addEventListener("fetch", async (fetchEvent) => {
153
+ // ── Automerge URL resolution ───────────────────────────────────────────
154
+ async function resolveAutomergeUrl(automergeURL) {
155
+ const repo = await getRepo();
156
+ const href = automergeURL.href;
157
+ const [maybeAutomergeUrl, ...path] = href.split("/");
158
+ if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
159
+ return new Response("invalid automerge url", { status: 400 });
160
+ }
161
+ // Trim trailing empty path segment
162
+ if (path.length && !path[path.length - 1])
163
+ path.pop();
164
+ const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
165
+ if (!heads) {
166
+ // Redirect to pinned-heads URL
167
+ const folder = await repo.find(maybeAutomergeUrl);
168
+ const latestHeads = folder.heads();
169
+ const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
170
+ let location = `/${encodeURIComponent(url)}`;
171
+ if (path.length)
172
+ location += `/${path.join("/")}`;
173
+ return Response.redirect(location, 307);
174
+ }
175
+ // If no path, check if this is a package with exports to resolve
176
+ // e.g. /automerge%3Adocid/abc → resolve "abc" via package.json exports
177
+ const folderHandle = await repo.find(maybeAutomergeUrl);
178
+ let fileHandle;
179
+ if (path.length) {
180
+ // Try direct file navigation first
181
+ fileHandle = await findHandleInFolderHandle(repo, folderHandle, path.map(decodeURIComponent));
182
+ // If not found as a direct path, try resolving as a package subpath export
183
+ // e.g. /automerge%3Adocid/abc → exports["./abc"] → "./dist/abc.js"
184
+ if (!fileHandle) {
185
+ const subpath = "./" + path.map(decodeURIComponent).join("/");
186
+ const pkgFileHandle = await findHandleInFolderHandle(repo, folderHandle, ["package.json"]);
187
+ if (pkgFileHandle) {
188
+ const pkgDoc = pkgFileHandle.doc();
189
+ if (pkgDoc?.content) {
190
+ const pkgJson = JSON.parse(String(pkgDoc.content));
191
+ try {
192
+ const resolved = resolvePackageExport(pkgJson, subpath);
193
+ if (resolved) {
194
+ const resolvedPath = resolved.replace(/^\.\//, "").split("/");
195
+ fileHandle = await findHandleInFolderHandle(repo, folderHandle, resolvedPath);
196
+ }
197
+ }
198
+ catch {
199
+ // not a valid export subpath, fall through to error
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ else {
206
+ // No path — resolve the root export (like "." in package.json)
207
+ const pkgFileHandle = await findHandleInFolderHandle(repo, folderHandle, ["package.json"]);
208
+ if (pkgFileHandle) {
209
+ const pkgDoc = pkgFileHandle.doc();
210
+ if (pkgDoc?.content) {
211
+ const pkgJson = JSON.parse(String(pkgDoc.content));
212
+ try {
213
+ const resolved = resolvePackageExport(pkgJson);
214
+ if (resolved) {
215
+ const resolvedPath = resolved.replace(/^\.\//, "").split("/");
216
+ fileHandle = await findHandleInFolderHandle(repo, folderHandle, resolvedPath);
217
+ }
218
+ }
219
+ catch { }
220
+ }
221
+ }
222
+ }
223
+ if (!fileHandle) {
224
+ throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
225
+ }
226
+ const fileDoc = fileHandle.doc();
227
+ const content = fileDoc?.content;
228
+ if (!content) {
229
+ throw new Error(`file at ${href} has no content`);
230
+ }
231
+ let body = content instanceof Uint8Array
232
+ ? new Uint8Array(content)
233
+ : String(content);
234
+ const mimeType = fileDoc.mimeType ?? "text/plain";
235
+ const headers = new Headers({ "content-type": mimeType });
236
+ headers.set("cross-origin-embedder-policy", "credentialless");
237
+ headers.set("cross-origin-resource-policy", "cross-origin");
238
+ return new Response(body, { status: 200, headers });
239
+ }
240
+ // ── Fetch handler ──────────────────────────────────────────────────────
241
+ self.addEventListener("fetch", (fetchEvent) => {
242
+ log("fetch event", fetchEvent.request.url);
69
243
  const request = fetchEvent.request;
70
244
  if (request.method !== "GET")
71
245
  return fetchEvent.respondWith(fetch(request));
72
246
  const url = new URL(fetchEvent.request.url);
73
- let handoffURL;
247
+ let specialURL;
74
248
  if (url.hostname == self.location.hostname &&
75
249
  url.port == self.location.port &&
76
250
  url.protocol == self.location.protocol) {
77
251
  try {
78
- // trap any request like /url e.g. /automerge%3Awhatever or /http%3A%2F%2Fsomething.com
79
- // URI encoded so we can include hashes etc
80
- handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
81
- log(`received handoff request ${handoffURL}`);
252
+ specialURL = new URL(decodeURIComponent(url.pathname.slice(1)));
253
+ log(`received special request ${specialURL}`);
82
254
  }
83
255
  catch { }
84
256
  }
@@ -86,65 +258,37 @@ self.addEventListener("fetch", async (fetchEvent) => {
86
258
  const cache = await caches.open(cachename);
87
259
  const match = await cache.match(request);
88
260
  try {
89
- if (handoffURL) {
90
- // cache-first strategy for handoff requests
91
- if (match)
92
- return match;
93
- const client = await self.clients.get(fetchEvent.clientId);
94
- // set up a request id
95
- const reqid = reqcount++;
96
- // create a place for the response event handler to put the response
97
- const resolvers = Promise.withResolvers();
98
- responseResolvers.set(reqid, resolvers);
99
- // i don't think this can happen
100
- if (!client) {
101
- throw new Error(`the client has gone missing!!! ${fetchEvent.clientId}. i have NO IDEA what to do`);
102
- }
103
- const message = {
104
- id: reqid,
105
- type: "request",
106
- cache: cachename,
107
- request: {
108
- url: handoffURL.href,
109
- headers: Object.fromEntries(request.headers.entries()),
110
- method: request.method,
111
- destination: request.destination,
112
- referrer: request.referrer,
113
- },
114
- };
115
- log("sending handoff request", message);
116
- // send request event to main thread to ask them how to handle it
117
- client.postMessage(message);
118
- // this'll finish when the main thread gets back to us
119
- fetchEvent.waitUntil(resolvers.promise);
120
- const handoffResponse = await resolvers.promise;
121
- log("received handoff response", handoffResponse);
122
- if (handoffResponse) {
123
- const response = new Response(handoffResponse.body, {
124
- status: handoffResponse.status,
125
- headers: handoffResponse.headers,
261
+ if (specialURL) {
262
+ if (match) {
263
+ log(`serving ${specialURL} from cache ${cachename}`);
264
+ const headers = new Headers(match.headers);
265
+ headers.set("cross-origin-embedder-policy", "credentialless");
266
+ headers.set("cross-origin-resource-policy", "cross-origin");
267
+ return new Response(match.body, {
268
+ status: match.status,
269
+ headers,
126
270
  });
127
- if (handoffResponse.cache !== false) {
128
- log(`caching ${handoffURL}`);
129
- await cache.put(request, response.clone());
130
- }
131
- else {
132
- log(`caching disabled on ${handoffURL}`);
133
- }
271
+ }
272
+ const response = await resolveAutomergeUrl(specialURL);
273
+ if (response.status === 307) {
134
274
  return response;
135
275
  }
136
- // no idea what's going on now i'm a teapot i'm a teapot
137
- return new Response("handler returned nothing", {
138
- status: 418,
139
- });
276
+ if (cacheableStatuses.includes(response.status)) {
277
+ log(`caching ${specialURL}`);
278
+ await cache.put(request, response.clone());
279
+ }
280
+ return response;
140
281
  }
141
282
  else {
142
- // network first strategy for external requests
143
- const response = await fetch(request);
283
+ const response = await fetch(request).catch(() => null);
144
284
  if (response) {
145
- if (response.ok && response.url.match(/^https?\:/)) {
285
+ if (cacheableStatuses.includes(response.status) &&
286
+ response.url.match(/^https?\:/)) {
146
287
  await cache.put(request, response.clone());
147
288
  }
289
+ else {
290
+ log(`skipping uncacheable response code from cache: ${response.status} for ${response.url}`);
291
+ }
148
292
  return response;
149
293
  }
150
294
  if (match)
@@ -153,12 +297,16 @@ self.addEventListener("fetch", async (fetchEvent) => {
153
297
  }
154
298
  }
155
299
  catch (error) {
300
+ const message = error instanceof Error
301
+ ? `${error.message}\n\n${error.stack}`
302
+ : String(error);
303
+ console.error(`service worker error resolving ${request.url}${specialURL ? ` (for: ${specialURL})` : ""}.\n${message}`);
156
304
  if (match)
157
305
  return match;
158
- // if something fucked up happens, serve a stale thing if there is one
159
- // probably can do better error messaging here based on what was caught
160
- return new Response(`yikes: ${error}`, { status: 555 });
306
+ return new Response(message, {
307
+ status: 500,
308
+ headers: { "content-type": "text/plain" },
309
+ });
161
310
  }
162
311
  })());
163
312
  });
164
- export {};
package/dist/setup.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- import type { HandoffHandler, SetupServiceWorkerOptions } from "./types.js";
1
+ import type { SetupServiceWorkerOptions } from "./types.js";
2
2
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
3
- export default function setupServiceWorker(handler: HandoffHandler, options?: SetupServiceWorkerOptions): Promise<ServiceWorker | undefined>;
3
+ export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<{
4
+ port: MessagePort;
5
+ }>;
package/dist/setup.js CHANGED
@@ -28,54 +28,66 @@ export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller)
28
28
  bumpServiceWorkerCacheVersion();
29
29
  setServiceWorkerCacheName(sw);
30
30
  }
31
- export default async function setupServiceWorker(handler, options) {
32
- navigator.serviceWorker.addEventListener("controllerchange", function () {
33
- console.log("%cnew service worker, reloading", "color: pink; font-weight: bold");
34
- bumpServiceWorkerCache(navigator.serviceWorker.controller);
35
- location.reload();
31
+ window.bumpServiceWorkerCache = bumpServiceWorkerCache;
32
+ /** Wait for a registration to have an active worker */
33
+ function waitForActive(reg) {
34
+ if (reg.active)
35
+ return Promise.resolve(reg.active);
36
+ const worker = reg.installing || reg.waiting;
37
+ if (!worker)
38
+ return Promise.reject(new Error("no service worker in registration"));
39
+ return new Promise((resolve) => {
40
+ worker.addEventListener("statechange", () => {
41
+ if (worker.state === "activated")
42
+ resolve(worker);
43
+ });
36
44
  });
37
- navigator.serviceWorker.addEventListener("message", async (event) => {
38
- if (event.data.type == "request") {
39
- const requestMessage = event.data;
40
- const source = event.source;
41
- if (!source) {
42
- throw new TypeError("can't operate without a source");
43
- }
44
- async function send(response, transfer) {
45
- source.postMessage({
46
- id: requestMessage.id,
47
- type: "response",
48
- response,
49
- }, { transfer });
50
- }
51
- const handoffResponse = await handler(requestMessage.request.url, requestMessage.request);
52
- if (!handoffResponse) {
53
- return source?.postMessage({ id: requestMessage.id, type: "response" });
54
- }
55
- if (typeof handoffResponse == "string") {
56
- return send({ body: handoffResponse }, [handoffResponse]);
57
- }
58
- if (handoffResponse instanceof Uint8Array) {
59
- return send({ body: handoffResponse }, [handoffResponse.buffer]);
60
- }
61
- const { body: handoffBody, headers, status, cache } = handoffResponse;
62
- const body = handoffBody;
63
- send({ body, headers, status, cache }, body instanceof Uint8Array ? [body.buffer] : undefined);
45
+ }
46
+ export default async function setupServiceWorker(options) {
47
+ // Backwards compat: if an old service worker sends handoff "request" messages,
48
+ // immediately reject them so its fetch handler doesn't hang forever.
49
+ navigator.serviceWorker.addEventListener("message", (event) => {
50
+ if (event.data?.type === "request" && event.data.id != null) {
51
+ navigator.serviceWorker.controller?.postMessage({
52
+ type: "response",
53
+ id: event.data.id,
54
+ response: {
55
+ body: "service worker upgraded, please refresh",
56
+ status: 503,
57
+ headers: { "content-type": "text/plain" },
58
+ },
59
+ });
64
60
  }
65
61
  });
66
- const existingSw = await navigator.serviceWorker.getRegistration();
67
- return navigator.serviceWorker
68
- .register(options?.path ?? "/service-worker.js")
69
- .then(async (sw) => {
70
- sw.active?.postMessage({
71
- type: "debug",
72
- debug: debugging,
62
+ const path = options?.path ?? "/service-worker.js";
63
+ const reg = await navigator.serviceWorker.register(path, { type: "module" });
64
+ // If there's an update waiting or installing, wait for it to activate
65
+ if (reg.installing || reg.waiting) {
66
+ await waitForActive(reg);
67
+ }
68
+ const active = reg.active;
69
+ active.postMessage({ type: "debug", debug: debugging });
70
+ // Wait for the controller to be available
71
+ if (!navigator.serviceWorker.controller) {
72
+ await new Promise((resolve) => {
73
+ navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
73
74
  });
74
- if (!existingSw?.active) {
75
- bumpServiceWorkerCache(sw.installing);
76
- queueMicrotask(() => location.reload());
77
- return sw.active;
78
- }
79
- console.log("service worker alive, loading %c patchwork system ", "background: #fff8f0; border: 1px solid; border-radius: 4px");
75
+ }
76
+ // Send a MessagePort so the SW's repo can sync with clients
77
+ const { port1, port2 } = new MessageChannel();
78
+ navigator.serviceWorker.controller.postMessage({ type: "port" }, [port2]);
79
+ // Keepalive — Chromium idles out service workers after ~30s of inactivity,
80
+ // which tears down the in-memory Repo and forces a cold restart on the next
81
+ // fetch. Send a ping every 20s while the page is visible to keep it warm.
82
+ setInterval(() => {
83
+ navigator.serviceWorker.controller?.postMessage({ type: "ping" });
84
+ }, 20_000);
85
+ // Reload on future SW updates (added after setup so the initial
86
+ // activation doesn't trigger a reload loop).
87
+ navigator.serviceWorker.addEventListener("controllerchange", function () {
88
+ console.info("%cnew service worker took control, reloading...", "color: pink; font-weight: bold");
89
+ location.reload();
80
90
  });
91
+ console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
92
+ return { port: port1 };
81
93
  }
package/dist/site.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * High-level browser-app boot sequence for a Patchwork site.
3
+ *
4
+ * Layers on top of {@link setupServiceWorker} (the package default export) to
5
+ * construct the Repo, wire up the service-worker port, load plugins via the
6
+ * ModuleWatcher, resolve the user's account document, and hand control to the
7
+ * configured root tool.
8
+ *
9
+ * This entry point pulls in DOM- and plugin-layer dependencies (patchwork
10
+ * elements, plugins, filesystem) and is intended for use only from a browser
11
+ * site's `main.ts`. Non-UI consumers should import the package default (which
12
+ * only does SW registration and port handoff).
13
+ */
14
+ import { type DocHandle, Repo, type AutomergeUrl, type StorageId } from "@automerge/vanillajs/slim";
15
+ import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
16
+ import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
17
+ import * as plugins from "@inkandswitch/patchwork-plugins";
18
+ import { SwLogReader } from "./sw-logger.js";
19
+ declare global {
20
+ interface Window {
21
+ accountDocHandle: DocHandle<AccountDoc>;
22
+ Automerge: typeof import("@automerge/automerge");
23
+ AutomergeRepo: typeof import("@automerge/automerge-repo");
24
+ repo: Repo;
25
+ getRepoChannel: () => MessagePort;
26
+ patchwork: {
27
+ repo: Repo;
28
+ modules: ModuleWatcher;
29
+ plugins: typeof plugins;
30
+ accountDocHandle: DocHandle<AccountDoc>;
31
+ sw: {
32
+ printLogs: (n?: number) => Promise<void>;
33
+ tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
34
+ exportLogs: () => Promise<string>;
35
+ clearLogs: () => Promise<void>;
36
+ };
37
+ };
38
+ uncache: (match: string) => Promise<void>;
39
+ }
40
+ }
41
+ export interface SiteConfig {
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`).
47
+ *
48
+ * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
49
+ * another automerge: URL — useful for local development against an
50
+ * unpublished tool set.
51
+ */
52
+ defaultModulesUrl: AutomergeUrl;
53
+ /**
54
+ * `localStorage` key under which this site remembers which account document
55
+ * belongs to the current user. Sites sharing an origin MUST use distinct
56
+ * keys so they do not clobber each other's accounts.
57
+ */
58
+ accountStorageKey: string;
59
+ /**
60
+ * Brand word appended to the document title as `"<doc> | <titleSuffix>"`
61
+ * when a document is open. The separator is provided for you.
62
+ */
63
+ titleSuffix: string;
64
+ /**
65
+ * DOM id of the `<patchwork-view>` element that will host the root tool.
66
+ * Defaults to `"root"`.
67
+ */
68
+ 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
+ }
75
+ export interface BootResult {
76
+ repo: Repo;
77
+ moduleWatcher: ModuleWatcher;
78
+ accountDocHandle: DocHandle<AccountDoc>;
79
+ }
80
+ /**
81
+ * Boot a Patchwork browser site.
82
+ *
83
+ * Performs the full application-shell setup: service worker + port, Repo,
84
+ * plugin/module loading, account resolution, URL-hash routing, and dev-console
85
+ * globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
86
+ * constructed Repo, ModuleWatcher and account handle for sites that want to
87
+ * do additional wiring after boot.
88
+ */
89
+ export declare function bootPatchworkSite(config: SiteConfig): Promise<BootResult>;