@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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e6afa48: Add `@inkandswitch/patchwork-bootloader/site` entry point exporting
8
+ `bootPatchworkSite(config)`, a full browser-app boot sequence that constructs
9
+ the Repo, wires the service-worker port, loads plugins via the ModuleWatcher,
10
+ resolves the user's account, and installs URL-hash routing + dev globals. This
11
+ lets per-site `main.ts` collapse to a ~10-line config object and keeps two
12
+ sibling sites from drifting apart.
13
+
14
+ Also removes the unused `@inkandswitch/patchwork-bootloader` devDependency from
15
+ `@inkandswitch/patchwork-plugins`, which eliminated a cyclic workspace edge.
16
+
17
+ ### Patch Changes
18
+
19
+ - a847c4f: release
20
+ - Updated dependencies [e6afa48]
21
+ - Updated dependencies [a847c4f]
22
+ - @inkandswitch/patchwork-plugins@0.0.8
23
+ - @inkandswitch/patchwork-elements@0.0.8
24
+ - @inkandswitch/patchwork-filesystem@0.0.6
25
+
3
26
  ## 0.0.4
4
27
 
5
28
  ### Patch Changes
package/dist/externals.js 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",
@@ -6,21 +6,22 @@ import { SwLogger } from "./sw-logger.js";
6
6
  // Uses /slim to avoid top-level await (disallowed in service workers).
7
7
  // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
8
8
  // of bundling the ~3MB base64 string.
9
- import { initializeWasm } from "@automerge/automerge/slim";
9
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
10
10
  // eslint-disable-next-line
11
11
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
12
12
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
13
13
  import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
14
14
  import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
15
- import { findHandleInFolderHandle, resolvePackageExport, } from "@inkandswitch/patchwork-filesystem";
15
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
16
16
  // Small adapters — bundled directly into the SW
17
17
  import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
18
18
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
19
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
20
19
  // TEMPORARY: enable debug npm module in SW context (no localStorage available)
21
20
  let cachename = "default";
22
21
  let debugging = false;
22
+ const workerInstanceId = crypto.randomUUID();
23
23
  const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
24
+ const RESOLVE_TIMEOUT_MS = 30_000;
24
25
  // ── Persistent logger ───────────────────────────────────────────────────
25
26
  // Initialized eagerly so it's available for the entire SW lifetime.
26
27
  // Access from the SW inspector console via self.printLogs(), self.tailLogs(),
@@ -46,9 +47,7 @@ const slog = SwLogger.open().then((logger) => {
46
47
  logger.info("sw-logger initialized");
47
48
  return logger;
48
49
  });
49
- const cacheableStatuses = [
50
- 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501,
51
- ];
50
+ const cacheableStatuses = [200, 203, 204, 206];
52
51
  function log(...args) {
53
52
  if (!debugging)
54
53
  return;
@@ -72,11 +71,12 @@ self.addEventListener("activate", async () => {
72
71
  let repoPromise = null;
73
72
  function getRepo() {
74
73
  if (!repoPromise) {
75
- repoPromise = (async () => {
74
+ const p = (async () => {
76
75
  const logger = await slog;
76
+ logger.info("getRepo: starting");
77
77
  logger.info("fetching wasm modules");
78
78
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
79
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
79
+ fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
80
80
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
81
81
  ]);
82
82
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -93,7 +93,7 @@ function getRepo() {
93
93
  },
94
94
  enableRemoteHeadsGossiping: true,
95
95
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
96
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
96
+ //network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
97
97
  });
98
98
  self.repo = repo;
99
99
  logger.info("repo constructed, waiting for network subsystem");
@@ -106,6 +106,14 @@ function getRepo() {
106
106
  });
107
107
  return repo;
108
108
  })();
109
+ // If construction fails (e.g. wasm fetch errors out because the SW was
110
+ // terminated mid-flight), don't permanently cache the rejection — clear
111
+ // the slot so the next caller can retry from scratch.
112
+ p.catch(() => {
113
+ if (repoPromise === p)
114
+ repoPromise = null;
115
+ });
116
+ repoPromise = p;
109
117
  }
110
118
  return repoPromise;
111
119
  }
