@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 +23 -0
- package/dist/externals.js +5 -0
- package/dist/service-worker.js +59 -74
- package/dist/setup.d.ts +2 -4
- package/dist/setup.js +147 -27
- package/dist/site.d.ts +3 -2
- package/dist/site.js +38 -26
- package/dist/types.d.ts +4 -0
- package/package.json +14 -14
- package/src/externals.ts +5 -0
- package/src/service-worker.ts +77 -103
- package/src/setup.ts +165 -33
- package/src/site.ts +54 -32
- package/src/types.ts +10 -0
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",
|
package/dist/service-worker.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
+
void pingServiceWorker();
|
|
84
187
|
}, 20_000);
|
|
85
|
-
//
|
|
86
|
-
// activation doesn't
|
|
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
|
-
|
|
89
|
-
|
|
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 {
|
|
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
|
-
|
|
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>;
|