@inkandswitch/patchwork-bootloader 0.2.5 → 0.2.7
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 +14 -0
- package/dist/automerge-worker.d.ts +1 -0
- package/dist/automerge-worker.js +505 -0
- package/dist/externals.js +0 -1
- package/dist/service-worker.js +98 -205
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +89 -106
- package/dist/site.d.ts +12 -7
- package/dist/site.js +61 -53
- package/dist/sync-config.d.ts +8 -0
- package/dist/sync-config.js +13 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +7 -1
- package/dist/vite/importmap-plugin.js +9 -3
- package/dist/vite/service-worker-plugin.js +25 -9
- package/package.json +20 -19
- package/src/automerge-worker.ts +647 -0
- package/src/externals.ts +0 -1
- package/src/service-worker.ts +123 -248
- package/src/setup.ts +105 -118
- package/src/site.ts +89 -65
- package/src/sync-config.ts +23 -0
- package/src/types.ts +98 -0
- package/src/vite/importmap-plugin.ts +13 -3
- package/src/vite/service-worker-plugin.ts +26 -11
- package/tsconfig.json +1 -1
- package/dist/sw-logger.d.ts +0 -105
- package/dist/sw-logger.js +0 -366
- package/src/sw-logger.ts +0 -463
package/dist/service-worker.js
CHANGED
|
@@ -1,60 +1,28 @@
|
|
|
1
1
|
/// <reference types="service-worker-types" />
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
|
|
10
|
-
// eslint-disable-next-line
|
|
11
|
-
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
12
|
-
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
13
|
-
import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
14
|
-
import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
|
|
15
|
-
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
16
|
-
// Small adapters — bundled directly into the SW
|
|
17
|
-
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
|
|
18
|
-
import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
|
|
19
|
-
import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
20
|
-
// TEMPORARY: enable debug npm module in SW context (no localStorage available)
|
|
2
|
+
// The service worker holds no automerge repo — that lives in the automerge
|
|
3
|
+
// SharedWorker (automerge-worker.ts). This worker manages the cache. When a
|
|
4
|
+
// special URL misses the cache it broadcasts a handoff request; the
|
|
5
|
+
// automerge worker resolves it, puts the response in our cache, and replies
|
|
6
|
+
// "cached" (or "response" for errors and other things that shouldn't be
|
|
7
|
+
// cached).
|
|
8
|
+
import { HANDOFF_CHANNEL, } from "./types.js";
|
|
21
9
|
let cachename = "default";
|
|
22
10
|
let debugging = false;
|
|
23
|
-
const workerInstanceId = crypto.randomUUID();
|
|
24
|
-
const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
|
|
25
|
-
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
26
|
-
// ── Persistent logger ───────────────────────────────────────────────────
|
|
27
|
-
// Initialized eagerly so it's available for the entire SW lifetime.
|
|
28
|
-
// Access from the SW inspector console via self.printLogs(), self.tailLogs(),
|
|
29
|
-
// self.exportLogs(), self.clearLogs().
|
|
30
|
-
const slog = SwLogger.open().then((logger) => {
|
|
31
|
-
self.slog = logger;
|
|
32
|
-
self.printLogs = async (n = 200) => {
|
|
33
|
-
const entries = await logger.tail(n);
|
|
34
|
-
for (const e of entries) {
|
|
35
|
-
const prefix = `[${e.ts}] [${e.level}]`;
|
|
36
|
-
if (e.data !== undefined) {
|
|
37
|
-
console.log(prefix, e.msg, e.data);
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
console.log(prefix, e.msg);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
console.log(`--- ${entries.length} entries ---`);
|
|
44
|
-
};
|
|
45
|
-
self.tailLogs = (n = 200) => logger.tail(n);
|
|
46
|
-
self.exportLogs = () => logger.exportAll();
|
|
47
|
-
self.clearLogs = () => logger.clear();
|
|
48
|
-
logger.info("sw-logger initialized");
|
|
49
|
-
return logger;
|
|
50
|
-
});
|
|
51
11
|
const cacheableStatuses = [200, 203, 204, 206];
|
|
12
|
+
// The automerge worker times its own resolution out after 30s and replies
|
|
13
|
+
// with an error, so this only fires when nobody is listening at all.
|
|
14
|
+
const HANDOFF_TIMEOUT_MS = 35_000;
|
|
52
15
|
function log(...args) {
|
|
53
16
|
if (!debugging)
|
|
54
17
|
return;
|
|
55
18
|
console.log.call(console, `%cpatchwork:serviceworker%c\n`, `color: #00ffcc; font-weight: bold`, "color: inherit", ...args);
|
|
56
19
|
}
|
|
57
|
-
self.addEventListener("install", () =>
|
|
20
|
+
self.addEventListener("install", (event) => {
|
|
21
|
+
// waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
|
|
22
|
+
// installed SW reliably jumps the "waiting" queue instead of stalling until
|
|
23
|
+
// every old tab closes.
|
|
24
|
+
event.waitUntil(self.skipWaiting());
|
|
25
|
+
});
|
|
58
26
|
async function clearOldCaches() {
|
|
59
27
|
const cacheWhitelist = [cachename];
|
|
60
28
|
const cacheNames = await caches.keys();
|
|
@@ -65,104 +33,17 @@ async function clearOldCaches() {
|
|
|
65
33
|
});
|
|
66
34
|
await Promise.all(deletePromises);
|
|
67
35
|
}
|
|
68
|
-
self.addEventListener("activate",
|
|
69
|
-
|
|
70
|
-
|
|
36
|
+
self.addEventListener("activate", (event) => {
|
|
37
|
+
// Without waitUntil the activate event settles immediately and clients.claim()
|
|
38
|
+
// runs detached — the new worker can be killed before it takes control, so
|
|
39
|
+
// existing tabs keep talking to the old SW. Extend the event instead.
|
|
40
|
+
event.waitUntil((async () => {
|
|
41
|
+
await clearOldCaches();
|
|
42
|
+
await self.clients.claim();
|
|
43
|
+
})());
|
|
71
44
|
});
|
|
72
|
-
let repoPromise = null;
|
|
73
|
-
function getRepo() {
|
|
74
|
-
if (!repoPromise) {
|
|
75
|
-
const p = (async () => {
|
|
76
|
-
const logger = await slog;
|
|
77
|
-
logger.info("getRepo: starting");
|
|
78
|
-
logger.info("fetching wasm modules");
|
|
79
|
-
const [amWasmBuf, sdnWasmBuf] = await Promise.all([
|
|
80
|
-
fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
|
|
81
|
-
fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
|
|
82
|
-
]);
|
|
83
|
-
initSubductionSync(new Uint8Array(sdnWasmBuf));
|
|
84
|
-
await initializeWasm(new Uint8Array(amWasmBuf));
|
|
85
|
-
logger.info("wasm initialized");
|
|
86
|
-
const signer = await WebCryptoSigner.setup();
|
|
87
|
-
const repo = new Repo({
|
|
88
|
-
storage: new IndexedDBStorageAdapter(),
|
|
89
|
-
signer,
|
|
90
|
-
peerId: ("service-worker-" +
|
|
91
|
-
(Math.random() * 10000).toString(36).slice(2)),
|
|
92
|
-
async sharePolicy(peerId) {
|
|
93
|
-
return peerId.includes("storage-server");
|
|
94
|
-
},
|
|
95
|
-
enableRemoteHeadsGossiping: true,
|
|
96
|
-
subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
|
|
97
|
-
network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
|
|
98
|
-
});
|
|
99
|
-
self.repo = repo;
|
|
100
|
-
logger.info("repo constructed, waiting for network subsystem");
|
|
101
|
-
// Don't block getRepo() on whenReady() — the network subsystem starts
|
|
102
|
-
// with only the subduction adapter, and the MessageChannel adapter is
|
|
103
|
-
// added later via connectPort (which awaits getRepo). Blocking here
|
|
104
|
-
// would deadlock that path and starve the fetch handler.
|
|
105
|
-
repo.networkSubsystem.whenReady().then(() => {
|
|
106
|
-
logger.info("repo network subsystem ready");
|
|
107
|
-
});
|
|
108
|
-
return repo;
|
|
109
|
-
})();
|
|
110
|
-
// If construction fails (e.g. wasm fetch errors out because the SW was
|
|
111
|
-
// terminated mid-flight), don't permanently cache the rejection — clear
|
|
112
|
-
// the slot so the next caller can retry from scratch.
|
|
113
|
-
p.catch(() => {
|
|
114
|
-
if (repoPromise === p)
|
|
115
|
-
repoPromise = null;
|
|
116
|
-
});
|
|
117
|
-
repoPromise = p;
|
|
118
|
-
}
|
|
119
|
-
return repoPromise;
|
|
120
|
-
}
|
|
121
|
-
// Connect client MessagePorts to the repo for sync
|
|
122
|
-
async function connectPort(port) {
|
|
123
|
-
const repo = await getRepo();
|
|
124
|
-
repo.networkSubsystem.addNetworkAdapter(new MessageChannelNetworkAdapter(port, { useWeakRef: true }));
|
|
125
|
-
}
|
|
126
45
|
self.addEventListener("message", async (event) => {
|
|
127
|
-
if (event.data.type == "
|
|
128
|
-
// Keepalive — Chromium idles out service workers after ~30s of inactivity.
|
|
129
|
-
// Reply via the provided port if any; the message event itself also resets
|
|
130
|
-
// the idle timer.
|
|
131
|
-
const [pongPort] = event.ports;
|
|
132
|
-
log("ping");
|
|
133
|
-
if (pongPort) {
|
|
134
|
-
pongPort.postMessage({ type: "pong", workerInstanceId });
|
|
135
|
-
log("pong");
|
|
136
|
-
pongPort.close();
|
|
137
|
-
}
|
|
138
|
-
else if (event.source) {
|
|
139
|
-
event.source.postMessage({
|
|
140
|
-
type: "pong",
|
|
141
|
-
workerInstanceId,
|
|
142
|
-
});
|
|
143
|
-
log("pong");
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
else if (event.data.type == "port") {
|
|
147
|
-
log("received messagechannel");
|
|
148
|
-
const [port] = event.ports;
|
|
149
|
-
const source = event.source;
|
|
150
|
-
const id = event.data.id;
|
|
151
|
-
// event.waitUntil keeps the SW alive until the work completes. Without
|
|
152
|
-
// it, the browser can terminate the SW the moment this synchronous block
|
|
153
|
-
// returns, killing the in-flight wasm fetch.
|
|
154
|
-
event.waitUntil(connectPort(port).then(() => source?.postMessage({ type: "port-ready", id, workerInstanceId }), (err) => {
|
|
155
|
-
console.error("connectPort failed", err);
|
|
156
|
-
// Tell the client we failed so it doesn't hang forever.
|
|
157
|
-
source?.postMessage({
|
|
158
|
-
type: "port-failed",
|
|
159
|
-
id,
|
|
160
|
-
error: String(err),
|
|
161
|
-
workerInstanceId,
|
|
162
|
-
});
|
|
163
|
-
}));
|
|
164
|
-
}
|
|
165
|
-
else if (event.data.type == "cachename") {
|
|
46
|
+
if (event.data.type == "cachename") {
|
|
166
47
|
const nextCachename = event.data.cachename;
|
|
167
48
|
if (cachename == nextCachename) {
|
|
168
49
|
return;
|
|
@@ -176,49 +57,62 @@ self.addEventListener("message", async (event) => {
|
|
|
176
57
|
log("serviceworker debugging enabled");
|
|
177
58
|
}
|
|
178
59
|
});
|
|
179
|
-
// ──
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
|
|
60
|
+
// ── Handoff to the automerge worker ────────────────────────────────────
|
|
61
|
+
const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
|
|
62
|
+
const pendingHandoffs = new Map();
|
|
63
|
+
handoffChannel.addEventListener("message", (event) => {
|
|
64
|
+
const data = event.data;
|
|
65
|
+
if (data?.type === "cached" || data?.type === "response") {
|
|
66
|
+
const pending = pendingHandoffs.get(data.id);
|
|
67
|
+
if (!pending) {
|
|
68
|
+
return log(`no pending handoff for id ${data.id}`);
|
|
69
|
+
}
|
|
70
|
+
pending.resolvers.resolve(data);
|
|
186
71
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const latestHeads = folder.heads();
|
|
195
|
-
const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
|
|
196
|
-
let location = `/${encodeURIComponent(url)}`;
|
|
197
|
-
if (path.length)
|
|
198
|
-
location += `/${path.join("/")}`;
|
|
199
|
-
return Response.redirect(location, 307);
|
|
72
|
+
else if (data?.type === "online") {
|
|
73
|
+
// The automerge worker (re)started — re-broadcast anything still in
|
|
74
|
+
// flight so requests that raced its boot aren't stranded.
|
|
75
|
+
for (const { message } of pendingHandoffs.values()) {
|
|
76
|
+
log(`re-broadcasting handoff ${message.id} to the fresh worker`);
|
|
77
|
+
handoffChannel.postMessage(message);
|
|
78
|
+
}
|
|
200
79
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
80
|
+
});
|
|
81
|
+
function handoff(request, handoffURL) {
|
|
82
|
+
const id = crypto.randomUUID();
|
|
83
|
+
const resolvers = Promise.withResolvers();
|
|
84
|
+
const message = {
|
|
85
|
+
id,
|
|
86
|
+
type: "request",
|
|
87
|
+
cachename,
|
|
88
|
+
request: {
|
|
89
|
+
url: request.url,
|
|
90
|
+
handoffURL: handoffURL.href,
|
|
91
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
92
|
+
method: request.method,
|
|
93
|
+
destination: request.destination,
|
|
94
|
+
referrer: request.referrer,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
pendingHandoffs.set(id, { message, resolvers });
|
|
98
|
+
log(`broadcasting handoff request for cache ${cachename}`, message);
|
|
99
|
+
handoffChannel.postMessage(message);
|
|
100
|
+
const timeout = setTimeout(() => {
|
|
101
|
+
resolvers.reject(new Error(`no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`));
|
|
102
|
+
}, HANDOFF_TIMEOUT_MS);
|
|
103
|
+
return resolvers.promise.finally(() => {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
pendingHandoffs.delete(id);
|
|
206
106
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const rootHandle = baseHandle.view(heads);
|
|
211
|
-
const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
|
|
212
|
-
if (!resolved) {
|
|
213
|
-
throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
|
|
214
|
-
}
|
|
215
|
-
const body = resolved.content instanceof Uint8Array
|
|
216
|
-
? new Uint8Array(resolved.content)
|
|
217
|
-
: resolved.content;
|
|
218
|
-
const headers = new Headers({ "content-type": resolved.type });
|
|
107
|
+
}
|
|
108
|
+
function withSpecialHeaders(response) {
|
|
109
|
+
const headers = new Headers(response.headers);
|
|
219
110
|
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
220
111
|
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
221
|
-
return new Response(body
|
|
112
|
+
return new Response(response.body ?? null, {
|
|
113
|
+
status: response.status ?? 200,
|
|
114
|
+
headers,
|
|
115
|
+
});
|
|
222
116
|
}
|
|
223
117
|
// ── Fetch handler ──────────────────────────────────────────────────────
|
|
224
118
|
self.addEventListener("fetch", (fetchEvent) => {
|
|
@@ -227,13 +121,13 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
227
121
|
if (request.method !== "GET")
|
|
228
122
|
return fetchEvent.respondWith(fetch(request));
|
|
229
123
|
const url = new URL(fetchEvent.request.url);
|
|
230
|
-
let
|
|
124
|
+
let handoffURL;
|
|
231
125
|
if (url.hostname == self.location.hostname &&
|
|
232
126
|
url.port == self.location.port &&
|
|
233
127
|
url.protocol == self.location.protocol) {
|
|
234
128
|
try {
|
|
235
|
-
|
|
236
|
-
log(`received special request ${
|
|
129
|
+
handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
|
|
130
|
+
log(`received special request ${handoffURL}`);
|
|
237
131
|
}
|
|
238
132
|
catch { }
|
|
239
133
|
}
|
|
@@ -241,29 +135,28 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
241
135
|
const cache = await caches.open(cachename);
|
|
242
136
|
const match = await cache.match(request);
|
|
243
137
|
try {
|
|
244
|
-
if (
|
|
138
|
+
if (handoffURL) {
|
|
245
139
|
if (match) {
|
|
246
|
-
log(`serving ${
|
|
247
|
-
|
|
248
|
-
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
249
|
-
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
250
|
-
return new Response(match.body, {
|
|
251
|
-
status: match.status,
|
|
252
|
-
headers,
|
|
253
|
-
});
|
|
140
|
+
log(`serving ${handoffURL} from cache ${cachename}`);
|
|
141
|
+
return withSpecialHeaders(match);
|
|
254
142
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
143
|
+
log(`handing ${handoffURL} off to the automerge worker`);
|
|
144
|
+
const replyPromise = handoff(request, handoffURL);
|
|
145
|
+
fetchEvent.waitUntil(replyPromise.catch(() => { }));
|
|
146
|
+
const reply = await replyPromise;
|
|
147
|
+
if (reply.type === "response") {
|
|
148
|
+
// errors, redirects and other things that shouldn't be cached
|
|
149
|
+
log(`serving handed-off response for ${handoffURL}`, reply);
|
|
150
|
+
return withSpecialHeaders(reply.response);
|
|
261
151
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
152
|
+
// reply.type === "cached": the automerge worker has put the
|
|
153
|
+
// response in our cache
|
|
154
|
+
const cached = await cache.match(request);
|
|
155
|
+
if (!cached) {
|
|
156
|
+
return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 500 });
|
|
265
157
|
}
|
|
266
|
-
|
|
158
|
+
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
159
|
+
return withSpecialHeaders(cached);
|
|
267
160
|
}
|
|
268
161
|
else {
|
|
269
162
|
const response = await fetch(request).catch(() => null);
|
|
@@ -286,7 +179,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
286
179
|
const message = error instanceof Error
|
|
287
180
|
? `${error.message}\n\n${error.stack}`
|
|
288
181
|
: String(error);
|
|
289
|
-
console.error(`service worker error resolving ${request.url}${
|
|
182
|
+
console.error(`service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`, error);
|
|
290
183
|
if (match)
|
|
291
184
|
return match;
|
|
292
185
|
return new Response(message, {
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
|
|
2
2
|
export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
|
|
3
|
+
export declare function connectClassicSync(server?: string): Promise<void>;
|
|
3
4
|
export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
|
package/dist/setup.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js";
|
|
1
2
|
import debug from "debug";
|
|
2
|
-
const
|
|
3
|
+
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
4
|
+
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
3
5
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
4
6
|
let nextRepoChannelId = 0;
|
|
5
|
-
let serviceWorkerInstanceId;
|
|
6
7
|
function bumpServiceWorkerCacheVersion() {
|
|
7
8
|
const version = new Date().valueOf().toString(36);
|
|
8
9
|
localStorage.setItem(key, version);
|
|
@@ -34,17 +35,58 @@ window.bumpServiceWorkerCache = bumpServiceWorkerCache;
|
|
|
34
35
|
function configureServiceWorker(sw) {
|
|
35
36
|
if (!sw)
|
|
36
37
|
return;
|
|
37
|
-
sw.postMessage({ type: "debug", debug:
|
|
38
|
+
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
38
39
|
const cachename = getServiceWorkerCacheVersion();
|
|
39
40
|
if (cachename)
|
|
40
41
|
sw.postMessage({ type: "cachename", cachename });
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// ── The automerge worker ───────────────────────────────────────────────
|
|
44
|
+
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
45
|
+
// instance is shared by every tab and lives exactly as long as any tab
|
|
46
|
+
// does, so there's no keepalive ping and no restart detection: if we're
|
|
47
|
+
// alive, it's alive. Repo sync ports are passed to it over its connect
|
|
48
|
+
// port; it talks to the service worker over a BroadcastChannel.
|
|
49
|
+
let automergeWorkerPath = "/automerge-worker.js";
|
|
50
|
+
let automergeWorker;
|
|
51
|
+
function getAutomergeWorker() {
|
|
52
|
+
if (!automergeWorker) {
|
|
53
|
+
automergeWorker = new SharedWorker(automergeWorkerPath, {
|
|
54
|
+
name: "patchwork-automerge",
|
|
55
|
+
type: "module",
|
|
56
|
+
});
|
|
57
|
+
// Control replies (port-ready &c) come back on this port, so it needs
|
|
58
|
+
// start() — we listen with addEventListener, not onmessage.
|
|
59
|
+
automergeWorker.port.start();
|
|
60
|
+
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
61
|
+
}
|
|
62
|
+
return automergeWorker;
|
|
63
|
+
}
|
|
64
|
+
export function connectClassicSync(server = readClassicSyncServer()) {
|
|
65
|
+
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
66
|
+
if (!/^wss?:\/\//.test(url)) {
|
|
67
|
+
return Promise.reject(new Error(`invalid classic sync server URL: ${server}`));
|
|
68
|
+
}
|
|
69
|
+
const worker = getAutomergeWorker();
|
|
70
|
+
const { port1, port2 } = new MessageChannel();
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const timeout = setTimeout(() => {
|
|
73
|
+
port1.close();
|
|
74
|
+
reject(new Error("connect-classic-sync timeout"));
|
|
75
|
+
}, 30_000);
|
|
76
|
+
port1.onmessage = (event) => {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
port1.close();
|
|
79
|
+
if (event.data?.type === "connect-classic-sync-ready") {
|
|
80
|
+
resolve();
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
87
|
+
port2,
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
48
90
|
}
|
|
49
91
|
/** Wait for a registration to have an active worker */
|
|
50
92
|
function waitForActive(reg) {
|
|
@@ -61,106 +103,63 @@ function waitForActive(reg) {
|
|
|
61
103
|
});
|
|
62
104
|
}
|
|
63
105
|
async function openRepoChannel() {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
106
|
+
const worker = getAutomergeWorker();
|
|
107
|
+
// Send a MessagePort so the worker's repo can sync with this tab, and wait
|
|
108
|
+
// for the worker to confirm its repo is constructed before returning. The
|
|
70
109
|
// MessageChannel adapter's whenReady() force-resolves after 100ms regardless
|
|
71
110
|
// of the other end's state, so it can't be used as a real readiness signal
|
|
72
|
-
// on first
|
|
111
|
+
// on first boot (when the worker still has to fetch wasm and build its repo).
|
|
73
112
|
const id = ++nextRepoChannelId;
|
|
74
|
-
let workerInstanceChanged = false;
|
|
75
113
|
const { port1, port2 } = new MessageChannel();
|
|
76
|
-
const
|
|
114
|
+
const workerReady = new Promise((resolve, reject) => {
|
|
77
115
|
let timeout;
|
|
78
116
|
const cleanup = () => {
|
|
79
117
|
clearTimeout(timeout);
|
|
80
|
-
|
|
118
|
+
worker.port.removeEventListener("message", listener);
|
|
81
119
|
};
|
|
82
120
|
const listener = (event) => {
|
|
83
|
-
if (event.data?.id
|
|
121
|
+
if (event.data?.id !== id)
|
|
84
122
|
return;
|
|
85
123
|
if (event.data?.type === "port-ready") {
|
|
86
|
-
workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
|
|
87
124
|
cleanup();
|
|
88
125
|
resolve();
|
|
89
126
|
}
|
|
90
127
|
else if (event.data?.type === "port-failed") {
|
|
91
|
-
workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
|
|
92
128
|
cleanup();
|
|
93
|
-
reject(new Error(`
|
|
129
|
+
reject(new Error(`automerge worker init failed: ${event.data.error}`));
|
|
94
130
|
}
|
|
95
131
|
};
|
|
96
|
-
|
|
97
|
-
// Failsafe: don't block boot forever if the
|
|
98
|
-
// issue and let the rest of the site come up rather than hanging on a
|
|
132
|
+
worker.port.addEventListener("message", listener);
|
|
133
|
+
// Failsafe: don't block boot forever if the worker never replies. Surface
|
|
134
|
+
// the issue and let the rest of the site come up rather than hanging on a
|
|
99
135
|
// blank page.
|
|
100
136
|
timeout = setTimeout(() => {
|
|
101
137
|
cleanup();
|
|
102
|
-
reject(new Error("
|
|
138
|
+
reject(new Error("automerge worker port-ready timeout"));
|
|
103
139
|
}, 30_000);
|
|
104
140
|
});
|
|
105
|
-
|
|
141
|
+
worker.port.postMessage({ type: "port", id }, [port2]);
|
|
106
142
|
try {
|
|
107
|
-
await
|
|
143
|
+
await workerReady;
|
|
108
144
|
}
|
|
109
145
|
catch (err) {
|
|
110
|
-
console.warn("proceeding without
|
|
146
|
+
console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
|
|
111
147
|
}
|
|
112
|
-
return
|
|
148
|
+
return port1;
|
|
149
|
+
}
|
|
150
|
+
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
151
|
+
function getRepoChannel() {
|
|
152
|
+
const worker = getAutomergeWorker();
|
|
153
|
+
const { port1, port2 } = new MessageChannel();
|
|
154
|
+
worker.port.postMessage({ type: "port", id: ++nextRepoChannelId }, [port2]);
|
|
155
|
+
return port1;
|
|
113
156
|
}
|
|
114
157
|
export default async function setupServiceWorker(options) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
}
|
|
159
|
-
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
console.warn("service worker ping failed:", err instanceof Error ? err.message : err);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
158
|
+
if (options?.workerPath)
|
|
159
|
+
automergeWorkerPath = options.workerPath;
|
|
160
|
+
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
161
|
+
// service worker installs.
|
|
162
|
+
getAutomergeWorker();
|
|
164
163
|
const path = options?.path ?? "/service-worker.js";
|
|
165
164
|
// No controller at this point means the page loaded without a service
|
|
166
165
|
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
@@ -178,36 +177,20 @@ export default async function setupServiceWorker(options) {
|
|
|
178
177
|
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
|
|
179
178
|
});
|
|
180
179
|
}
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
setInterval(() => {
|
|
186
|
-
void pingServiceWorker();
|
|
187
|
-
}, 20_000);
|
|
188
|
-
// Reconnect on future SW updates (added after setup so the initial
|
|
189
|
-
// activation doesn't notify before callers subscribe).
|
|
190
|
-
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
|
191
|
-
void reconnectRepoChannels("took control").catch((err) => {
|
|
192
|
-
console.error("service worker reconnect failed", err);
|
|
193
|
-
});
|
|
180
|
+
// A replacement service worker boots with the default cache name — re-send
|
|
181
|
+
// its configuration whenever a new one takes control.
|
|
182
|
+
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
183
|
+
configureServiceWorker(navigator.serviceWorker.controller);
|
|
194
184
|
});
|
|
195
185
|
console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
|
|
196
186
|
return {
|
|
187
|
+
connectClassicSync,
|
|
188
|
+
getRepoChannel,
|
|
197
189
|
async subscribeToRepoChannel(listener) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
|
190
|
+
// The automerge worker outlives the page, so unlike the old in-service-
|
|
191
|
+
// worker repo there's nothing to reconnect: one port, handed over once.
|
|
192
|
+
await listener(await openRepoChannel());
|
|
193
|
+
return () => { };
|
|
211
194
|
},
|
|
212
195
|
};
|
|
213
196
|
}
|