@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4
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 +11 -0
- package/dist/automerge-worker.js +543 -771
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +174 -205
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +251 -375
- package/dist/site.d.ts +17 -32
- package/dist/site.js +291 -376
- package/dist/vite/importmap-plugin.js +1 -5
- package/package.json +4 -4
- package/src/automerge-worker.ts +635 -833
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +219 -238
- package/src/setup.ts +287 -401
- package/src/site.ts +377 -439
- package/src/vite/importmap-plugin.ts +1 -5
package/src/site.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Browser-app boot sequence for a Patchwork site.
|
|
3
3
|
*
|
|
4
|
-
* Layers on top of {@link setupServiceWorker}
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* configured root tool.
|
|
4
|
+
* Layers on top of {@link setupServiceWorker} to construct the Repo, wire up
|
|
5
|
+
* the automerge-worker port, load plugins via the ModuleWatcher, resolve the
|
|
6
|
+
* user's account document, and hand control to the configured root tool.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* only does SW registration and the automerge-worker handoff).
|
|
8
|
+
* Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
|
|
9
|
+
* `main.ts` only. Non-UI consumers should import the package default, which
|
|
10
|
+
* does SW registration and the automerge-worker handoff and nothing else.
|
|
13
11
|
*/
|
|
14
12
|
import {
|
|
15
13
|
type DocHandle,
|
|
@@ -35,18 +33,6 @@ import {
|
|
|
35
33
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
36
34
|
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
37
35
|
|
|
38
|
-
declare const __SITE_NAME__: string;
|
|
39
|
-
const siteName =
|
|
40
|
-
typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
|
|
41
|
-
|
|
42
|
-
// Sync-server selection for keyhive. Defaults to "subduction". Build with
|
|
43
|
-
// KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
|
|
44
|
-
// the automerge-worker (SharedWorker) selection so the tab and the SW grant relay
|
|
45
|
-
// access to the same server.
|
|
46
|
-
declare const __KEYHIVE_SYNC_SERVER__: boolean;
|
|
47
|
-
const useKeyhiveSyncServer =
|
|
48
|
-
typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
49
|
-
|
|
50
36
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
51
37
|
import { importAutomergePackageViaWorker } from "./module-loader.js";
|
|
52
38
|
import {
|
|
@@ -65,14 +51,29 @@ import {
|
|
|
65
51
|
} from "@inkandswitch/patchwork-plugins";
|
|
66
52
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
67
53
|
|
|
68
|
-
import setupServiceWorker, {
|
|
54
|
+
import setupServiceWorker, { lifecycleLog } from "./setup.js";
|
|
69
55
|
import type {
|
|
70
56
|
ServiceWorkerRepoChannelListener,
|
|
71
57
|
SyncStateDocMessage,
|
|
72
58
|
} from "./types.js";
|
|
73
59
|
import debug from "debug";
|
|
60
|
+
|
|
74
61
|
const log = debug("patchwork:bootloader:site");
|
|
75
62
|
|
|
63
|
+
declare const __SITE_NAME__: string;
|
|
64
|
+
const siteName =
|
|
65
|
+
typeof __SITE_NAME__ !== "undefined"
|
|
66
|
+
? __SITE_NAME__
|
|
67
|
+
: "patchwork.inkandswitch.com";
|
|
68
|
+
|
|
69
|
+
// Must match the automerge-worker's selection, or the tab and the SW grant
|
|
70
|
+
// relay access to different servers.
|
|
71
|
+
declare const __KEYHIVE_SYNC_SERVER__: boolean;
|
|
72
|
+
const useKeyhiveSyncServer =
|
|
73
|
+
typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
74
|
+
|
|
75
|
+
type SignerIdentity = { peerId: string; verifyingKey: string };
|
|
76
|
+
|
|
76
77
|
declare global {
|
|
77
78
|
interface Window {
|
|
78
79
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
@@ -86,10 +87,7 @@ declare global {
|
|
|
86
87
|
packages: ModuleWatcher;
|
|
87
88
|
plugins: typeof plugins;
|
|
88
89
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
89
|
-
signer?:
|
|
90
|
-
peerId: string;
|
|
91
|
-
verifyingKey: string;
|
|
92
|
-
};
|
|
90
|
+
signer?: SignerIdentity;
|
|
93
91
|
sw: {
|
|
94
92
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
95
93
|
subscribeToRepoChannel: (
|
|
@@ -123,9 +121,7 @@ export interface SiteConfig {
|
|
|
123
121
|
* folder docs or plain HTTP(S) bundles, so deployment targets can be freely
|
|
124
122
|
* mixed.
|
|
125
123
|
*
|
|
126
|
-
*
|
|
127
|
-
* another `automerge:` URL or manifest URL — useful for local development
|
|
128
|
-
* against an unpublished tool set.
|
|
124
|
+
* Overridable at runtime with `localStorage.systemPackageListURL`.
|
|
129
125
|
*/
|
|
130
126
|
defaultModules?: string | string[];
|
|
131
127
|
|
|
@@ -148,16 +144,12 @@ export interface SiteConfig {
|
|
|
148
144
|
*/
|
|
149
145
|
titleSuffix: string;
|
|
150
146
|
|
|
151
|
-
/**
|
|
152
|
-
* DOM id of the `<patchwork-view>` element that will host the root tool.
|
|
153
|
-
* Defaults to `"root"`.
|
|
154
|
-
*/
|
|
147
|
+
/** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
|
|
155
148
|
rootElementId?: string;
|
|
156
149
|
|
|
157
150
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* instead of a sharePolicy.
|
|
151
|
+
* Initialize keyhive for access control. The Repo then uses keyhive's network
|
|
152
|
+
* adapter, peerId and idFactory instead of a sharePolicy.
|
|
161
153
|
*/
|
|
162
154
|
keyhive?: boolean;
|
|
163
155
|
}
|
|
@@ -168,208 +160,106 @@ export interface BootResult {
|
|
|
168
160
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
169
161
|
}
|
|
170
162
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
const BIG_PATCHWORK_HASH_REGEX =
|
|
176
|
-
/^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
177
|
-
|
|
178
|
-
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
163
|
+
// Started at module evaluation but not top-level awaited: awaiting here would
|
|
164
|
+
// hold up everything importing this module, so the loading animation couldn't
|
|
165
|
+
// appear until the biggest download of the boot had already finished.
|
|
166
|
+
const wasmFetches = Promise.all([
|
|
179
167
|
fetch("/automerge.wasm").then((r) => r.bytes()),
|
|
180
168
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
181
169
|
]);
|
|
170
|
+
wasmFetches.catch(() => {});
|
|
182
171
|
|
|
183
|
-
/**
|
|
184
|
-
* Boot a Patchwork browser site.
|
|
185
|
-
*
|
|
186
|
-
* Performs the full application-shell setup: service worker + port, Repo,
|
|
187
|
-
* plugin/module loading, account resolution, URL-hash routing, and dev-console
|
|
188
|
-
* globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
|
|
189
|
-
* constructed Repo, ModuleWatcher and account handle for sites that want to
|
|
190
|
-
* do additional wiring after boot.
|
|
191
|
-
*/
|
|
192
172
|
export async function bootPatchworkSite(
|
|
193
173
|
config: SiteConfig
|
|
194
174
|
): Promise<BootResult> {
|
|
195
|
-
const
|
|
175
|
+
const moduleSources = resolveDefaultModules(config);
|
|
196
176
|
showLoadingAnimation();
|
|
197
|
-
log(
|
|
177
|
+
log("booting", config);
|
|
198
178
|
installLifecycleLogging();
|
|
179
|
+
|
|
180
|
+
const [automergeWasm, subductionWasm] = await wasmFetches;
|
|
199
181
|
await initializeWasm(automergeWasm);
|
|
200
182
|
initSubductionSync(subductionWasm);
|
|
201
183
|
|
|
202
|
-
log("enabling workers");
|
|
203
184
|
const sw = await setupServiceWorker();
|
|
204
185
|
if (!sw) throw new Error("Failed to set up service worker");
|
|
205
186
|
log("workers ready");
|
|
206
187
|
|
|
207
188
|
let hive: AutomergeRepoKeyhive | undefined;
|
|
208
189
|
let repo: Repo;
|
|
209
|
-
let
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
// (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
|
|
190
|
+
let signerIdentity: SignerIdentity | undefined;
|
|
191
|
+
// Called with a fresh port when the automerge worker dies and is recreated.
|
|
192
|
+
// Assigned once the repo exists.
|
|
213
193
|
let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
|
|
214
194
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
218
|
-
// Otherwise create our own below.
|
|
195
|
+
// An embedding context may have provided a Repo before this entry ran. Reuse
|
|
196
|
+
// it and its keyhive so we share the same documents and sync context.
|
|
219
197
|
if (window.repo) {
|
|
220
198
|
log("using existing Repo from window");
|
|
221
199
|
repo = window.repo;
|
|
222
200
|
hive = window.hive;
|
|
223
201
|
} else {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// recreated after dying.
|
|
228
|
-
let resolveFirstPort!: (port: MessagePort) => void;
|
|
229
|
-
const firstPortPromise = new Promise<MessagePort>((r) => {
|
|
230
|
-
resolveFirstPort = r;
|
|
231
|
-
});
|
|
232
|
-
let seenFirstPort = false;
|
|
233
|
-
log("subscribing to repo channel");
|
|
234
|
-
// Deliberately not awaited: subscribeToRepoChannel resolves only after
|
|
235
|
-
// the boot channel's port-ready handshake, which can take its full 30s
|
|
236
|
-
// timeout against a stranded worker connection. Boot should block on the
|
|
237
|
-
// first *delivered* port instead — if the boot channel stalls, worker
|
|
238
|
-
// recovery hands the listener a good port long before that timeout.
|
|
239
|
-
void sw.subscribeToRepoChannel((port) => {
|
|
240
|
-
if (!seenFirstPort) {
|
|
241
|
-
seenFirstPort = true;
|
|
242
|
-
resolveFirstPort(port);
|
|
243
|
-
} else if (onWorkerPortRenewed) {
|
|
244
|
-
onWorkerPortRenewed(port);
|
|
245
|
-
} else {
|
|
202
|
+
const workerPort = await firstRepoPort(sw, (port) => {
|
|
203
|
+
if (onWorkerPortRenewed) onWorkerPortRenewed(port);
|
|
204
|
+
else {
|
|
246
205
|
console.warn(
|
|
247
206
|
"automerge worker port renewed before the repo existed; dropping it"
|
|
248
207
|
);
|
|
249
208
|
}
|
|
250
209
|
});
|
|
251
|
-
const workerPort = await firstPortPromise;
|
|
252
|
-
log("repo channel subscribed");
|
|
253
|
-
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
254
210
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
initKeyhiveWasm();
|
|
258
|
-
|
|
259
|
-
({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
260
|
-
createRepo: (config) => new Repo(config),
|
|
261
|
-
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
262
|
-
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
263
|
-
networkAdapter: workerAdapter,
|
|
264
|
-
automaticArchiveIngestion: true,
|
|
265
|
-
cachingMode: "periodic",
|
|
266
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
267
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
268
|
-
// Defaults to "subduction".
|
|
269
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
270
|
-
repo: {
|
|
271
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
272
|
-
enableRemoteHeadsGossiping: true,
|
|
273
|
-
},
|
|
274
|
-
}));
|
|
275
|
-
log("keyhive setup complete");
|
|
276
|
-
} else {
|
|
277
|
-
log("creating repo");
|
|
278
|
-
// Pass an explicit signer (instead of the Repo's internal default) so we
|
|
279
|
-
// can expose tab signer identity on window.patchwork for dev inspection.
|
|
280
|
-
// The tab never connects via Subduction (no endpoints/adapters), so this
|
|
281
|
-
// id never goes on the wire.
|
|
282
|
-
const tabSigner = new MemorySigner();
|
|
283
|
-
repo = new Repo({
|
|
284
|
-
network: [workerAdapter],
|
|
285
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
286
|
-
signer: tabSigner,
|
|
287
|
-
async sharePolicy(peerId) {
|
|
288
|
-
return peerId.includes("automerge-worker");
|
|
289
|
-
},
|
|
290
|
-
enableRemoteHeadsGossiping: true,
|
|
291
|
-
peerId:
|
|
292
|
-
`${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
293
|
-
});
|
|
294
|
-
tabSignerIdentity = {
|
|
295
|
-
peerId: tabSigner.peerId().toString(),
|
|
296
|
-
verifyingKey: (
|
|
297
|
-
tabSigner.verifyingKey() as Uint8Array<ArrayBufferLike> & {
|
|
298
|
-
toHex(): string;
|
|
299
|
-
}
|
|
300
|
-
).toHex(),
|
|
301
|
-
};
|
|
302
|
-
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
303
|
-
log("repo created");
|
|
304
|
-
}
|
|
211
|
+
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
212
|
+
({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
|
|
305
213
|
|
|
306
214
|
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
307
|
-
// port and drop the adapter stranded on the dead one.
|
|
308
|
-
// settled by the time this can fire (recovery needs a missed heartbeat).
|
|
215
|
+
// port and drop the adapter stranded on the dead one.
|
|
309
216
|
const bootHive = hive;
|
|
310
217
|
onWorkerPortRenewed = (port) => {
|
|
311
218
|
const fresh = new MessageChannelNetworkAdapter(port);
|
|
312
219
|
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
313
|
-
// keyhive adapter wrapped around the message channel
|
|
314
|
-
// the worker uses for its side of the pair).
|
|
220
|
+
// keyhive adapter wrapped around the message channel.
|
|
315
221
|
const registered = bootHive
|
|
316
222
|
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
317
223
|
: fresh;
|
|
318
224
|
repo.networkSubsystem.addNetworkAdapter(registered as any);
|
|
319
|
-
|
|
320
|
-
if (adapter === (registered as any)) continue;
|
|
321
|
-
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
322
|
-
const base = (adapter as any).networkAdapter ?? adapter;
|
|
323
|
-
if (base !== workerAdapter) continue;
|
|
324
|
-
try {
|
|
325
|
-
repo.networkSubsystem.removeNetworkAdapter(adapter as any);
|
|
326
|
-
} catch (err) {
|
|
327
|
-
console.error("failed to remove stale worker network adapter", err);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
225
|
+
removeAdapterFor(repo, workerAdapter, registered);
|
|
330
226
|
workerAdapter = fresh;
|
|
331
|
-
|
|
332
|
-
`[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
|
|
333
|
-
`recreated automerge worker`
|
|
334
|
-
);
|
|
227
|
+
lifecycleLog("repo re-wired to the recreated automerge worker");
|
|
335
228
|
};
|
|
336
229
|
}
|
|
337
|
-
|
|
230
|
+
|
|
338
231
|
window.repo = repo;
|
|
339
|
-
|
|
232
|
+
window.Automerge = Automerge;
|
|
233
|
+
window.AutomergeRepo = AutomergeRepo;
|
|
234
|
+
window.getRepoChannel = sw.getRepoChannel;
|
|
235
|
+
if (hive) window.hive = hive;
|
|
340
236
|
|
|
341
237
|
await repo.networkSubsystem.whenReady();
|
|
342
238
|
log("networkSubsystem ready");
|
|
343
|
-
|
|
344
|
-
if (hive) {
|
|
345
|
-
(hive.networkAdapter as any).syncKeyhive?.();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
|
|
239
|
+
(hive?.networkAdapter as any)?.syncKeyhive?.();
|
|
349
240
|
|
|
350
241
|
registerRepoProviderElement(repo as any);
|
|
351
242
|
|
|
352
|
-
const
|
|
243
|
+
const rootElementId = config.rootElementId ?? "root";
|
|
244
|
+
const rootElement = document.getElementById(rootElementId);
|
|
353
245
|
if (!rootElement) {
|
|
354
|
-
throw new Error(
|
|
355
|
-
`bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`
|
|
356
|
-
);
|
|
246
|
+
throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
|
|
357
247
|
}
|
|
248
|
+
|
|
358
249
|
// `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
|
|
359
|
-
// for any view outside a remapper
|
|
250
|
+
// for any view outside a remapper, resolving to the requested url unchanged.
|
|
360
251
|
const repoProvider = document.createElement("repo-provider");
|
|
361
252
|
rootElement.parentElement!.insertBefore(repoProvider, rootElement);
|
|
362
253
|
repoProvider.appendChild(rootElement);
|
|
363
254
|
|
|
364
255
|
registerPatchworkViewElement({ hive, repo });
|
|
365
256
|
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// is added lazily once it appears on the account doc — see below.
|
|
257
|
+
// Started with the site bundle alone so resolveAccountHandle has something to
|
|
258
|
+
// await on — the `account` datatype lives there. The user's own
|
|
259
|
+
// module-settings URL is added lazily once it appears on the account doc.
|
|
370
260
|
const moduleWatcher = new ModuleWatcher(
|
|
371
261
|
repo,
|
|
372
|
-
|
|
262
|
+
nameSources(moduleSources),
|
|
373
263
|
onModuleLoaded,
|
|
374
264
|
unregisterPlugins,
|
|
375
265
|
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
@@ -381,29 +271,35 @@ export async function bootPatchworkSite(
|
|
|
381
271
|
storageKey: config.accountStorageKey,
|
|
382
272
|
hive,
|
|
383
273
|
})) as DocHandle<AccountDoc>;
|
|
384
|
-
// TODO: something we (Orion & pvh) changed in the types made this necessary
|
|
385
|
-
// fix this before merging to main!
|
|
386
274
|
|
|
387
275
|
window.accountDocHandle = accountDocHandle;
|
|
388
|
-
|
|
389
|
-
wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
|
|
390
|
-
|
|
391
|
-
primeRootElement(rootElement, accountDocHandle);
|
|
392
|
-
logToolRegistryWhenLoaded(moduleWatcher);
|
|
393
|
-
|
|
276
|
+
window.uncache = uncache;
|
|
394
277
|
window.patchwork = {
|
|
395
278
|
repo,
|
|
396
279
|
packages: moduleWatcher,
|
|
397
280
|
plugins,
|
|
398
281
|
accountDocHandle,
|
|
399
|
-
...(
|
|
282
|
+
...(signerIdentity ? { signer: signerIdentity } : {}),
|
|
400
283
|
sw: {
|
|
401
284
|
connectClassicSync: sw.connectClassicSync,
|
|
402
285
|
subscribeToRepoChannel: sw.subscribeToRepoChannel,
|
|
403
286
|
subscribeSyncState: sw.subscribeSyncState,
|
|
404
287
|
},
|
|
405
288
|
};
|
|
406
|
-
|
|
289
|
+
|
|
290
|
+
wireModuleSettings(accountDocHandle, moduleWatcher);
|
|
291
|
+
primeRootElement(rootElement, accountDocHandle);
|
|
292
|
+
|
|
293
|
+
moduleWatcher.doneLoading.then(
|
|
294
|
+
() =>
|
|
295
|
+
log(
|
|
296
|
+
"doneLoading, tools registered:",
|
|
297
|
+
getRegistry("patchwork:tool")
|
|
298
|
+
.all()
|
|
299
|
+
.map((t: any) => t.id)
|
|
300
|
+
),
|
|
301
|
+
(err: unknown) => console.error("doneLoading rejected:", err)
|
|
302
|
+
);
|
|
407
303
|
|
|
408
304
|
installHashRouting({
|
|
409
305
|
rootElement,
|
|
@@ -415,160 +311,214 @@ export async function bootPatchworkSite(
|
|
|
415
311
|
return { repo, moduleWatcher, accountDocHandle };
|
|
416
312
|
}
|
|
417
313
|
|
|
418
|
-
// ─── Internals ──────────────────────────────────────────────────────────
|
|
419
|
-
|
|
420
314
|
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
315
|
+
* Resolve with the first repo port the worker delivers, calling `onRenewed` for
|
|
316
|
+
* every later one.
|
|
317
|
+
*
|
|
318
|
+
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
319
|
+
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
320
|
+
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
321
|
+
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
322
|
+
* a good port long before that timeout.
|
|
423
323
|
*/
|
|
324
|
+
function firstRepoPort(
|
|
325
|
+
sw: Awaited<ReturnType<typeof setupServiceWorker>>,
|
|
326
|
+
onRenewed: (port: MessagePort) => void
|
|
327
|
+
): Promise<MessagePort> {
|
|
328
|
+
return new Promise<MessagePort>((resolve) => {
|
|
329
|
+
let seen = false;
|
|
330
|
+
void sw.subscribeToRepoChannel((port) => {
|
|
331
|
+
if (seen) return onRenewed(port);
|
|
332
|
+
seen = true;
|
|
333
|
+
resolve(port);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function createRepo(
|
|
339
|
+
config: SiteConfig,
|
|
340
|
+
workerAdapter: MessageChannelNetworkAdapter
|
|
341
|
+
): Promise<{
|
|
342
|
+
repo: Repo;
|
|
343
|
+
hive?: AutomergeRepoKeyhive;
|
|
344
|
+
signerIdentity?: SignerIdentity;
|
|
345
|
+
}> {
|
|
346
|
+
if (config.keyhive) {
|
|
347
|
+
log("setting up keyhive");
|
|
348
|
+
initKeyhiveWasm();
|
|
349
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
350
|
+
createRepo: (repoConfig) => new Repo(repoConfig),
|
|
351
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
352
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
353
|
+
networkAdapter: workerAdapter,
|
|
354
|
+
automaticArchiveIngestion: true,
|
|
355
|
+
cachingMode: "periodic",
|
|
356
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
357
|
+
// ARK selects the relay via `syncServer`, defaulting to "subduction".
|
|
358
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
359
|
+
repo: {
|
|
360
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
361
|
+
enableRemoteHeadsGossiping: true,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
log("keyhive setup complete");
|
|
365
|
+
return { repo, hive };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// An explicit signer, rather than the Repo's internal default, so the tab's
|
|
369
|
+
// identity can be exposed on window.patchwork. The tab never connects via
|
|
370
|
+
// Subduction, so this id never goes on the wire.
|
|
371
|
+
const signer = new MemorySigner();
|
|
372
|
+
const repo = new Repo({
|
|
373
|
+
network: [workerAdapter],
|
|
374
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
375
|
+
signer,
|
|
376
|
+
async sharePolicy(peerId) {
|
|
377
|
+
return peerId.includes("automerge-worker");
|
|
378
|
+
},
|
|
379
|
+
enableRemoteHeadsGossiping: true,
|
|
380
|
+
peerId:
|
|
381
|
+
`${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
382
|
+
});
|
|
383
|
+
const signerIdentity = {
|
|
384
|
+
peerId: signer.peerId().toString(),
|
|
385
|
+
verifyingKey: (
|
|
386
|
+
signer.verifyingKey() as Uint8Array<ArrayBufferLike> & {
|
|
387
|
+
toHex(): string;
|
|
388
|
+
}
|
|
389
|
+
).toHex(),
|
|
390
|
+
};
|
|
391
|
+
log("repo created, tab subduction identity:", signerIdentity);
|
|
392
|
+
return { repo, signerIdentity };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
396
|
+
function removeAdapterFor(
|
|
397
|
+
repo: Repo,
|
|
398
|
+
stale: MessageChannelNetworkAdapter,
|
|
399
|
+
keep: unknown
|
|
400
|
+
): void {
|
|
401
|
+
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
402
|
+
if (adapter === keep) continue;
|
|
403
|
+
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
404
|
+
const base = (adapter as any).networkAdapter ?? adapter;
|
|
405
|
+
if (base !== stale) continue;
|
|
406
|
+
try {
|
|
407
|
+
repo.networkSubsystem.removeNetworkAdapter(adapter as any);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.error("failed to remove stale worker network adapter", err);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
424
414
|
function isValidModuleSource(source: string): boolean {
|
|
425
|
-
|
|
426
|
-
return (
|
|
427
|
-
source.startsWith("/") ||
|
|
428
|
-
source.startsWith("http://") ||
|
|
429
|
-
source.startsWith("https://") ||
|
|
430
|
-
source.startsWith("./")
|
|
431
|
-
);
|
|
415
|
+
return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
|
|
432
416
|
}
|
|
433
417
|
|
|
434
418
|
/**
|
|
435
|
-
*
|
|
436
|
-
* `localStorage.systemPackageListURL` dev override
|
|
437
|
-
* built-in
|
|
419
|
+
* The site's default module-list sources, honouring the
|
|
420
|
+
* `localStorage.systemPackageListURL` dev override, which replaces the entire
|
|
421
|
+
* built-in bundle. `defaultToolsUrl` is the pre-rename key.
|
|
438
422
|
*/
|
|
439
423
|
function resolveDefaultModules(config: SiteConfig): string[] {
|
|
440
|
-
const
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
);
|
|
424
|
+
const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
425
|
+
const builtin = (
|
|
426
|
+
Array.isArray(configured) ? configured : [configured]
|
|
427
|
+
).filter(Boolean);
|
|
444
428
|
|
|
445
429
|
const storage = globalThis.localStorage;
|
|
446
|
-
// `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
|
|
447
430
|
const override =
|
|
448
431
|
storage?.getItem("systemPackageListURL") ??
|
|
449
432
|
storage?.getItem("defaultToolsUrl");
|
|
433
|
+
|
|
434
|
+
if (override && isValidModuleSource(override)) {
|
|
435
|
+
console.info(`using systemPackageListURL from localStorage: ${override}`);
|
|
436
|
+
return [override];
|
|
437
|
+
}
|
|
450
438
|
if (override) {
|
|
451
|
-
if (isValidModuleSource(override)) {
|
|
452
|
-
if (!builtinList.includes(override)) {
|
|
453
|
-
console.info(
|
|
454
|
-
`using systemPackageListURL override from localStorage: ${override}`
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
return [override];
|
|
458
|
-
}
|
|
459
439
|
console.warn(
|
|
460
|
-
`ignoring invalid systemPackageListURL in localStorage: ${override}
|
|
440
|
+
`ignoring invalid systemPackageListURL in localStorage: ${override}`
|
|
461
441
|
);
|
|
462
442
|
}
|
|
463
443
|
|
|
464
|
-
if (
|
|
444
|
+
if (builtin.length === 0) {
|
|
465
445
|
throw new Error(
|
|
466
446
|
"bootPatchworkSite: no default module sources configured (set `defaultModules`)"
|
|
467
447
|
);
|
|
468
448
|
}
|
|
469
|
-
return
|
|
449
|
+
return builtin;
|
|
470
450
|
}
|
|
471
451
|
|
|
472
452
|
/**
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* per-account settings doc, which has branch-override precedence).
|
|
453
|
+
* Name the sources for the ModuleWatcher. The first keeps the canonical
|
|
454
|
+
* `system` name; the rest get suffixed. None may be `user`, which is reserved
|
|
455
|
+
* for the per-account settings doc and has branch-override precedence.
|
|
477
456
|
*/
|
|
478
|
-
function
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
map[name] = source;
|
|
483
|
-
});
|
|
484
|
-
return map;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function installDevConsoleGlobals(
|
|
488
|
-
repo: Repo,
|
|
489
|
-
hive: AutomergeRepoKeyhive | undefined,
|
|
490
|
-
getRepoChannel: () => MessagePort
|
|
491
|
-
): void {
|
|
492
|
-
window.repo = repo;
|
|
493
|
-
window.Automerge = Automerge;
|
|
494
|
-
window.AutomergeRepo = AutomergeRepo;
|
|
495
|
-
if (hive) {
|
|
496
|
-
window.hive = hive;
|
|
497
|
-
}
|
|
498
|
-
window.getRepoChannel = getRepoChannel;
|
|
457
|
+
function nameSources(sources: string[]): Record<string, string> {
|
|
458
|
+
return Object.fromEntries(
|
|
459
|
+
sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source])
|
|
460
|
+
);
|
|
499
461
|
}
|
|
500
462
|
|
|
501
|
-
/**
|
|
502
|
-
*
|
|
503
|
-
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
504
|
-
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
505
|
-
*/
|
|
463
|
+
/** Page Lifecycle and connectivity transitions, to line up against the
|
|
464
|
+
* SharedWorker's sync-socket reaps. */
|
|
506
465
|
function installLifecycleLogging(): void {
|
|
507
466
|
if (typeof document === "undefined") return;
|
|
508
467
|
const opts = { capture: true } as const;
|
|
509
|
-
const
|
|
510
|
-
if (!lifecycleLoggingEnabled()) return;
|
|
511
|
-
const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
|
|
512
|
-
if (extra === undefined) console.info(msg);
|
|
513
|
-
else console.info(msg, extra);
|
|
514
|
-
};
|
|
468
|
+
const persisted = (e: Event) => (e as PageTransitionEvent).persisted;
|
|
515
469
|
|
|
516
470
|
document.addEventListener(
|
|
517
471
|
"visibilitychange",
|
|
518
|
-
() =>
|
|
472
|
+
() => lifecycleLog("visibilitychange → %s", document.visibilityState),
|
|
519
473
|
opts
|
|
520
474
|
);
|
|
521
475
|
document.addEventListener(
|
|
522
476
|
"freeze",
|
|
523
|
-
() =>
|
|
477
|
+
() => lifecycleLog("freeze (tab suspended)"),
|
|
524
478
|
opts
|
|
525
479
|
);
|
|
526
480
|
document.addEventListener(
|
|
527
481
|
"resume",
|
|
528
|
-
() =>
|
|
482
|
+
() => lifecycleLog("resume (tab unsuspended)"),
|
|
529
483
|
opts
|
|
530
484
|
);
|
|
531
485
|
window.addEventListener(
|
|
532
486
|
"pageshow",
|
|
533
|
-
(e) =>
|
|
534
|
-
note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
|
|
487
|
+
(e) => lifecycleLog("pageshow persisted=%s", persisted(e)),
|
|
535
488
|
opts
|
|
536
489
|
);
|
|
537
490
|
window.addEventListener(
|
|
538
491
|
"pagehide",
|
|
539
|
-
(e) =>
|
|
540
|
-
note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
|
|
492
|
+
(e) => lifecycleLog("pagehide persisted=%s", persisted(e)),
|
|
541
493
|
opts
|
|
542
494
|
);
|
|
543
|
-
window.addEventListener("online", () =>
|
|
544
|
-
window.addEventListener("offline", () =>
|
|
495
|
+
window.addEventListener("online", () => lifecycleLog("online"), opts);
|
|
496
|
+
window.addEventListener("offline", () => lifecycleLog("offline"), opts);
|
|
545
497
|
|
|
546
|
-
|
|
547
|
-
|
|
498
|
+
lifecycleLog(
|
|
499
|
+
"logging installed (visibilityState=%s, hasFocus=%s)",
|
|
500
|
+
document.visibilityState,
|
|
501
|
+
document.hasFocus()
|
|
548
502
|
);
|
|
549
503
|
}
|
|
550
504
|
|
|
551
505
|
function onModuleLoaded(name: string, mod: any): void {
|
|
552
|
-
if (Array.isArray(mod.plugins)) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
mod.plugins.map((p: any) => `${p.type}:${p.id}`)
|
|
556
|
-
);
|
|
557
|
-
registerPlugins(mod.plugins, name);
|
|
558
|
-
} else {
|
|
559
|
-
console.warn(
|
|
560
|
-
`module ${name.slice(0, 30)}... has no plugins array`,
|
|
561
|
-
Object.keys(mod)
|
|
562
|
-
);
|
|
506
|
+
if (!Array.isArray(mod.plugins)) {
|
|
507
|
+
console.warn(`module ${name} has no plugins array`, Object.keys(mod));
|
|
508
|
+
return;
|
|
563
509
|
}
|
|
510
|
+
log(
|
|
511
|
+
`registering ${mod.plugins.length} plugin(s) from ${name}`,
|
|
512
|
+
mod.plugins.map((p: any) => `${p.type}:${p.id}`)
|
|
513
|
+
);
|
|
514
|
+
registerPlugins(mod.plugins, name);
|
|
564
515
|
}
|
|
565
516
|
|
|
566
517
|
/**
|
|
567
|
-
* The frame lazy-creates `moduleSettingsUrl` on first mount
|
|
568
|
-
* appear
|
|
569
|
-
* own tool bundle loads alongside the site default. Idempotent.
|
|
518
|
+
* The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
|
|
519
|
+
* appear and feed it to the ModuleWatcher.
|
|
570
520
|
*/
|
|
571
|
-
function
|
|
521
|
+
function wireModuleSettings(
|
|
572
522
|
accountDocHandle: DocHandle<AccountDoc>,
|
|
573
523
|
moduleWatcher: ModuleWatcher
|
|
574
524
|
): void {
|
|
@@ -584,88 +534,66 @@ function wireModuleSettingsWhenReady(
|
|
|
584
534
|
}
|
|
585
535
|
}
|
|
586
536
|
|
|
587
|
-
/**
|
|
588
|
-
* Set initial `tool-id` / `doc-url` attributes on the root
|
|
589
|
-
* `<patchwork-view>` based on the URL hash (if it specifies a frame
|
|
590
|
-
* override) or the account doc's configured frame tool + the account doc
|
|
591
|
-
* itself.
|
|
592
|
-
*/
|
|
593
537
|
function primeRootElement(
|
|
594
538
|
rootElement: HTMLElement,
|
|
595
539
|
accountDocHandle: DocHandle<AccountDoc>
|
|
596
540
|
): void {
|
|
597
541
|
rootElement.style.visibility = "hidden";
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
542
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
543
|
+
const frame = params.get("frame");
|
|
544
|
+
rootElement.setAttribute(
|
|
545
|
+
"tool-id",
|
|
546
|
+
frame ?? accountDocHandle.doc().frameToolId
|
|
547
|
+
);
|
|
548
|
+
rootElement.setAttribute(
|
|
549
|
+
"doc-url",
|
|
550
|
+
(frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url
|
|
551
|
+
);
|
|
609
552
|
}
|
|
610
553
|
|
|
611
|
-
|
|
612
|
-
moduleWatcher.doneLoading
|
|
613
|
-
.then(() => {
|
|
614
|
-
const toolReg = getRegistry("patchwork:tool");
|
|
615
|
-
const tools = toolReg.all();
|
|
616
|
-
log(
|
|
617
|
-
`doneLoading: ${tools.length} tools registered:`,
|
|
618
|
-
tools.map((t: any) => t.id)
|
|
619
|
-
);
|
|
620
|
-
})
|
|
621
|
-
.catch((err: unknown) => {
|
|
622
|
-
console.error("doneLoading rejected:", err);
|
|
623
|
-
});
|
|
624
|
-
}
|
|
554
|
+
// ── Loading animation ───────────────────────────────────────────────────
|
|
625
555
|
|
|
626
556
|
const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
|
|
627
557
|
const LOADING_ELEMENT_ID = "pw-bootloader-loading";
|
|
628
558
|
|
|
559
|
+
const LOADING_CSS = `
|
|
560
|
+
@keyframes pw-bootloader-pulse {
|
|
561
|
+
0%, 100% { opacity: 0.25; }
|
|
562
|
+
50% { opacity: 0.95; }
|
|
563
|
+
}
|
|
564
|
+
#${LOADING_ELEMENT_ID} {
|
|
565
|
+
position: fixed;
|
|
566
|
+
inset: 0;
|
|
567
|
+
z-index: 0;
|
|
568
|
+
pointer-events: none;
|
|
569
|
+
background-color: #fff;
|
|
570
|
+
background-image:
|
|
571
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
572
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
573
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
574
|
+
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
575
|
+
transition: opacity 0.6s ease-out;
|
|
576
|
+
}
|
|
577
|
+
@media (prefers-color-scheme: dark) {
|
|
578
|
+
#${LOADING_ELEMENT_ID} {
|
|
579
|
+
background-color: #000;
|
|
580
|
+
background-image:
|
|
581
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
582
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
583
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
587
|
+
opacity: 0;
|
|
588
|
+
animation: none;
|
|
589
|
+
}
|
|
590
|
+
`;
|
|
591
|
+
|
|
629
592
|
function showLoadingAnimation(): void {
|
|
630
593
|
if (!document.getElementById(LOADING_STYLE_ID)) {
|
|
631
594
|
const style = document.createElement("style");
|
|
632
595
|
style.id = LOADING_STYLE_ID;
|
|
633
|
-
style.textContent =
|
|
634
|
-
@keyframes pw-bootloader-pulse {
|
|
635
|
-
0%, 100% { opacity: 0.25; }
|
|
636
|
-
50% { opacity: 0.95; }
|
|
637
|
-
}
|
|
638
|
-
#${LOADING_ELEMENT_ID} {
|
|
639
|
-
position: fixed;
|
|
640
|
-
inset: 0;
|
|
641
|
-
z-index: 0;
|
|
642
|
-
pointer-events: none;
|
|
643
|
-
background-color: #fff;
|
|
644
|
-
background-image:
|
|
645
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
646
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
647
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
648
|
-
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
649
|
-
transition: opacity 0.6s ease-out;
|
|
650
|
-
top: 0;
|
|
651
|
-
left: 0;
|
|
652
|
-
right: 0;
|
|
653
|
-
bottom: 0;
|
|
654
|
-
}
|
|
655
|
-
@media (prefers-color-scheme: dark) {
|
|
656
|
-
#${LOADING_ELEMENT_ID} {
|
|
657
|
-
background-color: #000;
|
|
658
|
-
background-image:
|
|
659
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
660
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
661
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
665
|
-
opacity: 0;
|
|
666
|
-
animation: none;
|
|
667
|
-
}
|
|
668
|
-
`;
|
|
596
|
+
style.textContent = LOADING_CSS;
|
|
669
597
|
document.head.appendChild(style);
|
|
670
598
|
}
|
|
671
599
|
if (document.getElementById(LOADING_ELEMENT_ID)) return;
|
|
@@ -685,41 +613,46 @@ async function uncache(match: string): Promise<void> {
|
|
|
685
613
|
for (const name of await caches.keys()) {
|
|
686
614
|
const cache = await caches.open(name);
|
|
687
615
|
for (const request of await cache.keys()) {
|
|
688
|
-
if (request.url.includes(match))
|
|
689
|
-
cache.delete(request);
|
|
690
|
-
}
|
|
616
|
+
if (request.url.includes(match)) cache.delete(request);
|
|
691
617
|
}
|
|
692
618
|
}
|
|
693
619
|
}
|
|
694
620
|
|
|
695
|
-
//
|
|
696
|
-
|
|
621
|
+
// ── Hash routing ────────────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
624
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
625
|
+
// anchor on the `--` before the base58 document id rather than a strict slug
|
|
626
|
+
// charset.
|
|
627
|
+
const BIG_PATCHWORK_HASH_REGEX =
|
|
628
|
+
/^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
629
|
+
|
|
630
|
+
// The `doc=` value is an automerge URL, kept literal rather than
|
|
631
|
+
// percent-encoded so links stay readable.
|
|
697
632
|
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
698
|
-
//
|
|
699
|
-
//
|
|
633
|
+
// A stable order means re-serializing the same logical params is
|
|
634
|
+
// byte-identical, avoiding spurious `hashchange` round-trips.
|
|
700
635
|
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
701
636
|
|
|
702
637
|
function serializeHashParams(params: URLSearchParams): string {
|
|
703
|
-
const
|
|
638
|
+
const keys = [...HASH_KEY_ORDER, ...params.keys()];
|
|
704
639
|
const parts: string[] = [];
|
|
705
|
-
const
|
|
706
|
-
|
|
640
|
+
const emitted = new Set<string>();
|
|
641
|
+
for (const key of keys) {
|
|
642
|
+
if (emitted.has(key)) continue;
|
|
707
643
|
const value = params.get(key);
|
|
708
|
-
if (!value)
|
|
644
|
+
if (!value) continue;
|
|
709
645
|
emitted.add(key);
|
|
710
646
|
parts.push(
|
|
711
647
|
`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
|
|
712
648
|
);
|
|
713
|
-
}
|
|
714
|
-
for (const key of HASH_KEY_ORDER) emit(key);
|
|
715
|
-
for (const key of params.keys()) emit(key);
|
|
649
|
+
}
|
|
716
650
|
return parts.join("&");
|
|
717
651
|
}
|
|
718
652
|
|
|
719
653
|
/**
|
|
720
654
|
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
721
|
-
* (`automerge:<id>[#heads]`) or a bare document id for
|
|
722
|
-
* with older links.
|
|
655
|
+
* (`automerge:<id>[#heads]`) or a bare document id, for older links.
|
|
723
656
|
*/
|
|
724
657
|
function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
|
|
725
658
|
if (!docParam) return undefined;
|
|
@@ -727,10 +660,8 @@ function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
|
|
|
727
660
|
return docParam as AutomergeUrl;
|
|
728
661
|
}
|
|
729
662
|
const documentId = docParam.replace(/^automerge:/, "");
|
|
730
|
-
if (isValidDocumentId(documentId))
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
return undefined;
|
|
663
|
+
if (!isValidDocumentId(documentId)) return undefined;
|
|
664
|
+
return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
|
|
734
665
|
}
|
|
735
666
|
|
|
736
667
|
interface HashRoutingParams {
|
|
@@ -740,27 +671,84 @@ interface HashRoutingParams {
|
|
|
740
671
|
titleSuffix: string;
|
|
741
672
|
}
|
|
742
673
|
|
|
743
|
-
function installHashRouting(
|
|
744
|
-
|
|
674
|
+
function installHashRouting({
|
|
675
|
+
rootElement,
|
|
676
|
+
repo,
|
|
677
|
+
accountDocHandle,
|
|
678
|
+
titleSuffix,
|
|
679
|
+
}: HashRoutingParams): void {
|
|
680
|
+
const handleHashChange = async () => {
|
|
681
|
+
const hash = window.location.hash.slice(1);
|
|
682
|
+
|
|
683
|
+
// Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
|
|
684
|
+
// routing re-run on the resulting hashchange.
|
|
685
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
686
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
687
|
+
window.location.hash = serializeHashParams(
|
|
688
|
+
new URLSearchParams({
|
|
689
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
|
|
690
|
+
})
|
|
691
|
+
);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Bare automerge URL: /#automerge:<documentId>
|
|
696
|
+
if (isValidAutomergeUrl(hash as AutomergeUrl)) {
|
|
697
|
+
window.location.hash = "";
|
|
698
|
+
openDocument(rootElement, hash as AutomergeUrl);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const params = new URLSearchParams(hash);
|
|
703
|
+
const docUrl = docParamToUrl(params.get("doc"));
|
|
704
|
+
const frame = params.get("frame");
|
|
705
|
+
|
|
706
|
+
if (frame) {
|
|
707
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
708
|
+
if (
|
|
709
|
+
rootElement.getAttribute("tool-id") !== frame ||
|
|
710
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl
|
|
711
|
+
) {
|
|
712
|
+
rootElement.setAttribute("tool-id", frame);
|
|
713
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (docUrl) {
|
|
718
|
+
rootElement.dispatchEvent(
|
|
719
|
+
new CustomEvent("patchwork:open-document", {
|
|
720
|
+
detail: {
|
|
721
|
+
url: docUrl,
|
|
722
|
+
toolId: params.get("tool"),
|
|
723
|
+
title: params.get("title"),
|
|
724
|
+
type: params.get("type"),
|
|
725
|
+
},
|
|
726
|
+
})
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
745
730
|
|
|
746
731
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
747
|
-
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
748
732
|
const { url, toolId, type, title } = event.detail as {
|
|
749
733
|
url: AutomergeUrl;
|
|
750
734
|
toolId?: string;
|
|
751
735
|
type?: string;
|
|
752
736
|
title?: string;
|
|
753
737
|
};
|
|
754
|
-
|
|
755
|
-
|
|
738
|
+
|
|
739
|
+
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
740
|
+
// `doc` is the full automerge URL, so heads live inside it and the separate
|
|
741
|
+
// `heads=` param is gone.
|
|
756
742
|
params.delete("heads");
|
|
757
743
|
params.set("doc", url);
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
744
|
+
for (const [key, value] of [
|
|
745
|
+
["tool", toolId],
|
|
746
|
+
["title", title],
|
|
747
|
+
["type", type],
|
|
748
|
+
] as const) {
|
|
749
|
+
if (value) params.set(key, value);
|
|
750
|
+
else params.delete(key);
|
|
751
|
+
}
|
|
764
752
|
window.location.hash = serializeHashParams(params);
|
|
765
753
|
|
|
766
754
|
try {
|
|
@@ -770,90 +758,40 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
770
758
|
const doc = docHandle.doc();
|
|
771
759
|
const docType = type || doc?.["@patchwork"]?.type;
|
|
772
760
|
if (!docType) return;
|
|
773
|
-
const
|
|
774
|
-
|
|
761
|
+
const datatype =
|
|
762
|
+
await getRegistry<DatatypeDescription>("patchwork:datatype").load(
|
|
763
|
+
docType
|
|
764
|
+
);
|
|
775
765
|
if (!datatype) return;
|
|
776
766
|
const docTitle = (datatype.module as DatatypeImplementation).getTitle(
|
|
777
767
|
doc
|
|
778
768
|
);
|
|
779
|
-
if (docTitle) {
|
|
780
|
-
document.title = `${docTitle} | ${titleSuffix}`;
|
|
781
|
-
}
|
|
769
|
+
if (docTitle) document.title = `${docTitle} | ${titleSuffix}`;
|
|
782
770
|
} catch (e) {
|
|
783
771
|
console.error("Failed to update document title", e);
|
|
784
772
|
}
|
|
785
773
|
});
|
|
786
774
|
|
|
787
|
-
let
|
|
775
|
+
let revealed = false;
|
|
788
776
|
const reveal = () => {
|
|
789
|
-
if (
|
|
790
|
-
|
|
777
|
+
if (revealed) return;
|
|
778
|
+
revealed = true;
|
|
791
779
|
rootElement.style.visibility = "visible";
|
|
792
780
|
hideLoadingAnimation();
|
|
793
781
|
};
|
|
794
782
|
|
|
795
783
|
rootElement.addEventListener("patchwork:mounted", (event) => {
|
|
796
|
-
handleHashChange();
|
|
797
784
|
if (event.target !== rootElement) return;
|
|
798
|
-
|
|
785
|
+
log("root element mounted");
|
|
786
|
+
void handleHashChange();
|
|
799
787
|
reveal();
|
|
800
|
-
//
|
|
801
|
-
// get a second chance to render.
|
|
788
|
+
// Deep-links from freshly-loaded tools get a second chance to render.
|
|
802
789
|
setTimeout(handleHashChange, 1000);
|
|
803
790
|
});
|
|
804
791
|
|
|
805
|
-
//
|
|
806
|
-
//
|
|
792
|
+
// If nothing ever mounts, reveal anyway so the user sees something rather
|
|
793
|
+
// than a blank page.
|
|
807
794
|
setTimeout(reveal, 12_000);
|
|
808
795
|
|
|
809
|
-
const handleHashChange = async () => {
|
|
810
|
-
const hash = window.location.hash.slice(1);
|
|
811
|
-
|
|
812
|
-
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
813
|
-
// `--` followed by a valid document id, normalize it to the canonical
|
|
814
|
-
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
815
|
-
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
816
|
-
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
817
|
-
window.location.hash = serializeHashParams(
|
|
818
|
-
new URLSearchParams({
|
|
819
|
-
doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
|
|
820
|
-
})
|
|
821
|
-
);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
826
|
-
if (isValidAutomergeUrl(hash as AutomergeUrl)) {
|
|
827
|
-
const url = hash as AutomergeUrl;
|
|
828
|
-
window.location.hash = "";
|
|
829
|
-
openDocument(rootElement, url);
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const params = new URLSearchParams(hash);
|
|
834
|
-
const docUrl = docParamToUrl(params.get("doc"));
|
|
835
|
-
const toolId = params.get("tool");
|
|
836
|
-
const title = params.get("title");
|
|
837
|
-
const type = params.get("type");
|
|
838
|
-
const frame = params.get("frame");
|
|
839
|
-
if (frame) {
|
|
840
|
-
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
841
|
-
if (
|
|
842
|
-
rootElement.getAttribute("tool-id") !== frame ||
|
|
843
|
-
rootElement.getAttribute("doc-url") !== frameDocUrl
|
|
844
|
-
) {
|
|
845
|
-
rootElement.setAttribute("tool-id", frame);
|
|
846
|
-
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
if (docUrl) {
|
|
850
|
-
rootElement.dispatchEvent(
|
|
851
|
-
new CustomEvent("patchwork:open-document", {
|
|
852
|
-
detail: { url: docUrl, toolId, title, type },
|
|
853
|
-
})
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
|
|
858
796
|
window.addEventListener("hashchange", handleHashChange);
|
|
859
797
|
}
|