@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/dist/service-worker.js
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// "cached" (or "response" for errors and other things that shouldn't be
|
|
7
7
|
// cached).
|
|
8
8
|
import { HANDOFF_CHANNEL, } from "./types.js";
|
|
9
|
-
|
|
9
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
10
|
+
let cachename = DEFAULT_CACHE_NAME;
|
|
10
11
|
let debugging = false;
|
|
11
|
-
|
|
12
|
+
// 0 is an opaque response, that also needs cached
|
|
13
|
+
const cacheableStatuses = [0, 200, 203, 204];
|
|
12
14
|
// The automerge worker times its own resolution out after 30s and replies
|
|
13
15
|
// with an error, so this only fires when nobody is listening at all.
|
|
14
16
|
const HANDOFF_TIMEOUT_MS = 35_000;
|
|
@@ -52,20 +54,16 @@ self.addEventListener("install", (event) => {
|
|
|
52
54
|
// every old tab closes.
|
|
53
55
|
event.waitUntil(self.skipWaiting());
|
|
54
56
|
});
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const deletePromises = cacheNames.map((cacheName) => {
|
|
59
|
-
if (!cacheWhitelist.includes(cacheName)) {
|
|
57
|
+
async function clearOtherCaches() {
|
|
58
|
+
await Promise.all((await caches.keys()).map((cacheName) => {
|
|
59
|
+
if (cacheName !== cachename)
|
|
60
60
|
return caches.delete(cacheName);
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
await Promise.all(deletePromises);
|
|
61
|
+
}));
|
|
64
62
|
}
|
|
65
63
|
self.addEventListener("activate", (event) => {
|
|
66
64
|
lifecycle("info", "activate (claiming clients)");
|
|
67
65
|
event.waitUntil((async () => {
|
|
68
|
-
await
|
|
66
|
+
await clearOtherCaches();
|
|
69
67
|
await self.clients.claim();
|
|
70
68
|
// Pre-cache pages of already-open clients so they survive going offline
|
|
71
69
|
// before the next navigation.
|
|
@@ -77,7 +75,7 @@ self.addEventListener("activate", (event) => {
|
|
|
77
75
|
if (!existing) {
|
|
78
76
|
const response = await fetch(client.url);
|
|
79
77
|
if (cacheableStatuses.includes(response.status)) {
|
|
80
|
-
await cache
|
|
78
|
+
await cachePage(cache, client.url, response);
|
|
81
79
|
}
|
|
82
80
|
}
|
|
83
81
|
}
|
|
@@ -93,9 +91,18 @@ self.addEventListener("message", async (event) => {
|
|
|
93
91
|
if (cachename == nextCachename) {
|
|
94
92
|
return;
|
|
95
93
|
}
|
|
96
|
-
console.info(`
|
|
97
|
-
|
|
94
|
+
console.info(`moving from cache ${cachename} to ${nextCachename}`);
|
|
95
|
+
if (cachename === DEFAULT_CACHE_NAME) {
|
|
96
|
+
const defaultCache = await caches.open(cachename);
|
|
97
|
+
const nextCache = await caches.open(nextCachename);
|
|
98
|
+
await Promise.all((await defaultCache.keys()).map(async (request) => {
|
|
99
|
+
const response = await defaultCache.match(request);
|
|
100
|
+
if (response)
|
|
101
|
+
await nextCache.put(request, response);
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
98
104
|
cachename = nextCachename;
|
|
105
|
+
await clearOtherCaches();
|
|
99
106
|
}
|
|
100
107
|
else if (event.data.type == "debug") {
|
|
101
108
|
debugging = event.data.debug;
|
|
@@ -157,15 +164,39 @@ function handoff(request, handoffURL) {
|
|
|
157
164
|
pendingHandoffs.delete(id);
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
|
-
function
|
|
161
|
-
const headers = new Headers(response.headers);
|
|
162
|
-
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
163
|
-
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
167
|
+
function makeResponse(response) {
|
|
164
168
|
return new Response(response.body ?? null, {
|
|
165
169
|
status: response.status ?? 200,
|
|
166
|
-
headers,
|
|
170
|
+
headers: response.headers,
|
|
167
171
|
});
|
|
168
172
|
}
|
|
173
|
+
function indexRequestFor(request) {
|
|
174
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
175
|
+
if (url.origin !== self.location.origin)
|
|
176
|
+
return undefined;
|
|
177
|
+
url.pathname = "/index.html";
|
|
178
|
+
url.search = "";
|
|
179
|
+
url.hash = "";
|
|
180
|
+
return new Request(url.href);
|
|
181
|
+
}
|
|
182
|
+
function rootRequestFor(request) {
|
|
183
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
184
|
+
if (url.origin !== self.location.origin)
|
|
185
|
+
return undefined;
|
|
186
|
+
url.pathname = "/";
|
|
187
|
+
url.search = "";
|
|
188
|
+
url.hash = "";
|
|
189
|
+
return new Request(url.href);
|
|
190
|
+
}
|
|
191
|
+
async function cachePage(cache, request, response) {
|
|
192
|
+
const indexRequest = indexRequestFor(request);
|
|
193
|
+
if (indexRequest)
|
|
194
|
+
await cache.put(indexRequest, response.clone());
|
|
195
|
+
const rootRequest = rootRequestFor(request);
|
|
196
|
+
if (rootRequest)
|
|
197
|
+
await cache.put(rootRequest, response.clone());
|
|
198
|
+
await cache.put(request, response);
|
|
199
|
+
}
|
|
169
200
|
// ── Fetch handler ──────────────────────────────────────────────────────
|
|
170
201
|
self.addEventListener("fetch", (fetchEvent) => {
|
|
171
202
|
log("fetch event", fetchEvent.request.url);
|
|
@@ -190,7 +221,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
190
221
|
if (handoffURL) {
|
|
191
222
|
if (match) {
|
|
192
223
|
log(`serving ${handoffURL} from cache ${cachename}`);
|
|
193
|
-
return
|
|
224
|
+
return match;
|
|
194
225
|
}
|
|
195
226
|
log(`handing ${handoffURL} off to the automerge worker`);
|
|
196
227
|
const replyPromise = handoff(request, handoffURL);
|
|
@@ -199,7 +230,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
199
230
|
if (reply.type === "response") {
|
|
200
231
|
// errors, redirects and other things that shouldn't be cached
|
|
201
232
|
log(`serving handed-off response for ${handoffURL}`, reply);
|
|
202
|
-
return
|
|
233
|
+
return makeResponse(reply.response);
|
|
203
234
|
}
|
|
204
235
|
// reply.type === "cached": the automerge worker has put the
|
|
205
236
|
// response in our cache
|
|
@@ -208,25 +239,41 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
208
239
|
return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
|
|
209
240
|
}
|
|
210
241
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
211
|
-
return
|
|
242
|
+
return cached;
|
|
212
243
|
}
|
|
213
244
|
else {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
245
|
+
// fetch() rejects on network error / abort rather than resolving;
|
|
246
|
+
// keep the error so we can surface it in the 503 body below.
|
|
247
|
+
const result = await fetch(request).catch((error) => error instanceof Error ? error : new Error(String(error)));
|
|
248
|
+
if (result instanceof Response) {
|
|
249
|
+
const response = result;
|
|
250
|
+
// Tool subresources (<link>/<script>) are requested from srcdoc
|
|
251
|
+
// frames whose origin is "null", so they come back as opaque
|
|
252
|
+
// cross-origin `no-cors` responses: status 0 and an empty url. They
|
|
253
|
+
// render fine while online but were being excluded from the cache,
|
|
254
|
+
// so e.g. a theme stylesheet vanished on an offline refresh. Opaque
|
|
255
|
+
// responses are cacheable and replay to the same no-cors consumer,
|
|
256
|
+
// so treat status 0 as cacheable and gate the scheme on request.url
|
|
257
|
+
// (an opaque response's own url is "").
|
|
258
|
+
if ((response.status === 0 ||
|
|
259
|
+
cacheableStatuses.includes(response.status)) &&
|
|
260
|
+
/^https?:/.test(request.url)) {
|
|
261
|
+
const cachedResponse = response.clone();
|
|
262
|
+
await (request.mode === "navigate" ||
|
|
263
|
+
request.destination === "document"
|
|
264
|
+
? cachePage(cache, request, cachedResponse)
|
|
265
|
+
: cache.put(request, cachedResponse)).catch((error) => {
|
|
219
266
|
log(`error caching ${request.url} in ${cachename}`, error);
|
|
220
267
|
});
|
|
221
268
|
}
|
|
222
269
|
else {
|
|
223
|
-
log(`skipping uncacheable response code from cache: ${response.status} for ${
|
|
270
|
+
log(`skipping uncacheable response code from cache: ${response.status} for ${request.url}`);
|
|
224
271
|
}
|
|
225
272
|
return response;
|
|
226
273
|
}
|
|
227
274
|
if (match)
|
|
228
275
|
return match;
|
|
229
|
-
return new Response(
|
|
276
|
+
return new Response(`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${result.stack ?? result.message}`, { status: 503, headers: { "content-type": "text/plain" } });
|
|
230
277
|
}
|
|
231
278
|
}
|
|
232
279
|
catch (error) {
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
|
|
1
|
+
import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage } from "./types.js";
|
|
2
2
|
export declare function lifecycleLoggingEnabled(): boolean;
|
|
3
3
|
export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
|
|
4
4
|
export declare function getAutomergeWorker(): SharedWorker;
|
|
5
|
+
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
6
|
+
export declare function subscribeSyncState(documentId: string, listener: SyncStateListener): () => void;
|
|
5
7
|
export declare function connectClassicSync(server?: string): Promise<void>;
|
|
6
8
|
export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
|
|
9
|
+
export {};
|
package/dist/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js";
|
|
2
2
|
import debug from "debug";
|
|
3
|
+
import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port";
|
|
3
4
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
4
5
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
5
6
|
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
@@ -34,6 +35,7 @@ function installServiceWorkerLogForwarding() {
|
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
38
|
+
const defaultServiceWorkerCacheName = "patchwork";
|
|
37
39
|
let nextRepoChannelId = 0;
|
|
38
40
|
function bumpServiceWorkerCacheVersion() {
|
|
39
41
|
const version = new Date().valueOf().toString(36);
|
|
@@ -43,19 +45,13 @@ function bumpServiceWorkerCacheVersion() {
|
|
|
43
45
|
function getServiceWorkerCacheVersion() {
|
|
44
46
|
return localStorage.getItem(key);
|
|
45
47
|
}
|
|
46
|
-
function getOrCreateServiceWorkerCacheVersion() {
|
|
47
|
-
const existing = getServiceWorkerCacheVersion();
|
|
48
|
-
if (existing)
|
|
49
|
-
return existing;
|
|
50
|
-
return bumpServiceWorkerCacheVersion();
|
|
51
|
-
}
|
|
52
48
|
function setServiceWorkerCacheName(sw) {
|
|
53
49
|
if (!sw) {
|
|
54
50
|
throw new Error("no service worker!");
|
|
55
51
|
}
|
|
56
52
|
sw.postMessage({
|
|
57
53
|
type: "cachename",
|
|
58
|
-
cachename:
|
|
54
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
59
55
|
});
|
|
60
56
|
}
|
|
61
57
|
export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
|
|
@@ -67,9 +63,10 @@ function configureServiceWorker(sw) {
|
|
|
67
63
|
if (!sw)
|
|
68
64
|
return;
|
|
69
65
|
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
sw.postMessage({
|
|
67
|
+
type: "cachename",
|
|
68
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
69
|
+
});
|
|
73
70
|
}
|
|
74
71
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
75
72
|
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
@@ -79,9 +76,41 @@ function configureServiceWorker(sw) {
|
|
|
79
76
|
// port; it talks to the service worker over a BroadcastChannel.
|
|
80
77
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
81
78
|
let automergeWorker;
|
|
79
|
+
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
80
|
+
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
81
|
+
// port to the automerge worker (which requests one via its port provider).
|
|
82
|
+
// Being a SharedWorker itself, the proxy — and the donated worker↔worker
|
|
83
|
+
// port — outlives the donor tab. Emitted at /packages/... via externals.ts.
|
|
84
|
+
const SUBDUCTION_IO_WORKER_URL = "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
85
|
+
// Bench toggles for the subduction socket (see getSubductionEndpoints in
|
|
86
|
+
// automerge-worker.ts), passed as query params because SharedWorker scope
|
|
87
|
+
// has no localStorage — which also gives each configuration its own worker
|
|
88
|
+
// instance, so bench arms can't share state.
|
|
89
|
+
// localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
|
|
90
|
+
// localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
|
|
91
|
+
// windowFrames override
|
|
92
|
+
function workerBenchParams() {
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
try {
|
|
95
|
+
for (const [key, param] of [
|
|
96
|
+
["patchwork:ws-mode", "ws-mode"],
|
|
97
|
+
["patchwork:ws-window", "ws-window"],
|
|
98
|
+
]) {
|
|
99
|
+
const value = globalThis.localStorage?.getItem(key);
|
|
100
|
+
if (value)
|
|
101
|
+
params.set(param, value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
106
|
+
}
|
|
107
|
+
const qs = params.toString();
|
|
108
|
+
return qs ? `?${qs}` : "";
|
|
109
|
+
}
|
|
82
110
|
export function getAutomergeWorker() {
|
|
83
111
|
if (!automergeWorker) {
|
|
84
|
-
|
|
112
|
+
const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
|
|
113
|
+
automergeWorker = new SharedWorker(workerUrl, {
|
|
85
114
|
name: "patchwork-automerge",
|
|
86
115
|
type: "module",
|
|
87
116
|
});
|
|
@@ -91,6 +120,26 @@ export function getAutomergeWorker() {
|
|
|
91
120
|
// Surface the SharedWorker's console output and uncaught errors in this
|
|
92
121
|
// tab's console (it has its own console that's awkward to find otherwise).
|
|
93
122
|
automergeWorker.port.addEventListener("message", (event) => {
|
|
123
|
+
if (event.data?.type === "sync-state") {
|
|
124
|
+
dispatchSyncState(event.data);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (isWorkerErrorMessage(event.data)) {
|
|
128
|
+
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
129
|
+
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
130
|
+
// loudly — these otherwise only exist in chrome://inspect.
|
|
131
|
+
console.error("[subduction-io]", event.data);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (event.data?.type === "drift-samples") {
|
|
135
|
+
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
136
|
+
// bounded window global for the Playwright bench to harvest.
|
|
137
|
+
const sink = (window.__driftSamples ??= []);
|
|
138
|
+
sink.push(...event.data.samples);
|
|
139
|
+
if (sink.length > 10_000)
|
|
140
|
+
sink.splice(0, sink.length - 10_000);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
94
143
|
if (event.data?.type !== "console")
|
|
95
144
|
return;
|
|
96
145
|
const { level, args } = event.data;
|
|
@@ -113,6 +162,15 @@ export function getAutomergeWorker() {
|
|
|
113
162
|
}
|
|
114
163
|
});
|
|
115
164
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
165
|
+
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
166
|
+
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
167
|
+
donatePort(automergeWorker.port, () => {
|
|
168
|
+
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
169
|
+
type: "module",
|
|
170
|
+
name: "subduction-websocket",
|
|
171
|
+
});
|
|
172
|
+
return io.port;
|
|
173
|
+
});
|
|
116
174
|
installWorkerDeathDetection(automergeWorker);
|
|
117
175
|
}
|
|
118
176
|
return automergeWorker;
|
|
@@ -181,6 +239,44 @@ function installWorkerDeathDetection(worker) {
|
|
|
181
239
|
}
|
|
182
240
|
}, HEARTBEAT_MS);
|
|
183
241
|
}
|
|
242
|
+
const syncStateListeners = new Map();
|
|
243
|
+
function dispatchSyncState(update) {
|
|
244
|
+
const listeners = syncStateListeners.get(update.documentId);
|
|
245
|
+
if (!listeners)
|
|
246
|
+
return;
|
|
247
|
+
for (const listener of listeners) {
|
|
248
|
+
try {
|
|
249
|
+
listener(update);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
console.error("sync-state listener threw", err);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function subscribeSyncState(documentId, listener) {
|
|
257
|
+
const worker = getAutomergeWorker();
|
|
258
|
+
let listeners = syncStateListeners.get(documentId);
|
|
259
|
+
if (!listeners) {
|
|
260
|
+
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
261
|
+
// First local watcher for this doc — ask the worker to start pushing it.
|
|
262
|
+
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
263
|
+
}
|
|
264
|
+
listeners.add(listener);
|
|
265
|
+
let active = true;
|
|
266
|
+
return () => {
|
|
267
|
+
if (!active)
|
|
268
|
+
return; // idempotent
|
|
269
|
+
active = false;
|
|
270
|
+
const set = syncStateListeners.get(documentId);
|
|
271
|
+
if (!set)
|
|
272
|
+
return;
|
|
273
|
+
set.delete(listener);
|
|
274
|
+
if (set.size === 0) {
|
|
275
|
+
syncStateListeners.delete(documentId);
|
|
276
|
+
worker.port.postMessage({ type: "sync-unsub", documentId });
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
184
280
|
export function connectClassicSync(server = readClassicSyncServer()) {
|
|
185
281
|
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
186
282
|
if (!/^wss?:\/\//.test(url)) {
|
|
@@ -278,6 +374,7 @@ export default async function setupServiceWorker(options) {
|
|
|
278
374
|
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
279
375
|
// install / activate markers from the controlling worker are rendered here.
|
|
280
376
|
installServiceWorkerLogForwarding();
|
|
377
|
+
localStorage.removeItem(key);
|
|
281
378
|
if (options?.workerPath)
|
|
282
379
|
automergeWorkerPath = options.workerPath;
|
|
283
380
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
@@ -318,6 +415,7 @@ export default async function setupServiceWorker(options) {
|
|
|
318
415
|
shared,
|
|
319
416
|
connectClassicSync,
|
|
320
417
|
getRepoChannel,
|
|
418
|
+
subscribeSyncState,
|
|
321
419
|
async subscribeToRepoChannel(listener) {
|
|
322
420
|
// The automerge worker outlives the page, so unlike the old in-service-
|
|
323
421
|
// worker repo there's nothing to reconnect: one port, handed over once.
|
package/dist/site.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
|
16
16
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
17
17
|
import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
|
|
18
18
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
19
|
-
import type { ServiceWorkerRepoChannelListener } from "./types.js";
|
|
19
|
+
import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "./types.js";
|
|
20
20
|
declare global {
|
|
21
21
|
interface Window {
|
|
22
22
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
@@ -30,9 +30,14 @@ declare global {
|
|
|
30
30
|
packages: ModuleWatcher;
|
|
31
31
|
plugins: typeof plugins;
|
|
32
32
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
33
|
+
signer?: {
|
|
34
|
+
peerId: string;
|
|
35
|
+
verifyingKey: string;
|
|
36
|
+
};
|
|
33
37
|
sw: {
|
|
34
38
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
35
39
|
subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
|
|
40
|
+
subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
|
|
36
41
|
};
|
|
37
42
|
};
|
|
38
43
|
uncache: (match: string) => Promise<void>;
|
package/dist/site.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* site's `main.ts`. Non-UI consumers should import the package default (which
|
|
12
12
|
* only does SW registration and the automerge-worker handoff).
|
|
13
13
|
*/
|
|
14
|
-
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter,
|
|
14
|
+
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
|
|
15
15
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
16
16
|
import * as Automerge from "@automerge/automerge/slim";
|
|
17
17
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
@@ -19,6 +19,7 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
|
|
|
19
19
|
// eslint-disable-next-line
|
|
20
20
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
21
21
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
22
|
+
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
22
23
|
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
|
|
23
24
|
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
24
25
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
@@ -30,8 +31,11 @@ import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
|
30
31
|
import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
|
|
31
32
|
import debug from "debug";
|
|
32
33
|
const log = debug("patchwork:bootloader:site");
|
|
33
|
-
// Legacy big-patchwork hash shape:
|
|
34
|
-
|
|
34
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
35
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
36
|
+
// we anchor on the `--` before the base58 document id and allow any non-query
|
|
37
|
+
// characters ahead of it rather than a strict slug charset.
|
|
38
|
+
const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
35
39
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
36
40
|
fetch("/automerge.wasm?main").then((r) => r.bytes()),
|
|
37
41
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
@@ -59,6 +63,7 @@ export async function bootPatchworkSite(config) {
|
|
|
59
63
|
log("workers ready");
|
|
60
64
|
let hive;
|
|
61
65
|
let repo;
|
|
66
|
+
let tabSignerIdentity;
|
|
62
67
|
// If a Repo is already on `window` — an embedding context provided one before
|
|
63
68
|
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
64
69
|
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
@@ -102,15 +107,26 @@ export async function bootPatchworkSite(config) {
|
|
|
102
107
|
}
|
|
103
108
|
else {
|
|
104
109
|
log("creating repo");
|
|
110
|
+
// Pass an explicit signer (instead of the Repo's internal default) so we
|
|
111
|
+
// can expose tab signer identity on window.patchwork for dev inspection.
|
|
112
|
+
// The tab never connects via Subduction (no endpoints/adapters), so this
|
|
113
|
+
// id never goes on the wire.
|
|
114
|
+
const tabSigner = new MemorySigner();
|
|
105
115
|
repo = new Repo({
|
|
106
116
|
network: [new MessageChannelNetworkAdapter(workerPort)],
|
|
107
117
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
118
|
+
signer: tabSigner,
|
|
108
119
|
async sharePolicy(peerId) {
|
|
109
120
|
return peerId.includes("automerge-worker");
|
|
110
121
|
},
|
|
111
122
|
enableRemoteHeadsGossiping: true,
|
|
112
123
|
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
113
124
|
});
|
|
125
|
+
tabSignerIdentity = {
|
|
126
|
+
peerId: tabSigner.peerId().toString(),
|
|
127
|
+
verifyingKey: tabSigner.verifyingKey().toHex(),
|
|
128
|
+
};
|
|
129
|
+
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
114
130
|
log("repo created");
|
|
115
131
|
}
|
|
116
132
|
}
|
|
@@ -157,9 +173,11 @@ export async function bootPatchworkSite(config) {
|
|
|
157
173
|
packages: moduleWatcher,
|
|
158
174
|
plugins,
|
|
159
175
|
accountDocHandle,
|
|
176
|
+
...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
|
|
160
177
|
sw: {
|
|
161
178
|
connectClassicSync: sw.connectClassicSync,
|
|
162
179
|
subscribeToRepoChannel: sw.subscribeToRepoChannel,
|
|
180
|
+
subscribeSyncState: sw.subscribeSyncState,
|
|
163
181
|
},
|
|
164
182
|
};
|
|
165
183
|
window.uncache = uncache;
|
|
@@ -167,7 +185,6 @@ export async function bootPatchworkSite(config) {
|
|
|
167
185
|
rootElement,
|
|
168
186
|
repo,
|
|
169
187
|
accountDocHandle,
|
|
170
|
-
moduleWatcher,
|
|
171
188
|
titleSuffix: config.titleSuffix,
|
|
172
189
|
});
|
|
173
190
|
return { repo, moduleWatcher, accountDocHandle };
|
|
@@ -296,10 +313,7 @@ function primeRootElement(rootElement, accountDocHandle) {
|
|
|
296
313
|
const initialParams = new URLSearchParams(location.hash.slice(1));
|
|
297
314
|
if (initialParams.has("frame")) {
|
|
298
315
|
rootElement.setAttribute("tool-id", initialParams.get("frame"));
|
|
299
|
-
const
|
|
300
|
-
const docUrl = docId
|
|
301
|
-
? stringifyAutomergeUrl({ documentId: docId })
|
|
302
|
-
: accountDocHandle.url;
|
|
316
|
+
const docUrl = docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
|
|
303
317
|
rootElement.setAttribute("doc-url", docUrl);
|
|
304
318
|
}
|
|
305
319
|
else {
|
|
@@ -385,20 +399,56 @@ async function uncache(match) {
|
|
|
385
399
|
}
|
|
386
400
|
}
|
|
387
401
|
}
|
|
402
|
+
// The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
|
|
403
|
+
// heads) literal rather than percent-encoding it so links stay readable.
|
|
404
|
+
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
405
|
+
// Emit hash params in a stable order so re-serializing the same logical params
|
|
406
|
+
// yields a byte-identical string (avoids spurious `hashchange` round-trips).
|
|
407
|
+
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
408
|
+
function serializeHashParams(params) {
|
|
409
|
+
const emitted = new Set();
|
|
410
|
+
const parts = [];
|
|
411
|
+
const emit = (key) => {
|
|
412
|
+
if (emitted.has(key))
|
|
413
|
+
return;
|
|
414
|
+
const value = params.get(key);
|
|
415
|
+
if (!value)
|
|
416
|
+
return;
|
|
417
|
+
emitted.add(key);
|
|
418
|
+
parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
|
|
419
|
+
};
|
|
420
|
+
for (const key of HASH_KEY_ORDER)
|
|
421
|
+
emit(key);
|
|
422
|
+
for (const key of params.keys())
|
|
423
|
+
emit(key);
|
|
424
|
+
return parts.join("&");
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
428
|
+
* (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
|
|
429
|
+
* with older links.
|
|
430
|
+
*/
|
|
431
|
+
function docParamToUrl(docParam) {
|
|
432
|
+
if (!docParam)
|
|
433
|
+
return undefined;
|
|
434
|
+
if (isValidAutomergeUrl(docParam)) {
|
|
435
|
+
return docParam;
|
|
436
|
+
}
|
|
437
|
+
const documentId = docParam.replace(/^automerge:/, "");
|
|
438
|
+
if (isValidDocumentId(documentId)) {
|
|
439
|
+
return stringifyAutomergeUrl({ documentId: documentId });
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
388
443
|
function installHashRouting(params) {
|
|
389
|
-
const { rootElement, repo, accountDocHandle,
|
|
390
|
-
rootElement.addEventListener("patchwork:no-tool", (event) => {
|
|
391
|
-
moduleWatcher.loadSuggestedImportUrl(event.detail.url);
|
|
392
|
-
});
|
|
444
|
+
const { rootElement, repo, accountDocHandle, titleSuffix } = params;
|
|
393
445
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
394
446
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
395
447
|
const { url, toolId, type, title } = event.detail;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
else
|
|
401
|
-
params.delete("heads");
|
|
448
|
+
// `doc` is now the full automerge URL — heads, if any, live inside it, so
|
|
449
|
+
// the separate `heads=` param is gone.
|
|
450
|
+
params.delete("heads");
|
|
451
|
+
params.set("doc", url);
|
|
402
452
|
if (toolId)
|
|
403
453
|
params.set("tool", toolId);
|
|
404
454
|
else
|
|
@@ -411,9 +461,9 @@ function installHashRouting(params) {
|
|
|
411
461
|
params.set("type", type);
|
|
412
462
|
else
|
|
413
463
|
params.delete("type");
|
|
414
|
-
window.location.hash = params
|
|
464
|
+
window.location.hash = serializeHashParams(params);
|
|
415
465
|
try {
|
|
416
|
-
const docHandle = await repo.find(
|
|
466
|
+
const docHandle = await repo.find(url);
|
|
417
467
|
const doc = docHandle.doc();
|
|
418
468
|
const docType = type || doc?.["@patchwork"]?.type;
|
|
419
469
|
if (!docType)
|
|
@@ -454,44 +504,40 @@ function installHashRouting(params) {
|
|
|
454
504
|
setTimeout(reveal, 12_000);
|
|
455
505
|
const handleHashChange = async () => {
|
|
456
506
|
const hash = window.location.hash.slice(1);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
507
|
+
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
508
|
+
// `--` followed by a valid document id, normalize it to the canonical
|
|
509
|
+
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
510
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
511
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
512
|
+
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
513
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
514
|
+
}));
|
|
463
515
|
return;
|
|
464
516
|
}
|
|
465
517
|
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
466
518
|
if (isValidAutomergeUrl(hash)) {
|
|
467
|
-
const
|
|
519
|
+
const url = hash;
|
|
468
520
|
window.location.hash = "";
|
|
469
|
-
openDocument(rootElement,
|
|
521
|
+
openDocument(rootElement, url);
|
|
470
522
|
return;
|
|
471
523
|
}
|
|
472
524
|
const params = new URLSearchParams(hash);
|
|
473
|
-
const
|
|
474
|
-
const heads = params.get("heads")?.split("|");
|
|
525
|
+
const docUrl = docParamToUrl(params.get("doc"));
|
|
475
526
|
const toolId = params.get("tool");
|
|
476
527
|
const title = params.get("title");
|
|
477
528
|
const type = params.get("type");
|
|
478
529
|
const frame = params.get("frame");
|
|
479
530
|
if (frame) {
|
|
480
|
-
const
|
|
531
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
481
532
|
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
482
|
-
rootElement.getAttribute("doc-url") !==
|
|
533
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
483
534
|
rootElement.setAttribute("tool-id", frame);
|
|
484
|
-
rootElement.setAttribute("doc-url",
|
|
535
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
485
536
|
}
|
|
486
537
|
}
|
|
487
|
-
if (
|
|
538
|
+
if (docUrl) {
|
|
488
539
|
rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
|
|
489
|
-
detail: {
|
|
490
|
-
url: stringifyAutomergeUrl({ documentId, heads }),
|
|
491
|
-
toolId,
|
|
492
|
-
title,
|
|
493
|
-
type,
|
|
494
|
-
},
|
|
540
|
+
detail: { url: docUrl, toolId, title, type },
|
|
495
541
|
}));
|
|
496
542
|
}
|
|
497
543
|
};
|