@inkandswitch/patchwork-bootloader 0.0.8 → 0.1.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/dist/site.js CHANGED
@@ -19,13 +19,19 @@ import * as AutomergeRepo from "@automerge/automerge-repo/slim";
19
19
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
20
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
21
21
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
22
- import { getRegistry, registerPlugins, resolveAccountHandle, } from "@inkandswitch/patchwork-plugins";
22
+ import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
23
23
  import * as plugins from "@inkandswitch/patchwork-plugins";
24
24
  import setupServiceWorker from "./setup.js";
25
25
  import { SwLogReader } from "./sw-logger.js";
26
+ import debug from "debug";
27
+ const log = debug("patchwork:bootloader:site");
26
28
  const DEFAULT_REMOTE_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d";
27
29
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
28
30
  const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
31
+ const [automergeWasm, subductionWasm] = await Promise.all([
32
+ fetch("/automerge.wasm?main").then((r) => r.bytes()),
33
+ fetch("/subduction.wasm").then((r) => r.bytes()),
34
+ ]);
29
35
  /**
30
36
  * Boot a Patchwork browser site.
31
37
  *
@@ -37,11 +43,8 @@ const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-
37
43
  */
38
44
  export async function bootPatchworkSite(config) {
39
45
  const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
40
- // Fetch both Wasm binaries in parallel, then compile
41
- const [automergeWasm, subductionWasm] = await Promise.all([
42
- fetch("/automerge.wasm").then((r) => r.bytes()),
43
- fetch("/subduction.wasm").then((r) => r.bytes()),
44
- ]);
46
+ showLoadingAnimation();
47
+ log(`booting`, config);
45
48
  await initializeWasm(automergeWasm);
46
49
  initSubductionSync(subductionWasm);
47
50
  const repo = new Repo({
@@ -50,21 +53,29 @@ export async function bootPatchworkSite(config) {
50
53
  return peerId.includes("service-worker");
51
54
  },
52
55
  enableRemoteHeadsGossiping: true,
56
+ peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
53
57
  });
54
58
  repo.subscribeToRemotes(config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]);
55
59
  const sw = await setupServiceWorker();
56
60
  if (!sw)
57
61
  throw new Error("Failed to set up service worker");
58
- const net = new MessageChannelNetworkAdapter(sw.port);
59
- repo.networkSubsystem.addNetworkAdapter(net);
60
- await net.whenReady();
62
+ let activeServiceWorkerPort;
63
+ const connectServiceWorkerPort = async (port) => {
64
+ const previousPort = activeServiceWorkerPort;
65
+ activeServiceWorkerPort = port;
66
+ const net = new MessageChannelNetworkAdapter(port);
67
+ repo.networkSubsystem.addNetworkAdapter(net);
68
+ await net.whenReady();
69
+ previousPort?.close();
70
+ };
71
+ await sw.subscribeToRepoChannel(connectServiceWorkerPort);
61
72
  installDevConsoleGlobals(repo);
62
73
  registerPatchworkViewElement({ repo });
63
74
  // The watcher is started with the site's default-tools bundle alone so that
64
75
  // `resolveAccountHandle` below has something to await on (the `account`
65
76
  // datatype lives in that bundle today). The user's own module-settings URL
66
77
  // is added lazily once it appears on the account doc — see below.
67
- const moduleWatcher = new ModuleWatcher(repo, [defaultModulesUrl], onModuleLoaded);
78
+ const moduleWatcher = new ModuleWatcher(repo, { system: defaultModulesUrl }, onModuleLoaded, unregisterPlugins);
68
79
  const accountDocHandle = await resolveAccountHandle(repo, {
69
80
  storageKey: config.accountStorageKey,
70
81
  });
