@inkandswitch/patchwork-bootloader 0.0.9 → 0.1.1
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 +3 -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 +3 -0
- package/src/service-worker.ts +26 -7
- package/src/setup.ts +147 -56
- package/src/site.ts +22 -11
- 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
|
@@ -9,6 +9,9 @@ const externals = [
|
|
|
9
9
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
10
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
11
11
|
"@automerge/automerge-repo-keyhive",
|
|
12
|
+
"@automerge/automerge-repo-react-hooks",
|
|
13
|
+
"@automerge/automerge-repo-network-messagechannel",
|
|
14
|
+
"@automerge/automerge-repo-storage-indexeddb",
|
|
12
15
|
"@automerge/automerge-subduction",
|
|
13
16
|
"@automerge/automerge-subduction/slim",
|
|
14
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>;
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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
|
-
|
|
92
|
+
packages: moduleWatcher,
|
|
82
93
|
plugins,
|
|
83
94
|
accountDocHandle,
|
|
84
|
-
sw:
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
182
|
+
log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
|
|
175
183
|
})
|
|
176
184
|
.catch((err) => {
|
|
177
|
-
console.error("
|
|
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
|
-
|
|
195
|
+
log(prefix, e.msg, e.data);
|
|
188
196
|
else
|
|
189
|
-
|
|
197
|
+
log(prefix, e.msg);
|
|
190
198
|
}
|
|
191
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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.
|
|
44
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
45
|
-
"@automerge/automerge-subduction": "0.
|
|
46
|
-
"@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.
|
|
47
|
-
"@automerge/automerge-repo-network-websocket": "2.6.0-subduction.
|
|
48
|
-
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.
|
|
49
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
43
|
+
"@automerge/automerge": "3.2.6",
|
|
44
|
+
"@automerge/automerge-repo": "2.6.0-subduction.20",
|
|
45
|
+
"@automerge/automerge-subduction": "0.12.1",
|
|
46
|
+
"@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.20",
|
|
47
|
+
"@automerge/automerge-repo-network-websocket": "2.6.0-subduction.20",
|
|
48
|
+
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.20",
|
|
49
|
+
"@automerge/vanillajs": "2.6.0-subduction.20",
|
|
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.
|
|
56
|
-
"@inkandswitch/patchwork-
|
|
57
|
-
"@inkandswitch/patchwork-
|
|
55
|
+
"@inkandswitch/patchwork-elements": "^0.0.8",
|
|
56
|
+
"@inkandswitch/patchwork-plugins": "^0.0.8",
|
|
57
|
+
"@inkandswitch/patchwork-filesystem": "^0.0.7"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@automerge/automerge": "3.2.
|
|
61
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
60
|
+
"@automerge/automerge": "3.2.6",
|
|
61
|
+
"@automerge/automerge-repo": "2.6.0-subduction.20",
|
|
62
62
|
"@automerge/automerge-repo-keyhive": "0.2.0-alpha.1d",
|
|
63
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
63
|
+
"@automerge/vanillajs": "2.6.0-subduction.20"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "tsc",
|
package/src/externals.ts
CHANGED
|
@@ -9,6 +9,9 @@ const externals = [
|
|
|
9
9
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
10
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
11
11
|
"@automerge/automerge-repo-keyhive",
|
|
12
|
+
"@automerge/automerge-repo-react-hooks",
|
|
13
|
+
"@automerge/automerge-repo-network-messagechannel",
|
|
14
|
+
"@automerge/automerge-repo-storage-indexeddb",
|
|
12
15
|
"@automerge/automerge-subduction",
|
|
13
16
|
"@automerge/automerge-subduction/slim",
|
|
14
17
|
"@keyhive/keyhive",
|
package/src/service-worker.ts
CHANGED
|
@@ -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";
|
|
@@ -31,6 +31,7 @@ import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websoc
|
|
|
31
31
|
// TEMPORARY: enable debug npm module in SW context (no localStorage available)
|
|
32
32
|
let cachename = "default";
|
|
33
33
|
let debugging = false;
|
|
34
|
+
const workerInstanceId = crypto.randomUUID();
|
|
34
35
|
|
|
35
36
|
const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
|
|
36
37
|
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
@@ -166,27 +167,36 @@ self.addEventListener("message", async (event) => {
|
|
|
166
167
|
const [pongPort] = event.ports;
|
|
167
168
|
log("ping");
|
|
168
169
|
if (pongPort) {
|
|
169
|
-
pongPort.postMessage({ type: "pong" });
|
|
170
|
+
pongPort.postMessage({ type: "pong", workerInstanceId });
|
|
170
171
|
log("pong");
|
|
171
172
|
pongPort.close();
|
|
172
173
|
} else if (event.source) {
|
|
173
|
-
(event.source as unknown as Client).postMessage({
|
|
174
|
+
(event.source as unknown as Client).postMessage({
|
|
175
|
+
type: "pong",
|
|
176
|
+
workerInstanceId,
|
|
177
|
+
});
|
|
174
178
|
log("pong");
|
|
175
179
|
}
|
|
176
180
|
} else if (event.data.type == "port") {
|
|
177
181
|
log("received messagechannel");
|
|
178
182
|
const [port] = event.ports;
|
|
179
183
|
const source = event.source as Client | null;
|
|
184
|
+
const id = event.data.id;
|
|
180
185
|
// event.waitUntil keeps the SW alive until the work completes. Without
|
|
181
186
|
// it, the browser can terminate the SW the moment this synchronous block
|
|
182
187
|
// returns, killing the in-flight wasm fetch.
|
|
183
188
|
(event as unknown as FetchEvent).waitUntil(
|
|
184
189
|
connectPort(port).then(
|
|
185
|
-
() => source?.postMessage({ type: "port-ready" }),
|
|
190
|
+
() => source?.postMessage({ type: "port-ready", id, workerInstanceId }),
|
|
186
191
|
(err) => {
|
|
187
192
|
console.error("connectPort failed", err);
|
|
188
193
|
// Tell the client we failed so it doesn't hang forever.
|
|
189
|
-
source?.postMessage({
|
|
194
|
+
source?.postMessage({
|
|
195
|
+
type: "port-failed",
|
|
196
|
+
id,
|
|
197
|
+
error: String(err),
|
|
198
|
+
workerInstanceId,
|
|
199
|
+
});
|
|
190
200
|
}
|
|
191
201
|
)
|
|
192
202
|
);
|
|
@@ -220,7 +230,7 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
|
|
|
220
230
|
// Trim trailing empty path segment
|
|
221
231
|
if (path.length && !path[path.length - 1]) path.pop();
|
|
222
232
|
|
|
223
|
-
const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
|
|
233
|
+
const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
|
|
224
234
|
const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
225
235
|
|
|
226
236
|
if (!heads) {
|
|
@@ -232,7 +242,16 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
|
|
|
232
242
|
return Response.redirect(location, 307);
|
|
233
243
|
}
|
|
234
244
|
|
|
235
|
-
|
|
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 });
|
|
253
|
+
}
|
|
254
|
+
const rootHandle = baseHandle.view(heads);
|
|
236
255
|
|
|
237
256
|
const resolved = await resolvePath(
|
|
238
257
|
repo,
|
package/src/setup.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ServiceWorkerRepoChannelListener,
|
|
3
|
+
SetupServiceWorkerOptions,
|
|
4
|
+
SetupServiceWorkerResult,
|
|
5
|
+
} from "./types.js";
|
|
2
6
|
import debug from "debug";
|
|
3
7
|
|
|
4
8
|
const debugging = debug.enabled("patchwork:serviceworker");
|
|
5
9
|
|
|
6
10
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
11
|
+
let nextRepoChannelId = 0;
|
|
12
|
+
let serviceWorkerInstanceId: string | undefined;
|
|
7
13
|
|
|
8
14
|
function bumpServiceWorkerCacheVersion() {
|
|
9
15
|
const version = new Date().valueOf().toString(36);
|
|
@@ -40,6 +46,21 @@ export function bumpServiceWorkerCache(
|
|
|
40
46
|
|
|
41
47
|
(window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;
|
|
42
48
|
|
|
49
|
+
function configureServiceWorker(sw: ServiceWorker | null) {
|
|
50
|
+
if (!sw) return;
|
|
51
|
+
sw.postMessage({ type: "debug", debug: debugging });
|
|
52
|
+
const cachename = getServiceWorkerCacheVersion();
|
|
53
|
+
if (cachename) sw.postMessage({ type: "cachename", cachename });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateServiceWorkerInstanceId(next: unknown) {
|
|
57
|
+
if (typeof next !== "string") return false;
|
|
58
|
+
const changed =
|
|
59
|
+
serviceWorkerInstanceId != null && serviceWorkerInstanceId !== next;
|
|
60
|
+
serviceWorkerInstanceId = next;
|
|
61
|
+
return changed;
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/** Wait for a registration to have an active worker */
|
|
44
65
|
function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
|
|
45
66
|
if (reg.active) return Promise.resolve(reg.active);
|
|
@@ -53,50 +74,13 @@ function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
|
|
|
53
74
|
});
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
navigator.serviceWorker.controller?.postMessage({
|
|
64
|
-
type: "response",
|
|
65
|
-
id: event.data.id,
|
|
66
|
-
response: {
|
|
67
|
-
body: "service worker upgraded, please refresh",
|
|
68
|
-
status: 503,
|
|
69
|
-
headers: { "content-type": "text/plain" },
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const path = options?.path ?? "/service-worker.js";
|
|
76
|
-
// No controller at this point means the page loaded without a service
|
|
77
|
-
// worker — i.e. this is a first-time install (or a hard reload). We'll
|
|
78
|
-
// reload after activation so the page boots with the SW in control of its
|
|
79
|
-
// initial fetches.
|
|
80
|
-
//const isFirstInstall = !navigator.serviceWorker.controller;
|
|
81
|
-
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
82
|
-
|
|
83
|
-
// If there's an update waiting or installing, wait for it to activate
|
|
84
|
-
if (reg.installing || reg.waiting) {
|
|
85
|
-
await waitForActive(reg);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const active = reg.active!;
|
|
89
|
-
active.postMessage({ type: "debug", debug: debugging });
|
|
90
|
-
|
|
91
|
-
// Wait for the controller to be available
|
|
92
|
-
if (!navigator.serviceWorker.controller) {
|
|
93
|
-
await new Promise<void>((resolve) => {
|
|
94
|
-
navigator.serviceWorker.addEventListener(
|
|
95
|
-
"controllerchange",
|
|
96
|
-
() => resolve(),
|
|
97
|
-
{ once: true }
|
|
98
|
-
);
|
|
99
|
-
});
|
|
77
|
+
async function openRepoChannel(): Promise<{
|
|
78
|
+
port: MessagePort;
|
|
79
|
+
workerInstanceChanged: boolean;
|
|
80
|
+
}> {
|
|
81
|
+
const controller = navigator.serviceWorker.controller;
|
|
82
|
+
if (!controller) {
|
|
83
|
+
throw new Error("no service worker controller");
|
|
100
84
|
}
|
|
101
85
|
|
|
102
86
|
// Send a MessagePort so the SW's repo can sync with clients, and wait for
|
|
@@ -104,6 +88,8 @@ export default async function setupServiceWorker(
|
|
|
104
88
|
// MessageChannel adapter's whenReady() force-resolves after 100ms regardless
|
|
105
89
|
// of the other end's state, so it can't be used as a real readiness signal
|
|
106
90
|
// on first install (when the SW still has to fetch wasm and build its repo).
|
|
91
|
+
const id = ++nextRepoChannelId;
|
|
92
|
+
let workerInstanceChanged = false;
|
|
107
93
|
const { port1, port2 } = new MessageChannel();
|
|
108
94
|
const swReady = new Promise<void>((resolve, reject) => {
|
|
109
95
|
let timeout: ReturnType<typeof setTimeout>;
|
|
@@ -112,10 +98,17 @@ export default async function setupServiceWorker(
|
|
|
112
98
|
navigator.serviceWorker.removeEventListener("message", listener);
|
|
113
99
|
};
|
|
114
100
|
const listener = (event: MessageEvent) => {
|
|
101
|
+
if (event.data?.id != null && event.data.id !== id) return;
|
|
115
102
|
if (event.data?.type === "port-ready") {
|
|
103
|
+
workerInstanceChanged = updateServiceWorkerInstanceId(
|
|
104
|
+
event.data.workerInstanceId
|
|
105
|
+
);
|
|
116
106
|
cleanup();
|
|
117
107
|
resolve();
|
|
118
108
|
} else if (event.data?.type === "port-failed") {
|
|
109
|
+
workerInstanceChanged = updateServiceWorkerInstanceId(
|
|
110
|
+
event.data.workerInstanceId
|
|
111
|
+
);
|
|
119
112
|
cleanup();
|
|
120
113
|
reject(new Error(`service worker init failed: ${event.data.error}`));
|
|
121
114
|
}
|
|
@@ -129,7 +122,7 @@ export default async function setupServiceWorker(
|
|
|
129
122
|
reject(new Error("service worker port-ready timeout"));
|
|
130
123
|
}, 30_000);
|
|
131
124
|
});
|
|
132
|
-
|
|
125
|
+
controller.postMessage({ type: "port", id }, [port2]);
|
|
133
126
|
try {
|
|
134
127
|
await swReady;
|
|
135
128
|
} catch (err) {
|
|
@@ -138,22 +131,105 @@ export default async function setupServiceWorker(
|
|
|
138
131
|
err instanceof Error ? err.message : err
|
|
139
132
|
);
|
|
140
133
|
}
|
|
134
|
+
return { port: port1, workerInstanceChanged };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default async function setupServiceWorker(
|
|
138
|
+
options?: SetupServiceWorkerOptions
|
|
139
|
+
): Promise<SetupServiceWorkerResult> {
|
|
140
|
+
const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
|
|
141
|
+
let reconnectPromise: Promise<void> | null = null;
|
|
142
|
+
|
|
143
|
+
const reconnectRepoChannels = (reason: string) => {
|
|
144
|
+
if (reconnectPromise) return reconnectPromise;
|
|
145
|
+
reconnectPromise = (async () => {
|
|
146
|
+
console.info(
|
|
147
|
+
`%cservice worker ${reason}, reconnecting repo channels...`,
|
|
148
|
+
"color: pink; font-weight: bold"
|
|
149
|
+
);
|
|
150
|
+
configureServiceWorker(navigator.serviceWorker.controller);
|
|
151
|
+
for (const listener of repoChannelListeners) {
|
|
152
|
+
try {
|
|
153
|
+
const { port } = await openRepoChannel();
|
|
154
|
+
await listener(port);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error("service worker repo channel listener failed", err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})().finally(() => {
|
|
160
|
+
reconnectPromise = null;
|
|
161
|
+
});
|
|
162
|
+
return reconnectPromise;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const pingServiceWorker = async () => {
|
|
166
|
+
const controller = navigator.serviceWorker.controller;
|
|
167
|
+
if (!controller) return;
|
|
168
|
+
const { port1, port2 } = new MessageChannel();
|
|
169
|
+
const pong = new Promise<unknown>((resolve, reject) => {
|
|
170
|
+
const timeout = setTimeout(() => {
|
|
171
|
+
port1.close();
|
|
172
|
+
reject(new Error("service worker pong timeout"));
|
|
173
|
+
}, 5_000);
|
|
174
|
+
port1.onmessage = (event) => {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
port1.close();
|
|
177
|
+
resolve(event.data?.workerInstanceId);
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
controller.postMessage({ type: "ping" }, [port2]);
|
|
181
|
+
try {
|
|
182
|
+
const restarted = updateServiceWorkerInstanceId(await pong);
|
|
183
|
+
if (restarted) {
|
|
184
|
+
await reconnectRepoChannels("restarted");
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.warn(
|
|
188
|
+
"service worker ping failed:",
|
|
189
|
+
err instanceof Error ? err.message : err
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const path = options?.path ?? "/service-worker.js";
|
|
195
|
+
// No controller at this point means the page loaded without a service
|
|
196
|
+
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
197
|
+
// activation so the app boots with the SW in control of generated fetches.
|
|
198
|
+
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
199
|
+
|
|
200
|
+
// If there's an update waiting or installing, wait for it to activate
|
|
201
|
+
let active = reg.active;
|
|
202
|
+
if (reg.installing || reg.waiting) {
|
|
203
|
+
active = await waitForActive(reg);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
configureServiceWorker(active);
|
|
207
|
+
|
|
208
|
+
// Wait for the controller to be available
|
|
209
|
+
if (!navigator.serviceWorker.controller) {
|
|
210
|
+
await new Promise<void>((resolve) => {
|
|
211
|
+
navigator.serviceWorker.addEventListener(
|
|
212
|
+
"controllerchange",
|
|
213
|
+
() => resolve(),
|
|
214
|
+
{ once: true }
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
141
218
|
|
|
142
219
|
// Keepalive — Chromium idles out service workers after ~30s of inactivity,
|
|
143
220
|
// which tears down the in-memory Repo and forces a cold restart on the next
|
|
144
|
-
// fetch.
|
|
221
|
+
// fetch. Ping through a MessageChannel so we can detect when a restarted SW
|
|
222
|
+
// has a new in-memory Repo and reconnect all repo channels.
|
|
145
223
|
setInterval(() => {
|
|
146
|
-
|
|
224
|
+
void pingServiceWorker();
|
|
147
225
|
}, 20_000);
|
|
148
226
|
|
|
149
|
-
//
|
|
150
|
-
// activation doesn't
|
|
227
|
+
// Reconnect on future SW updates (added after setup so the initial
|
|
228
|
+
// activation doesn't notify before callers subscribe).
|
|
151
229
|
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
|
152
|
-
|
|
153
|
-
"
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
location.reload();
|
|
230
|
+
void reconnectRepoChannels("took control").catch((err) => {
|
|
231
|
+
console.error("service worker reconnect failed", err);
|
|
232
|
+
});
|
|
157
233
|
});
|
|
158
234
|
|
|
159
235
|
console.log(
|
|
@@ -161,5 +237,20 @@ export default async function setupServiceWorker(
|
|
|
161
237
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
162
238
|
);
|
|
163
239
|
|
|
164
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
async subscribeToRepoChannel(listener) {
|
|
242
|
+
const { port, workerInstanceChanged } = await openRepoChannel();
|
|
243
|
+
if (workerInstanceChanged) {
|
|
244
|
+
await reconnectRepoChannels("restarted");
|
|
245
|
+
}
|
|
246
|
+
repoChannelListeners.add(listener);
|
|
247
|
+
try {
|
|
248
|
+
await listener(port);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
repoChannelListeners.delete(listener);
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
return () => repoChannelListeners.delete(listener);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
165
256
|
}
|
package/src/site.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
50
50
|
|
|
51
51
|
import setupServiceWorker from "./setup.js";
|
|
52
|
+
import type { ServiceWorkerRepoChannelListener } from "./types.js";
|
|
52
53
|
import { SwLogReader } from "./sw-logger.js";
|
|
53
54
|
import debug from "debug";
|
|
54
55
|
const log = debug("patchwork:bootloader:site");
|
|
@@ -59,7 +60,6 @@ declare global {
|
|
|
59
60
|
Automerge: typeof import("@automerge/automerge");
|
|
60
61
|
AutomergeRepo: typeof import("@automerge/automerge-repo");
|
|
61
62
|
repo: Repo;
|
|
62
|
-
getRepoChannel: () => MessagePort;
|
|
63
63
|
patchwork: {
|
|
64
64
|
repo: Repo;
|
|
65
65
|
packages: ModuleWatcher;
|
|
@@ -70,6 +70,9 @@ declare global {
|
|
|
70
70
|
tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
|
|
71
71
|
exportLogs: () => Promise<string>;
|
|
72
72
|
clearLogs: () => Promise<void>;
|
|
73
|
+
subscribeToRepoChannel: (
|
|
74
|
+
listener: ServiceWorkerRepoChannelListener
|
|
75
|
+
) => Promise<() => void>;
|
|
73
76
|
};
|
|
74
77
|
};
|
|
75
78
|
uncache: (match: string) => Promise<void>;
|
|
@@ -167,9 +170,16 @@ export async function bootPatchworkSite(
|
|
|
167
170
|
|
|
168
171
|
const sw = await setupServiceWorker();
|
|
169
172
|
if (!sw) throw new Error("Failed to set up service worker");
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
let activeServiceWorkerPort: MessagePort | undefined;
|
|
174
|
+
const connectServiceWorkerPort = async (port: MessagePort) => {
|
|
175
|
+
const previousPort = activeServiceWorkerPort;
|
|
176
|
+
activeServiceWorkerPort = port;
|
|
177
|
+
const net = new MessageChannelNetworkAdapter(port);
|
|
178
|
+
repo.networkSubsystem.addNetworkAdapter(net);
|
|
179
|
+
await net.whenReady();
|
|
180
|
+
previousPort?.close();
|
|
181
|
+
};
|
|
182
|
+
await sw.subscribeToRepoChannel(connectServiceWorkerPort);
|
|
173
183
|
|
|
174
184
|
installDevConsoleGlobals(repo);
|
|
175
185
|
registerPatchworkViewElement({ repo });
|
|
@@ -208,7 +218,10 @@ export async function bootPatchworkSite(
|
|
|
208
218
|
packages: moduleWatcher,
|
|
209
219
|
plugins,
|
|
210
220
|
accountDocHandle,
|
|
211
|
-
sw:
|
|
221
|
+
sw: {
|
|
222
|
+
...buildSwLogApi(),
|
|
223
|
+
subscribeToRepoChannel: sw.subscribeToRepoChannel,
|
|
224
|
+
},
|
|
212
225
|
};
|
|
213
226
|
window.uncache = uncache;
|
|
214
227
|
|
|
@@ -246,11 +259,6 @@ function installDevConsoleGlobals(repo: Repo): void {
|
|
|
246
259
|
window.repo = repo;
|
|
247
260
|
window.Automerge = Automerge;
|
|
248
261
|
window.AutomergeRepo = AutomergeRepo;
|
|
249
|
-
window.getRepoChannel = () => {
|
|
250
|
-
const { port1, port2 } = new MessageChannel();
|
|
251
|
-
navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
|
|
252
|
-
return port1;
|
|
253
|
-
};
|
|
254
262
|
}
|
|
255
263
|
|
|
256
264
|
function onModuleLoaded(name: string, mod: any): void {
|
|
@@ -329,7 +337,10 @@ function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
|
|
|
329
337
|
});
|
|
330
338
|
}
|
|
331
339
|
|
|
332
|
-
function buildSwLogApi():
|
|
340
|
+
function buildSwLogApi(): Omit<
|
|
341
|
+
Window["patchwork"]["sw"],
|
|
342
|
+
"subscribeToRepoChannel"
|
|
343
|
+
> {
|
|
333
344
|
return {
|
|
334
345
|
printLogs: async (n = 200) => {
|
|
335
346
|
const entries = await SwLogReader.tail(n);
|
package/src/types.ts
CHANGED
|
@@ -5,3 +5,13 @@ export type SetupServiceWorkerOptions = {
|
|
|
5
5
|
*/
|
|
6
6
|
path?: string;
|
|
7
7
|
};
|
|
8
|
+
|
|
9
|
+
export type ServiceWorkerRepoChannelListener = (
|
|
10
|
+
port: MessagePort
|
|
11
|
+
) => void | Promise<void>;
|
|
12
|
+
|
|
13
|
+
export type SetupServiceWorkerResult = {
|
|
14
|
+
subscribeToRepoChannel: (
|
|
15
|
+
listener: ServiceWorkerRepoChannelListener
|
|
16
|
+
) => Promise<() => void>;
|
|
17
|
+
};
|