@@ -122,19 +130,36 @@ self.addEventListener("message", async (event) => {
122
130
  const [pongPort] = event.ports;
123
131
  log("ping");
124
132
  if (pongPort) {
125
- pongPort.postMessage({ type: "pong" });
133
+ pongPort.postMessage({ type: "pong", workerInstanceId });
126
134
  log("pong");
127
135
  pongPort.close();
128
136
  }
129
137
  else if (event.source) {
130
- event.source.postMessage({ type: "pong" });
138
+ event.source.postMessage({
139
+ type: "pong",
140
+ workerInstanceId,
141
+ });
131
142
  log("pong");
132
143
  }
133
144
  }
134
145
  else if (event.data.type == "port") {
135
146
  log("received messagechannel");
136
147
  const [port] = event.ports;
137
- connectPort(port);
148
+ const source = event.source;
149
+ const id = event.data.id;
150
+ // event.waitUntil keeps the SW alive until the work completes. Without
151
+ // it, the browser can terminate the SW the moment this synchronous block
152
+ // returns, killing the in-flight wasm fetch.
153
+ event.waitUntil(connectPort(port).then(() => source?.postMessage({ type: "port-ready", id, workerInstanceId }), (err) => {
154
+ console.error("connectPort failed", err);
155
+ // Tell the client we failed so it doesn't hang forever.
156
+ source?.postMessage({
157
+ type: "port-failed",
158
+ id,
159
+ error: String(err),
160
+ workerInstanceId,
161
+ });
162
+ }));
138
163
  }
139
164
  else if (event.data.type == "cachename") {
140
165
  const nextCachename = event.data.cachename;
@@ -161,10 +186,10 @@ async function resolveAutomergeUrl(automergeURL) {
161
186
  // Trim trailing empty path segment
162
187
  if (path.length && !path[path.length - 1])
163
188
  path.pop();
164
- const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
189
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
190
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
165
191
  if (!heads) {
166
- // Redirect to pinned-heads URL
167
- const folder = await repo.find(maybeAutomergeUrl);
192
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
168
193
  const latestHeads = folder.heads();
169
194
  const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
170
195
  let location = `/${encodeURIComponent(url)}`;
@@ -172,67 +197,24 @@ async function resolveAutomergeUrl(automergeURL) {
172
197
  location += `/${path.join("/")}`;
173
198
  return Response.redirect(location, 307);
174
199
  }
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
- }
200
+ // Load by documentId only so we can verify the requested heads are actually
201
+ // in our local history. repo.find with a heads-bearing URL returns a view
202
+ // at those heads, which silently materializes garbage if we never synced them.
203
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
204
+ signal,
205
+ });
206
+ if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
207
+ return new Response("heads not found", { status: 404 });
222
208
  }
223
- if (!fileHandle) {
209
+ const rootHandle = baseHandle.view(heads);
210
+ const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
211
+ if (!resolved) {
224
212
  throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
225
213
  }
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 });
214
+ const body = resolved.content instanceof Uint8Array
215
+ ? new Uint8Array(resolved.content)
216
+ : resolved.content;
217
+ const headers = new Headers({ "content-type": resolved.type });
236
218
  headers.set("cross-origin-embedder-policy", "credentialless");
237
219
  headers.set("cross-origin-resource-policy", "cross-origin");
238
220
  return new Response(body, { status: 200, headers });
@@ -269,7 +251,10 @@ self.addEventListener("fetch", (fetchEvent) => {
269
251
  headers,
270
252
  });
271
253
  }
272
- const response = await resolveAutomergeUrl(specialURL);
254
+ const response = await Promise.race([
255
+ resolveAutomergeUrl(specialURL),
256
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)), RESOLVE_TIMEOUT_MS)),
257
+ ]);
273
258
  if (response.status === 307) {
274
259
  return response;
275
260
  }
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { SetupServiceWorkerOptions } from "./types.js";
1
+ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
2
2
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
3
- export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<{
4
- port: MessagePort;
5
- }>;
3
+ export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
package/dist/setup.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import debug from "debug";
2
2
  const debugging = debug.enabled("patchwork:serviceworker");
3
3
  const key = "patchworkServiceWorkerCacheVersion";
4
+ let nextRepoChannelId = 0;
5
+ let serviceWorkerInstanceId;
4
6
  function bumpServiceWorkerCacheVersion() {
5
7
  const version = new Date().valueOf().toString(36);
6
8
  localStorage.setItem(key, version);
@@ -29,6 +31,21 @@ export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller)
29
31
  setServiceWorkerCacheName(sw);
30
32
  }
31
33
  window.bumpServiceWorkerCache = bumpServiceWorkerCache;