@@ -78,10 +89,13 @@ export async function bootPatchworkSite(config) {
78
89
  logToolRegistryWhenLoaded(moduleWatcher);
79
90
  window.patchwork = {
80
91
  repo,
81
- modules: moduleWatcher,
92
+ packages: moduleWatcher,
82
93
  plugins,
83
94
  accountDocHandle,
84
- sw: buildSwLogApi(),
95
+ sw: {
96
+ ...buildSwLogApi(),
97
+ subscribeToRepoChannel: sw.subscribeToRepoChannel,
98
+ },
85
99
  };
86
100
  window.uncache = uncache;
87
101
  installHashRouting({
@@ -111,19 +125,14 @@ function installDevConsoleGlobals(repo) {
111
125
  window.repo = repo;
112
126
  window.Automerge = Automerge;
113
127
  window.AutomergeRepo = AutomergeRepo;
114
- window.getRepoChannel = () => {
115
- const { port1, port2 } = new MessageChannel();
116
- navigator.serviceWorker.controller.postMessage({ type: "port" }, [port2]);
117
- return port1;
118
- };
119
128
  }
120
129
  function onModuleLoaded(name, mod) {
121
130
  if (Array.isArray(mod.plugins)) {
122
- console.log(`[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
131
+ log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
123
132
  registerPlugins(mod.plugins, name);
124
133
  }
125
134
  else {
126
- console.warn(`[site] module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
135
+ console.warn(`module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
127
136
  }
128
137
  }
129
138
  /**
@@ -136,7 +145,7 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
136
145
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
137
146
  if (!url)
138
147
  return;
139
- void moduleWatcher.addUrl(url);
148
+ void moduleWatcher.addUrl("user", url);
140
149
  accountDocHandle.off("change", wire);
141
150
  };
142
151
  wire();
@@ -151,7 +160,6 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
151
160
  */
152
161
  function primeRootElement(rootElement, accountDocHandle) {
153
162
  rootElement.style.visibility = "hidden";
154
- showLoadingAnimation();
155
163
  const initialParams = new URLSearchParams(location.hash.slice(1));
156
164
  if (initialParams.has("frame")) {
157
165
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
@@ -171,10 +179,10 @@ function logToolRegistryWhenLoaded(moduleWatcher) {
171
179
  .then(() => {
172
180
  const toolReg = getRegistry("patchwork:tool");
173
181
  const tools = toolReg.all();
174
- console.log(`[site] doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
182
+ log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
175
183
  })
176
184
  .catch((err) => {
177
- console.error("[site] doneLoading rejected:", err);
185
+ console.error("doneLoading rejected:", err);
178
186
  });
179
187
  }
180
188
  function buildSwLogApi() {
@@ -184,11 +192,11 @@ function buildSwLogApi() {
184
192
  for (const e of entries) {
185
193
  const prefix = `[${e.ts}] [${e.level}]`;
186
194
  if (e.data !== undefined)
187
- console.log(prefix, e.msg, e.data);
195
+ log(prefix, e.msg, e.data);
188
196
  else
189
- console.log(prefix, e.msg);
197
+ log(prefix, e.msg);
190
198
  }
191
- console.log(`--- ${entries.length} entries ---`);
199
+ log(`--- ${entries.length} entries ---`);
192
200
  },
193
201
  tailLogs: (n = 200) => SwLogReader.tail(n),
194
202
  exportLogs: () => SwLogReader.exportAll(),
@@ -218,6 +226,10 @@ function showLoadingAnimation() {
218
226
  radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
219
227
  animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
220
228
  transition: opacity 0.6s ease-out;
229
+ top: 0;
230
+ left: 0;
231
+ right: 0;
232
+ bottom: 0;
221
233
  }
222
234
  @media (prefers-color-scheme: dark) {
223
235
  #${LOADING_ELEMENT_ID} {
package/dist/types.d.ts CHANGED
@@ -5,3 +5,7 @@ export type SetupServiceWorkerOptions = {
5
5
  */
6
6
  path?: string;
7
7
  };
8
+ export type ServiceWorkerRepoChannelListener = (port: MessagePort) => void | Promise<void>;
9
+ export type SetupServiceWorkerResult = {
10
+ subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,27 +40,27 @@
40
40
  }
41
41
  },
42
42
  "dependencies": {
43
- "@automerge/automerge": "3.2.5",
44
- "@automerge/automerge-repo": "2.6.0-subduction.15",
45
- "@automerge/automerge-subduction": "0.8.1",
46
- "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.15",
47
- "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.15",
48
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.15",
49
- "@automerge/vanillajs": "2.6.0-subduction.15",
43
+ "@automerge/automerge": "3.2.6",
44
+ "@automerge/automerge-repo": "2.6.0-subduction.19",
45
+ "@automerge/automerge-subduction": "0.12.0",
46
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.19",
47
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.19",
48
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.19",
49
+ "@automerge/vanillajs": "2.6.0-subduction.19",
50
50
  "@types/debug": "^4.1.12",
51
51
  "debug": "^4.4.3",
52
52
  "resolve.exports": "^2.0.3",
53
53
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
54
54
  "tinyargs": "^0.1.4",
55
- "@inkandswitch/patchwork-elements": "^0.0.7",
56
- "@inkandswitch/patchwork-filesystem": "^0.0.5",
57
- "@inkandswitch/patchwork-plugins": "^0.0.7"
55
+ "@inkandswitch/patchwork-elements": "^0.0.8",
56
+ "@inkandswitch/patchwork-plugins": "^0.0.8",
57
+ "@inkandswitch/patchwork-filesystem": "^0.0.6"
58
58
  },
59
59
  "peerDependencies": {
60
- "@automerge/automerge": "3.2.5",
61
- "@automerge/automerge-repo": "2.6.0-subduction.15",
60
+ "@automerge/automerge": "3.2.6",
61
+ "@automerge/automerge-repo": "2.6.0-subduction.19",
62
62
  "@automerge/automerge-repo-keyhive": "0.2.0-alpha.1d",
63
- "@automerge/vanillajs": "2.6.0-subduction.15"
63
+ "@automerge/vanillajs": "2.6.0-subduction.19"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsc",
package/src/externals.ts CHANGED
@@ -6,7 +6,12 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
+ "@automerge/automerge-repo-network-messagechannel",
10
+ "@automerge/automerge-repo-storage-indexeddb",
9
11
  "@automerge/automerge-repo-keyhive",
12
+ "@automerge/automerge-repo-react-hooks",
13
+ "@automerge/automerge-repo-network-messagechannel",
14
+ "@automerge/automerge-repo-storage-indexeddb",
10
15
  "@automerge/automerge-subduction",
11
16
  "@automerge/automerge-subduction/slim",
12
17
  "@keyhive/keyhive",
@@ -8,7 +8,7 @@ import { SwLogger } from "./sw-logger.js";
8
8
  // Uses /slim to avoid top-level await (disallowed in service workers).
9
9
  // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
10
10
  // of bundling the ~3MB base64 string.
11
- import { initializeWasm } from "@automerge/automerge/slim";
11
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
12
12
  // eslint-disable-next-line
13
13
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
14
14
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
@@ -21,11 +21,7 @@ import {
21
21
  stringifyAutomergeUrl,
22
22
  type PeerId,
23
23
  } from "@automerge/automerge-repo/slim";
24
- import {
25
- findHandleInFolderHandle,
26
- resolvePackageExport,
27
- type FolderDoc,
28
- } from "@inkandswitch/patchwork-filesystem";
24
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
29
25
 
30
26
  // Small adapters — bundled directly into the SW
31
27
  import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
@@ -35,8 +31,10 @@ import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websoc
35
31
  // TEMPORARY: enable debug npm module in SW context (no localStorage available)
36
32
  let cachename = "default";
37
33
  let debugging = false;
34
+ const workerInstanceId = crypto.randomUUID();
38
35
 
39
36
  const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
37
+ const RESOLVE_TIMEOUT_MS = 30_000;
40
38
 
41
39
  // ── Persistent logger ───────────────────────────────────────────────────
42
40
  // Initialized eagerly so it's available for the entire SW lifetime.
@@ -66,9 +64,7 @@ const slog = SwLogger.open().then((logger) => {
66
64
  return logger;
67
65
  });
68
66
 
69
- const cacheableStatuses = [
70
- 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501,
71
- ];
67
+ const cacheableStatuses = [200, 203, 204, 206];
72
68
 
73
69
  function log(...args: any[]) {
74
70
  if (!debugging) return;
@@ -103,12 +99,13 @@ let repoPromise: Promise<Repo> | null = null;
103
99
 
104
100
  function getRepo() {
105
101
  if (!repoPromise) {
106
- repoPromise = (async () => {
102
+ const p: Promise<Repo> = (async () => {
107
103
  const logger = await slog;
104
+ logger.info("getRepo: starting");
108
105
 
109
106
  logger.info("fetching wasm modules");
110
107
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
111
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
108
+ fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
112
109
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
113
110
  ]);
114
111
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -127,7 +124,7 @@ function getRepo() {
127
124
  },
128
125
  enableRemoteHeadsGossiping: true,
129
126
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
130
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
127
+ //network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
131
128
  });
132
129
 
133
130
  (self as any).repo = repo;
@@ -143,6 +140,13 @@ function getRepo() {
143
140
 
144
141
  return repo;
145
142
  })();
143
+ // If construction fails (e.g. wasm fetch errors out because the SW was
144
+ // terminated mid-flight), don't permanently cache the rejection — clear
145
+ // the slot so the next caller can retry from scratch.
146
+ p.catch(() => {
147
+ if (repoPromise === p) repoPromise = null;
148
+ });
149
+ repoPromise = p;
146
150
  }
147
151
  return repoPromise;
148
152
  }
@@ -163,17 +167,39 @@ self.addEventListener("message", async (event) => {
163
167
  const [pongPort] = event.ports;
164
168
  log("ping");
165
169
  if (pongPort) {
166
- pongPort.postMessage({ type: "pong" });
170
+ pongPort.postMessage({ type: "pong", workerInstanceId });
167
171
  log("pong");
168
172
  pongPort.close();
169
173
  } else if (event.source) {
170
- (event.source as unknown as Client).postMessage({ type: "pong" });
174
+ (event.source as unknown as Client).postMessage({
175
+ type: "pong",
176
+ workerInstanceId,
177
+ });
171
178
  log("pong");
172
179
  }
173
180
  } else if (event.data.type == "port") {
174
181
  log("received messagechannel");
175
182
  const [port] = event.ports;
176
- connectPort(port);
183
+ const source = event.source as Client | null;
184
+ const id = event.data.id;
185
+ // event.waitUntil keeps the SW alive until the work completes. Without
186
+ // it, the browser can terminate the SW the moment this synchronous block
187
+ // returns, killing the in-flight wasm fetch.
188
+ (event as unknown as FetchEvent).waitUntil(
189
+ connectPort(port).then(
190
+ () => source?.postMessage({ type: "port-ready", id, workerInstanceId }),
191
+ (err) => {
192
+ console.error("connectPort failed", err);
193
+ // Tell the client we failed so it doesn't hang forever.
194
+ source?.postMessage({
195
+ type: "port-failed",
196
+ id,
197
+ error: String(err),
198
+ workerInstanceId,
199
+ });
200
+ }
201
+ )
202
+ );
177
203
  } else if (event.data.type == "cachename") {
178
204
  const nextCachename = event.data.cachename;
179
205
  if (cachename == nextCachename) {
@@ -190,11 +216,6 @@ self.addEventListener("message", async (event) => {
190
216
  }
191
217
  });
192
218
 
193
- interface FileDoc {
194
- content: string | Uint8Array;
195
- mimeType?: string;
196
- }
197
-
198
219
  // ── Automerge URL resolution ───────────────────────────────────────────
199
220
 
200
221
  async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
@@ -209,11 +230,11 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
209
230
  // Trim trailing empty path segment
210
231
  if (path.length && !path[path.length - 1]) path.pop();
211
232
 
212
- const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
233
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
234
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
213
235
 
214
236
  if (!heads) {
215
- // Redirect to pinned-heads URL
216
- const folder = await repo.find(maybeAutomergeUrl);
237
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
217
238
  const latestHeads = folder.heads();
218
239
  const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
219
240
  let location = `/${encodeURIComponent(url)}`;
@@ -221,93 +242,35 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
221
242
  return Response.redirect(location, 307);
222
243
  }
223
244
 
224
- // If no path, check if this is a package with exports to resolve
225
- // e.g. /automerge%3Adocid/abc resolve "abc" via package.json exports
226
- const folderHandle = await repo.find<FolderDoc>(maybeAutomergeUrl);
227
-
228
- let fileHandle;
229
- if (path.length) {
230
- // Try direct file navigation first
231
- fileHandle = await findHandleInFolderHandle<FileDoc>(
232
- repo,
233
- folderHandle,
234
- path.map(decodeURIComponent)
235
- );
236
-
237
- // If not found as a direct path, try resolving as a package subpath export
238
- // e.g. /automerge%3Adocid/abc → exports["./abc"] → "./dist/abc.js"
239
- if (!fileHandle) {
240
- const subpath = "./" + path.map(decodeURIComponent).join("/");
241
- const pkgFileHandle = await findHandleInFolderHandle<FileDoc>(
242
- repo,
243
- folderHandle,
244
- ["package.json"]
245
- );
246
- if (pkgFileHandle) {
247
- const pkgDoc = pkgFileHandle.doc() as FileDoc | undefined;
248
- if (pkgDoc?.content) {
249
- const pkgJson = JSON.parse(String(pkgDoc.content));
250
- try {
251
- const resolved = resolvePackageExport(pkgJson, subpath);
252
- if (resolved) {
253
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
254
- fileHandle = await findHandleInFolderHandle<FileDoc>(
255
- repo,
256
- folderHandle,
257
- resolvedPath
258
- );
259
- }
260
- } catch {
261
- // not a valid export subpath, fall through to error
262
- }
263
- }
264
- }
265
- }
266
- } else {
267
- // No path — resolve the root export (like "." in package.json)
268
- const pkgFileHandle = await findHandleInFolderHandle<FileDoc>(
269
- repo,
270
- folderHandle,
271
- ["package.json"]
272
- );
273
- if (pkgFileHandle) {
274
- const pkgDoc = pkgFileHandle.doc() as FileDoc | undefined;
275
- if (pkgDoc?.content) {
276
- const pkgJson = JSON.parse(String(pkgDoc.content));
277
- try {
278
- const resolved = resolvePackageExport(pkgJson);
279
- if (resolved) {
280
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
281
- fileHandle = await findHandleInFolderHandle<FileDoc>(
282
- repo,
283
- folderHandle,
284
- resolvedPath
285
- );
286
- }
287
- } catch {}
288
- }
289
- }
245
+ // Load by documentId only so we can verify the requested heads are actually
246
+ // in our local history. repo.find with a heads-bearing URL returns a view
247
+ // at those heads, which silently materializes garbage if we never synced them.
248
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
249
+ signal,
250
+ });
251
+ if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
252
+ return new Response("heads not found", { status: 404 });
290
253
  }
254
+ const rootHandle = baseHandle.view(heads);
255
+
256
+ const resolved = await resolvePath(
257
+ repo,
258
+ rootHandle,
259
+ path.map(decodeURIComponent)
260
+ );
291
261
 
292
- if (!fileHandle) {
262
+ if (!resolved) {
293
263
  throw new Error(
294
264
  `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
295
265
  );
296
266
  }
297
267
 
298
- const fileDoc = fileHandle.doc() as unknown as FileDoc;
299
- const content = fileDoc?.content;
300
- if (!content) {
301
- throw new Error(`file at ${href} has no content`);
302
- }
303
-
304
- let body: BodyInit =
305
- content instanceof Uint8Array
306
- ? (new Uint8Array(content) as BlobPart)
307
- : String(content);
308
- const mimeType = fileDoc.mimeType ?? "text/plain";
268
+ const body: BodyInit =
269
+ resolved.content instanceof Uint8Array
270
+ ? (new Uint8Array(resolved.content) as BlobPart)
271
+ : resolved.content;
309
272
 
310
- const headers = new Headers({ "content-type": mimeType });
273
+ const headers = new Headers({ "content-type": resolved.type });
311
274
  headers.set("cross-origin-embedder-policy", "credentialless");
312
275
  headers.set("cross-origin-resource-policy", "cross-origin");
313
276
 
@@ -353,7 +316,18 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
353
316
  });
354
317
  }
355
318
 
356
- const response = await resolveAutomergeUrl(specialURL);
319
+ const response = await Promise.race([
320
+ resolveAutomergeUrl(specialURL),
321
+ new Promise<never>((_, reject) =>
322
+ setTimeout(
323
+ () =>
324
+ reject(
325
+ new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)
326
+ ),
327
+ RESOLVE_TIMEOUT_MS
328
+ )
329
+ ),
330
+ ]);
357
331
 
358
332
  if (response.status === 307) {
359
333
  return response;