@inkandswitch/patchwork-bootloader 0.3.1 → 0.4.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 +16 -0
- package/dist/automerge-worker.js +434 -15
- package/dist/externals.js +5 -0
- package/dist/service-worker.js +76 -29
- package/dist/setup.d.ts +4 -1
- package/dist/setup.js +109 -11
- package/dist/site.d.ts +6 -1
- package/dist/site.js +86 -40
- package/dist/types.d.ts +64 -0
- package/package.json +13 -13
- package/src/automerge-worker.ts +480 -15
- package/src/externals.ts +5 -0
- package/src/service-worker.ts +92 -32
- package/src/setup.ts +123 -10
- package/src/site.ts +104 -44
- package/src/types.ts +88 -0
package/src/service-worker.ts
CHANGED
|
@@ -13,10 +13,13 @@ import {
|
|
|
13
13
|
type HandoffRequestMessage,
|
|
14
14
|
} from "./types.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
17
|
+
|
|
18
|
+
let cachename = DEFAULT_CACHE_NAME;
|
|
17
19
|
let debugging = false;
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// 0 is an opaque response, that also needs cached
|
|
22
|
+
const cacheableStatuses = [0, 200, 203, 204];
|
|
20
23
|
|
|
21
24
|
// The automerge worker times its own resolution out after 30s and replies
|
|
22
25
|
// with an error, so this only fires when nobody is listening at all.
|
|
@@ -82,22 +85,19 @@ self.addEventListener("install", (event) => {
|
|
|
82
85
|
(event as ExtendableEvent).waitUntil(self.skipWaiting());
|
|
83
86
|
});
|
|
84
87
|
|
|
85
|
-
async function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
await Promise.all(deletePromises);
|
|
88
|
+
async function clearOtherCaches() {
|
|
89
|
+
await Promise.all(
|
|
90
|
+
(await caches.keys()).map((cacheName) => {
|
|
91
|
+
if (cacheName !== cachename) return caches.delete(cacheName);
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
self.addEventListener("activate", (event) => {
|
|
97
97
|
lifecycle("info", "activate (claiming clients)");
|
|
98
98
|
(event as ExtendableEvent).waitUntil(
|
|
99
99
|
(async () => {
|
|
100
|
-
await
|
|
100
|
+
await clearOtherCaches();
|
|
101
101
|
await self.clients.claim();
|
|
102
102
|
// Pre-cache pages of already-open clients so they survive going offline
|
|
103
103
|
// before the next navigation.
|
|
@@ -110,7 +110,7 @@ self.addEventListener("activate", (event) => {
|
|
|
110
110
|
if (!existing) {
|
|
111
111
|
const response = await fetch(client.url);
|
|
112
112
|
if (cacheableStatuses.includes(response.status)) {
|
|
113
|
-
await cache
|
|
113
|
+
await cachePage(cache, client.url, response);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
} catch {
|
|
@@ -128,11 +128,19 @@ self.addEventListener("message", async (event) => {
|
|
|
128
128
|
if (cachename == nextCachename) {
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
|
-
console.info(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
console.info(`moving from cache ${cachename} to ${nextCachename}`);
|
|
132
|
+
if (cachename === DEFAULT_CACHE_NAME) {
|
|
133
|
+
const defaultCache = await caches.open(cachename);
|
|
134
|
+
const nextCache = await caches.open(nextCachename);
|
|
135
|
+
await Promise.all(
|
|
136
|
+
(await defaultCache.keys()).map(async (request) => {
|
|
137
|
+
const response = await defaultCache.match(request);
|
|
138
|
+
if (response) await nextCache.put(request, response);
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
135
142
|
cachename = nextCachename;
|
|
143
|
+
await clearOtherCaches();
|
|
136
144
|
} else if (event.data.type == "debug") {
|
|
137
145
|
debugging = event.data.debug;
|
|
138
146
|
log("serviceworker debugging enabled");
|
|
@@ -216,20 +224,47 @@ function handoff(
|
|
|
216
224
|
});
|
|
217
225
|
}
|
|
218
226
|
|
|
219
|
-
function
|
|
227
|
+
function makeResponse(response: {
|
|
220
228
|
body?: BodyInit | ReadableStream<Uint8Array> | null;
|
|
221
229
|
status?: number;
|
|
222
230
|
headers?: HeadersInit;
|
|
223
231
|
}): Response {
|
|
224
|
-
const headers = new Headers(response.headers);
|
|
225
|
-
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
226
|
-
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
227
232
|
return new Response(response.body ?? null, {
|
|
228
233
|
status: response.status ?? 200,
|
|
229
|
-
headers,
|
|
234
|
+
headers: response.headers,
|
|
230
235
|
});
|
|
231
236
|
}
|
|
232
237
|
|
|
238
|
+
function indexRequestFor(request: Request | string): Request | undefined {
|
|
239
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
240
|
+
if (url.origin !== self.location.origin) return undefined;
|
|
241
|
+
url.pathname = "/index.html";
|
|
242
|
+
url.search = "";
|
|
243
|
+
url.hash = "";
|
|
244
|
+
return new Request(url.href);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function rootRequestFor(request: Request | string): Request | undefined {
|
|
248
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
249
|
+
if (url.origin !== self.location.origin) return undefined;
|
|
250
|
+
url.pathname = "/";
|
|
251
|
+
url.search = "";
|
|
252
|
+
url.hash = "";
|
|
253
|
+
return new Request(url.href);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function cachePage(
|
|
257
|
+
cache: Cache,
|
|
258
|
+
request: Request | string,
|
|
259
|
+
response: Response
|
|
260
|
+
) {
|
|
261
|
+
const indexRequest = indexRequestFor(request);
|
|
262
|
+
if (indexRequest) await cache.put(indexRequest, response.clone());
|
|
263
|
+
const rootRequest = rootRequestFor(request);
|
|
264
|
+
if (rootRequest) await cache.put(rootRequest, response.clone());
|
|
265
|
+
await cache.put(request, response);
|
|
266
|
+
}
|
|
267
|
+
|
|
233
268
|
// ── Fetch handler ──────────────────────────────────────────────────────
|
|
234
269
|
|
|
235
270
|
self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
@@ -260,7 +295,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
260
295
|
if (handoffURL) {
|
|
261
296
|
if (match) {
|
|
262
297
|
log(`serving ${handoffURL} from cache ${cachename}`);
|
|
263
|
-
return
|
|
298
|
+
return match;
|
|
264
299
|
}
|
|
265
300
|
|
|
266
301
|
log(`handing ${handoffURL} off to the automerge worker`);
|
|
@@ -271,7 +306,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
271
306
|
if (reply.type === "response") {
|
|
272
307
|
// errors, redirects and other things that shouldn't be cached
|
|
273
308
|
log(`serving handed-off response for ${handoffURL}`, reply);
|
|
274
|
-
return
|
|
309
|
+
return makeResponse(reply.response);
|
|
275
310
|
}
|
|
276
311
|
|
|
277
312
|
// reply.type === "cached": the automerge worker has put the
|
|
@@ -284,26 +319,51 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
284
319
|
);
|
|
285
320
|
}
|
|
286
321
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
287
|
-
return
|
|
322
|
+
return cached;
|
|
288
323
|
} else {
|
|
289
|
-
|
|
290
|
-
|
|
324
|
+
// fetch() rejects on network error / abort rather than resolving;
|
|
325
|
+
// keep the error so we can surface it in the 503 body below.
|
|
326
|
+
const result = await fetch(request).catch((error: unknown) =>
|
|
327
|
+
error instanceof Error ? error : new Error(String(error))
|
|
328
|
+
);
|
|
329
|
+
if (result instanceof Response) {
|
|
330
|
+
const response = result;
|
|
331
|
+
// Tool subresources (<link>/<script>) are requested from srcdoc
|
|
332
|
+
// frames whose origin is "null", so they come back as opaque
|
|
333
|
+
// cross-origin `no-cors` responses: status 0 and an empty url. They
|
|
334
|
+
// render fine while online but were being excluded from the cache,
|
|
335
|
+
// so e.g. a theme stylesheet vanished on an offline refresh. Opaque
|
|
336
|
+
// responses are cacheable and replay to the same no-cors consumer,
|
|
337
|
+
// so treat status 0 as cacheable and gate the scheme on request.url
|
|
338
|
+
// (an opaque response's own url is "").
|
|
291
339
|
if (
|
|
292
|
-
|
|
293
|
-
|
|
340
|
+
(response.status === 0 ||
|
|
341
|
+
cacheableStatuses.includes(response.status)) &&
|
|
342
|
+
/^https?:/.test(request.url)
|
|
294
343
|
) {
|
|
295
|
-
|
|
344
|
+
const cachedResponse = response.clone();
|
|
345
|
+
await (
|
|
346
|
+
request.mode === "navigate" ||
|
|
347
|
+
request.destination === "document"
|
|
348
|
+
? cachePage(cache, request, cachedResponse)
|
|
349
|
+
: cache.put(request, cachedResponse)
|
|
350
|
+
).catch((error) => {
|
|
296
351
|
log(`error caching ${request.url} in ${cachename}`, error);
|
|
297
352
|
});
|
|
298
353
|
} else {
|
|
299
354
|
log(
|
|
300
|
-
`skipping uncacheable response code from cache: ${response.status} for ${
|
|
355
|
+
`skipping uncacheable response code from cache: ${response.status} for ${request.url}`
|
|
301
356
|
);
|
|
302
357
|
}
|
|
303
358
|
return response;
|
|
304
359
|
}
|
|
305
360
|
if (match) return match;
|
|
306
|
-
return new Response(
|
|
361
|
+
return new Response(
|
|
362
|
+
`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${
|
|
363
|
+
result.stack ?? result.message
|
|
364
|
+
}`,
|
|
365
|
+
{ status: 503, headers: { "content-type": "text/plain" } }
|
|
366
|
+
);
|
|
307
367
|
}
|
|
308
368
|
} catch (error) {
|
|
309
369
|
const message =
|
package/src/setup.ts
CHANGED
|
@@ -2,12 +2,17 @@ import type {
|
|
|
2
2
|
ServiceWorkerRepoChannelListener,
|
|
3
3
|
SetupServiceWorkerOptions,
|
|
4
4
|
SetupServiceWorkerResult,
|
|
5
|
+
SyncStateDocMessage,
|
|
5
6
|
} from "./types.js";
|
|
6
7
|
import {
|
|
7
8
|
readClassicSyncServer,
|
|
8
9
|
DEFAULT_CLASSIC_SYNC_SERVER,
|
|
9
10
|
} from "./sync-config.js";
|
|
10
11
|
import debug from "debug";
|
|
12
|
+
import {
|
|
13
|
+
donatePort,
|
|
14
|
+
isWorkerErrorMessage,
|
|
15
|
+
} from "@automerge/automerge-repo/worker-port";
|
|
11
16
|
|
|
12
17
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
13
18
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
@@ -41,6 +46,7 @@ function installServiceWorkerLogForwarding(): void {
|
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
49
|
+
const defaultServiceWorkerCacheName = "patchwork";
|
|
44
50
|
let nextRepoChannelId = 0;
|
|
45
51
|
|
|
46
52
|
function bumpServiceWorkerCacheVersion() {
|
|
@@ -53,19 +59,13 @@ function getServiceWorkerCacheVersion() {
|
|
|
53
59
|
return localStorage.getItem(key);
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function getOrCreateServiceWorkerCacheVersion() {
|
|
57
|
-
const existing = getServiceWorkerCacheVersion();
|
|
58
|
-
if (existing) return existing;
|
|
59
|
-
return bumpServiceWorkerCacheVersion();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
62
|
function setServiceWorkerCacheName(sw: ServiceWorker | null) {
|
|
63
63
|
if (!sw) {
|
|
64
64
|
throw new Error("no service worker!");
|
|
65
65
|
}
|
|
66
66
|
sw.postMessage({
|
|
67
67
|
type: "cachename",
|
|
68
|
-
cachename:
|
|
68
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -81,8 +81,10 @@ export function bumpServiceWorkerCache(
|
|
|
81
81
|
function configureServiceWorker(sw: ServiceWorker | null) {
|
|
82
82
|
if (!sw) return;
|
|
83
83
|
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
sw.postMessage({
|
|
85
|
+
type: "cachename",
|
|
86
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
87
|
+
});
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
@@ -95,9 +97,42 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
95
97
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
96
98
|
let automergeWorker: SharedWorker | undefined;
|
|
97
99
|
|
|
100
|
+
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
101
|
+
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
102
|
+
// port to the automerge worker (which requests one via its port provider).
|
|
103
|
+
// Being a SharedWorker itself, the proxy — and the donated worker↔worker
|
|
104
|
+
// port — outlives the donor tab. Emitted at /packages/... via externals.ts.
|
|
105
|
+
const SUBDUCTION_IO_WORKER_URL =
|
|
106
|
+
"/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
107
|
+
|
|
108
|
+
// Bench toggles for the subduction socket (see getSubductionEndpoints in
|
|
109
|
+
// automerge-worker.ts), passed as query params because SharedWorker scope
|
|
110
|
+
// has no localStorage — which also gives each configuration its own worker
|
|
111
|
+
// instance, so bench arms can't share state.
|
|
112
|
+
// localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
|
|
113
|
+
// localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
|
|
114
|
+
// windowFrames override
|
|
115
|
+
function workerBenchParams(): string {
|
|
116
|
+
const params = new URLSearchParams();
|
|
117
|
+
try {
|
|
118
|
+
for (const [key, param] of [
|
|
119
|
+
["patchwork:ws-mode", "ws-mode"],
|
|
120
|
+
["patchwork:ws-window", "ws-window"],
|
|
121
|
+
] as const) {
|
|
122
|
+
const value = globalThis.localStorage?.getItem(key);
|
|
123
|
+
if (value) params.set(param, value);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
127
|
+
}
|
|
128
|
+
const qs = params.toString();
|
|
129
|
+
return qs ? `?${qs}` : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
export function getAutomergeWorker(): SharedWorker {
|
|
99
133
|
if (!automergeWorker) {
|
|
100
|
-
|
|
134
|
+
const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
|
|
135
|
+
automergeWorker = new SharedWorker(workerUrl, {
|
|
101
136
|
name: "patchwork-automerge",
|
|
102
137
|
type: "module",
|
|
103
138
|
});
|
|
@@ -107,6 +142,25 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
107
142
|
// Surface the SharedWorker's console output and uncaught errors in this
|
|
108
143
|
// tab's console (it has its own console that's awkward to find otherwise).
|
|
109
144
|
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
145
|
+
if (event.data?.type === "sync-state") {
|
|
146
|
+
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (isWorkerErrorMessage(event.data)) {
|
|
150
|
+
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
151
|
+
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
152
|
+
// loudly — these otherwise only exist in chrome://inspect.
|
|
153
|
+
console.error("[subduction-io]", event.data);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.data?.type === "drift-samples") {
|
|
157
|
+
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
158
|
+
// bounded window global for the Playwright bench to harvest.
|
|
159
|
+
const sink = ((window as any).__driftSamples ??= []) as number[];
|
|
160
|
+
sink.push(...event.data.samples);
|
|
161
|
+
if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
110
164
|
if (event.data?.type !== "console") return;
|
|
111
165
|
const { level, args } = event.data;
|
|
112
166
|
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
@@ -130,6 +184,16 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
130
184
|
});
|
|
131
185
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
132
186
|
|
|
187
|
+
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
188
|
+
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
189
|
+
donatePort(automergeWorker.port, () => {
|
|
190
|
+
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
191
|
+
type: "module",
|
|
192
|
+
name: "subduction-websocket",
|
|
193
|
+
});
|
|
194
|
+
return io.port;
|
|
195
|
+
});
|
|
196
|
+
|
|
133
197
|
installWorkerDeathDetection(automergeWorker);
|
|
134
198
|
}
|
|
135
199
|
return automergeWorker;
|
|
@@ -205,6 +269,53 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
|
205
269
|
}, HEARTBEAT_MS);
|
|
206
270
|
}
|
|
207
271
|
|
|
272
|
+
// ── Sync-state subscriptions ────────────────────────────────────────────
|
|
273
|
+
// The automerge worker pushes per-document heads only to the tabs that ask for
|
|
274
|
+
// them (see SyncStateDocMessage). We ref-count locally so several callers in
|
|
275
|
+
// this tab can watch the same doc with a single worker subscription, and tear
|
|
276
|
+
// the worker subscription down when the last local watcher drops.
|
|
277
|
+
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
278
|
+
const syncStateListeners = new Map<string, Set<SyncStateListener>>();
|
|
279
|
+
|
|
280
|
+
function dispatchSyncState(update: SyncStateDocMessage): void {
|
|
281
|
+
const listeners = syncStateListeners.get(update.documentId);
|
|
282
|
+
if (!listeners) return;
|
|
283
|
+
for (const listener of listeners) {
|
|
284
|
+
try {
|
|
285
|
+
listener(update);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error("sync-state listener threw", err);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function subscribeSyncState(
|
|
293
|
+
documentId: string,
|
|
294
|
+
listener: SyncStateListener
|
|
295
|
+
): () => void {
|
|
296
|
+
const worker = getAutomergeWorker();
|
|
297
|
+
let listeners = syncStateListeners.get(documentId);
|
|
298
|
+
if (!listeners) {
|
|
299
|
+
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
300
|
+
// First local watcher for this doc — ask the worker to start pushing it.
|
|
301
|
+
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
302
|
+
}
|
|
303
|
+
listeners.add(listener);
|
|
304
|
+
|
|
305
|
+
let active = true;
|
|
306
|
+
return () => {
|
|
307
|
+
if (!active) return; // idempotent
|
|
308
|
+
active = false;
|
|
309
|
+
const set = syncStateListeners.get(documentId);
|
|
310
|
+
if (!set) return;
|
|
311
|
+
set.delete(listener);
|
|
312
|
+
if (set.size === 0) {
|
|
313
|
+
syncStateListeners.delete(documentId);
|
|
314
|
+
worker.port.postMessage({ type: "sync-unsub", documentId });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
208
319
|
export function connectClassicSync(
|
|
209
320
|
server: string = readClassicSyncServer()
|
|
210
321
|
): Promise<void> {
|
|
@@ -311,6 +422,7 @@ export default async function setupServiceWorker(
|
|
|
311
422
|
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
312
423
|
// install / activate markers from the controlling worker are rendered here.
|
|
313
424
|
installServiceWorkerLogForwarding();
|
|
425
|
+
localStorage.removeItem(key);
|
|
314
426
|
|
|
315
427
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
316
428
|
|
|
@@ -367,6 +479,7 @@ export default async function setupServiceWorker(
|
|
|
367
479
|
shared,
|
|
368
480
|
connectClassicSync,
|
|
369
481
|
getRepoChannel,
|
|
482
|
+
subscribeSyncState,
|
|
370
483
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
371
484
|
// The automerge worker outlives the page, so unlike the old in-service-
|
|
372
485
|
// worker repo there's nothing to reconnect: one port, handed over once.
|