34
+ function configureServiceWorker(sw) {
35
+ if (!sw)
36
+ return;
37
+ sw.postMessage({ type: "debug", debug: debugging });
38
+ const cachename = getServiceWorkerCacheVersion();
39
+ if (cachename)
40
+ sw.postMessage({ type: "cachename", cachename });
41
+ }
42
+ function updateServiceWorkerInstanceId(next) {
43
+ if (typeof next !== "string")
44
+ return false;
45
+ const changed = serviceWorkerInstanceId != null && serviceWorkerInstanceId !== next;
46
+ serviceWorkerInstanceId = next;
47
+ return changed;
48
+ }
32
49
  /** Wait for a registration to have an active worker */
33
50
  function waitForActive(reg) {
34
51
  if (reg.active)
@@ -43,51 +60,154 @@ function waitForActive(reg) {
43
60
  });
44
61
  });
45
62
  }
63
+ async function openRepoChannel() {
64
+ const controller = navigator.serviceWorker.controller;
65
+ if (!controller) {
66
+ throw new Error("no service worker controller");
67
+ }
68
+ // Send a MessagePort so the SW's repo can sync with clients, and wait for
69
+ // the SW to confirm its repo is constructed before returning. The
70
+ // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
71
+ // of the other end's state, so it can't be used as a real readiness signal
72
+ // on first install (when the SW still has to fetch wasm and build its repo).
73
+ const id = ++nextRepoChannelId;
74
+ let workerInstanceChanged = false;
75
+ const { port1, port2 } = new MessageChannel();
76
+ const swReady = new Promise((resolve, reject) => {
77
+ let timeout;
78
+ const cleanup = () => {
79
+ clearTimeout(timeout);
80
+ navigator.serviceWorker.removeEventListener("message", listener);
81
+ };
82
+ const listener = (event) => {
83
+ if (event.data?.id != null && event.data.id !== id)
84
+ return;
85
+ if (event.data?.type === "port-ready") {
86
+ workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
87
+ cleanup();
88
+ resolve();
89
+ }
90
+ else if (event.data?.type === "port-failed") {
91
+ workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
92
+ cleanup();
93
+ reject(new Error(`service worker init failed: ${event.data.error}`));
94
+ }
95
+ };
96
+ navigator.serviceWorker.addEventListener("message", listener);
97
+ // Failsafe: don't block boot forever if the SW never replies. Surface the
98
+ // issue and let the rest of the site come up rather than hanging on a
99
+ // blank page.
100
+ timeout = setTimeout(() => {
101
+ cleanup();
102
+ reject(new Error("service worker port-ready timeout"));
103
+ }, 30_000);
104
+ });
105
+ controller.postMessage({ type: "port", id }, [port2]);
106
+ try {
107
+ await swReady;
108
+ }
109
+ catch (err) {
110
+ console.warn("proceeding without SW ready ack:", err instanceof Error ? err.message : err);
111
+ }
112
+ return { port: port1, workerInstanceChanged };
113
+ }
46
114
  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
- });
115
+ const repoChannelListeners = new Set();
116
+ let reconnectPromise = null;
117
+ const reconnectRepoChannels = (reason) => {
118
+ if (reconnectPromise)
119
+ return reconnectPromise;
120
+ reconnectPromise = (async () => {
121
+ console.info(`%cservice worker ${reason}, reconnecting repo channels...`, "color: pink; font-weight: bold");
122
+ configureServiceWorker(navigator.serviceWorker.controller);
123
+ for (const listener of repoChannelListeners) {
124
+ try {
125
+ const { port } = await openRepoChannel();
126
+ await listener(port);
127
+ }
128
+ catch (err) {
129
+ console.error("service worker repo channel listener failed", err);
130
+ }
131
+ }
132
+ })().finally(() => {
133
+ reconnectPromise = null;
134
+ });
135
+ return reconnectPromise;
136
+ };
137
+ const pingServiceWorker = async () => {
138
+ const controller = navigator.serviceWorker.controller;
139
+ if (!controller)
140
+ return;
141
+ const { port1, port2 } = new MessageChannel();
142
+ const pong = new Promise((resolve, reject) => {
143
+ const timeout = setTimeout(() => {
144
+ port1.close();
145
+ reject(new Error("service worker pong timeout"));
146
+ }, 5_000);
147
+ port1.onmessage = (event) => {
148
+ clearTimeout(timeout);
149
+ port1.close();
150
+ resolve(event.data?.workerInstanceId);
151
+ };
152
+ });
153
+ controller.postMessage({ type: "ping" }, [port2]);
154
+ try {
155
+ const restarted = updateServiceWorkerInstanceId(await pong);
156
+ if (restarted) {
157
+ await reconnectRepoChannels("restarted");
158
+ }
60
159
  }
61
- });
160
+ catch (err) {
161
+ console.warn("service worker ping failed:", err instanceof Error ? err.message : err);
162
+ }
163
+ };
62
164
  const path = options?.path ?? "/service-worker.js";
