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