165
+ // No controller at this point means the page loaded without a service
166
+ // worker — i.e. this is a first-time install (or a hard reload). Wait for
167
+ // activation so the app boots with the SW in control of generated fetches.
63
168
  const reg = await navigator.serviceWorker.register(path, { type: "module" });
64
169
  // If there's an update waiting or installing, wait for it to activate
170
+ let active = reg.active;
65
171
  if (reg.installing || reg.waiting) {
66
- await waitForActive(reg);
172
+ active = await waitForActive(reg);
67
173
  }
68
- const active = reg.active;
69
- active.postMessage({ type: "debug", debug: debugging });
174
+ configureServiceWorker(active);
70
175
  // Wait for the controller to be available
71
176
  if (!navigator.serviceWorker.controller) {
72
177
  await new Promise((resolve) => {
73
178
  navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
74
179
  });
75
180
  }
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
181
  // Keepalive — Chromium idles out service workers after ~30s of inactivity,
80
182
  // 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.
183
+ // fetch. Ping through a MessageChannel so we can detect when a restarted SW
184
+ // has a new in-memory Repo and reconnect all repo channels.
82
185
  setInterval(() => {
83
- navigator.serviceWorker.controller?.postMessage({ type: "ping" });
186
+ void pingServiceWorker();
84
187
  }, 20_000);
85
- // Reload on future SW updates (added after setup so the initial
86
- // activation doesn't trigger a reload loop).
188
+ // Reconnect on future SW updates (added after setup so the initial
189
+ // activation doesn't notify before callers subscribe).
87
190
  navigator.serviceWorker.addEventListener("controllerchange", function () {
88
- console.info("%cnew service worker took control, reloading...", "color: pink; font-weight: bold");
89
- location.reload();
191
+ void reconnectRepoChannels("took control").catch((err) => {
192
+ console.error("service worker reconnect failed", err);
193
+ });
90
194
  });
91
195
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
92
- return { port: port1 };
196
+ return {
197
+ async subscribeToRepoChannel(listener) {
198
+ const { port, workerInstanceChanged } = await openRepoChannel();
199
+ if (workerInstanceChanged) {
200
+ await reconnectRepoChannels("restarted");
201
+ }
202
+ repoChannelListeners.add(listener);
203
+ try {
204
+ await listener(port);
205
+ }
206
+ catch (err) {
207
+ repoChannelListeners.delete(listener);
208
+ throw err;
209
+ }
210
+ return () => repoChannelListeners.delete(listener);
211
+ },
212
+ };
93
213
  }
package/dist/site.d.ts CHANGED
@@ -15,6 +15,7 @@ import { type DocHandle, Repo, type AutomergeUrl, type StorageId } from "@autome
15
15
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
16
16
  import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
17
17
  import * as plugins from "@inkandswitch/patchwork-plugins";
18
+ import type { ServiceWorkerRepoChannelListener } from "./types.js";
18
19
  import { SwLogReader } from "./sw-logger.js";
19
20
  declare global {
20
21
  interface Window {
@@ -22,10 +23,9 @@ declare global {
22
23
  Automerge: typeof import("@automerge/automerge");
23
24
  AutomergeRepo: typeof import("@automerge/automerge-repo");
24
25
  repo: Repo;
25
- getRepoChannel: () => MessagePort;
26
26
  patchwork: {
27
27
  repo: Repo;
28
- modules: ModuleWatcher;
28
+ packages: ModuleWatcher;
29
29
  plugins: typeof plugins;
30
30
  accountDocHandle: DocHandle<AccountDoc>;
31
31
  sw: {
@@ -33,6 +33,7 @@ declare global {
33
33
  tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
34
34
  exportLogs: () => Promise<string>;
35
35
  clearLogs: () => Promise<void>;
36
+ subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
36
37
  };
37
38
  };
38
39
  uncache: (match: string) => Promise<void>;