@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/dist/site.js
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 { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
|
|
15
13
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
@@ -20,195 +18,103 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
|
|
|
20
18
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
21
19
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
22
20
|
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
23
|
-
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
|
|
24
|
-
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
25
21
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
26
22
|
import { importAutomergePackageViaWorker } from "./module-loader.js";
|
|
27
23
|
import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
|
|
28
24
|
import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
|
|
29
25
|
import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
|
|
30
26
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
31
|
-
import setupServiceWorker, {
|
|
27
|
+
import setupServiceWorker, { lifecycleLog } from "./setup.js";
|
|
32
28
|
import debug from "debug";
|
|
33
29
|
const log = debug("patchwork:bootloader:site");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
const siteName = typeof __SITE_NAME__ !== "undefined"
|
|
31
|
+
? __SITE_NAME__
|
|
32
|
+
: "patchwork.inkandswitch.com";
|
|
33
|
+
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
34
|
+
// Started at module evaluation but not top-level awaited: awaiting here would
|
|
35
|
+
// hold up everything importing this module, so the loading animation couldn't
|
|
36
|
+
// appear until the biggest download of the boot had already finished.
|
|
37
|
+
const wasmFetches = Promise.all([
|
|
40
38
|
fetch("/automerge.wasm").then((r) => r.bytes()),
|
|
41
39
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
42
40
|
]);
|
|
43
|
-
|
|
44
|
-
* Boot a Patchwork browser site.
|
|
45
|
-
*
|
|
46
|
-
* Performs the full application-shell setup: service worker + port, Repo,
|
|
47
|
-
* plugin/module loading, account resolution, URL-hash routing, and dev-console
|
|
48
|
-
* globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
|
|
49
|
-
* constructed Repo, ModuleWatcher and account handle for sites that want to
|
|
50
|
-
* do additional wiring after boot.
|
|
51
|
-
*/
|
|
41
|
+
wasmFetches.catch(() => { });
|
|
52
42
|
export async function bootPatchworkSite(config) {
|
|
53
|
-
const
|
|
43
|
+
const moduleSources = resolveDefaultModules(config);
|
|
54
44
|
showLoadingAnimation();
|
|
55
|
-
log(
|
|
45
|
+
log("booting", config);
|
|
56
46
|
installLifecycleLogging();
|
|
47
|
+
const [automergeWasm, subductionWasm] = await wasmFetches;
|
|
57
48
|
await initializeWasm(automergeWasm);
|
|
58
49
|
initSubductionSync(subductionWasm);
|
|
59
|
-
log("enabling workers");
|
|
60
50
|
const sw = await setupServiceWorker();
|
|
61
51
|
if (!sw)
|
|
62
52
|
throw new Error("Failed to set up service worker");
|
|
63
53
|
log("workers ready");
|
|
64
54
|
let hive;
|
|
65
55
|
let repo;
|
|
66
|
-
let
|
|
67
|
-
// Called with a fresh port when the automerge worker dies and is recreated
|
|
68
|
-
//
|
|
56
|
+
let signerIdentity;
|
|
57
|
+
// Called with a fresh port when the automerge worker dies and is recreated.
|
|
58
|
+
// Assigned once the repo exists.
|
|
69
59
|
let onWorkerPortRenewed;
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
73
|
-
// Otherwise create our own below.
|
|
60
|
+
// An embedding context may have provided a Repo before this entry ran. Reuse
|
|
61
|
+
// it and its keyhive so we share the same documents and sync context.
|
|
74
62
|
if (window.repo) {
|
|
75
63
|
log("using existing Repo from window");
|
|
76
64
|
repo = window.repo;
|
|
77
65
|
hive = window.hive;
|
|
78
66
|
}
|
|
79
67
|
else {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// The listener is called again with a fresh port if the worker is ever
|
|
83
|
-
// recreated after dying.
|
|
84
|
-
let resolveFirstPort;
|
|
85
|
-
const firstPortPromise = new Promise((r) => {
|
|
86
|
-
resolveFirstPort = r;
|
|
87
|
-
});
|
|
88
|
-
let seenFirstPort = false;
|
|
89
|
-
log("subscribing to repo channel");
|
|
90
|
-
// Deliberately not awaited: subscribeToRepoChannel resolves only after
|
|
91
|
-
// the boot channel's port-ready handshake, which can take its full 30s
|
|
92
|
-
// timeout against a stranded worker connection. Boot should block on the
|
|
93
|
-
// first *delivered* port instead — if the boot channel stalls, worker
|
|
94
|
-
// recovery hands the listener a good port long before that timeout.
|
|
95
|
-
void sw.subscribeToRepoChannel((port) => {
|
|
96
|
-
if (!seenFirstPort) {
|
|
97
|
-
seenFirstPort = true;
|
|
98
|
-
resolveFirstPort(port);
|
|
99
|
-
}
|
|
100
|
-
else if (onWorkerPortRenewed) {
|
|
68
|
+
const workerPort = await firstRepoPort(sw, (port) => {
|
|
69
|
+
if (onWorkerPortRenewed)
|
|
101
70
|
onWorkerPortRenewed(port);
|
|
102
|
-
}
|
|
103
71
|
else {
|
|
104
72
|
console.warn("automerge worker port renewed before the repo existed; dropping it");
|
|
105
73
|
}
|
|
106
74
|
});
|
|
107
|
-
const workerPort = await firstPortPromise;
|
|
108
|
-
log("repo channel subscribed");
|
|
109
75
|
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
110
|
-
|
|
111
|
-
log("setting up keyhive");
|
|
112
|
-
initKeyhiveWasm();
|
|
113
|
-
({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
114
|
-
createRepo: (config) => new Repo(config),
|
|
115
|
-
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
116
|
-
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
117
|
-
networkAdapter: workerAdapter,
|
|
118
|
-
automaticArchiveIngestion: true,
|
|
119
|
-
cachingMode: "periodic",
|
|
120
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
121
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
122
|
-
// Defaults to "subduction".
|
|
123
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
124
|
-
repo: {
|
|
125
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
126
|
-
enableRemoteHeadsGossiping: true,
|
|
127
|
-
},
|
|
128
|
-
}));
|
|
129
|
-
log("keyhive setup complete");
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
log("creating repo");
|
|
133
|
-
// Pass an explicit signer (instead of the Repo's internal default) so we
|
|
134
|
-
// can expose tab signer identity on window.patchwork for dev inspection.
|
|
135
|
-
// The tab never connects via Subduction (no endpoints/adapters), so this
|
|
136
|
-
// id never goes on the wire.
|
|
137
|
-
const tabSigner = new MemorySigner();
|
|
138
|
-
repo = new Repo({
|
|
139
|
-
network: [workerAdapter],
|
|
140
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
141
|
-
signer: tabSigner,
|
|
142
|
-
async sharePolicy(peerId) {
|
|
143
|
-
return peerId.includes("automerge-worker");
|
|
144
|
-
},
|
|
145
|
-
enableRemoteHeadsGossiping: true,
|
|
146
|
-
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
147
|
-
});
|
|
148
|
-
tabSignerIdentity = {
|
|
149
|
-
peerId: tabSigner.peerId().toString(),
|
|
150
|
-
verifyingKey: tabSigner.verifyingKey().toHex(),
|
|
151
|
-
};
|
|
152
|
-
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
153
|
-
log("repo created");
|
|
154
|
-
}
|
|
76
|
+
({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
|
|
155
77
|
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
156
|
-
// port and drop the adapter stranded on the dead one.
|
|
157
|
-
// settled by the time this can fire (recovery needs a missed heartbeat).
|
|
78
|
+
// port and drop the adapter stranded on the dead one.
|
|
158
79
|
const bootHive = hive;
|
|
159
80
|
onWorkerPortRenewed = (port) => {
|
|
160
81
|
const fresh = new MessageChannelNetworkAdapter(port);
|
|
161
82
|
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
162
|
-
// keyhive adapter wrapped around the message channel
|
|
163
|
-
// the worker uses for its side of the pair).
|
|
83
|
+
// keyhive adapter wrapped around the message channel.
|
|
164
84
|
const registered = bootHive
|
|
165
85
|
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
166
86
|
: fresh;
|
|
167
87
|
repo.networkSubsystem.addNetworkAdapter(registered);
|
|
168
|
-
|
|
169
|
-
if (adapter === registered)
|
|
170
|
-
continue;
|
|
171
|
-
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
172
|
-
const base = adapter.networkAdapter ?? adapter;
|
|
173
|
-
if (base !== workerAdapter)
|
|
174
|
-
continue;
|
|
175
|
-
try {
|
|
176
|
-
repo.networkSubsystem.removeNetworkAdapter(adapter);
|
|
177
|
-
}
|
|
178
|
-
catch (err) {
|
|
179
|
-
console.error("failed to remove stale worker network adapter", err);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
88
|
+
removeAdapterFor(repo, workerAdapter, registered);
|
|
182
89
|
workerAdapter = fresh;
|
|
183
|
-
|
|
184
|
-
`recreated automerge worker`);
|
|
90
|
+
lifecycleLog("repo re-wired to the recreated automerge worker");
|
|
185
91
|
};
|
|
186
92
|
}
|
|
187
|
-
log("popping repo on window");
|
|
188
93
|
window.repo = repo;
|
|
189
|
-
|
|
94
|
+
window.Automerge = Automerge;
|
|
95
|
+
window.AutomergeRepo = AutomergeRepo;
|
|
96
|
+
window.getRepoChannel = sw.getRepoChannel;
|
|
97
|
+
if (hive)
|
|
98
|
+
window.hive = hive;
|
|
190
99
|
await repo.networkSubsystem.whenReady();
|
|
191
100
|
log("networkSubsystem ready");
|
|
192
|
-
|
|
193
|
-
hive.networkAdapter.syncKeyhive?.();
|
|
194
|
-
}
|
|
195
|
-
installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
|
|
101
|
+
hive?.networkAdapter?.syncKeyhive?.();
|
|
196
102
|
registerRepoProviderElement(repo);
|
|
197
|
-
const
|
|
103
|
+
const rootElementId = config.rootElementId ?? "root";
|
|
104
|
+
const rootElement = document.getElementById(rootElementId);
|
|
198
105
|
if (!rootElement) {
|
|
199
|
-
throw new Error(`bootPatchworkSite: no element with id="${
|
|
106
|
+
throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
|
|
200
107
|
}
|
|
201
108
|
// `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
|
|
202
|
-
// for any view outside a remapper
|
|
109
|
+
// for any view outside a remapper, resolving to the requested url unchanged.
|
|
203
110
|
const repoProvider = document.createElement("repo-provider");
|
|
204
111
|
rootElement.parentElement.insertBefore(repoProvider, rootElement);
|
|
205
112
|
repoProvider.appendChild(rootElement);
|
|
206
113
|
registerPatchworkViewElement({ hive, repo });
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
|
|
114
|
+
// Started with the site bundle alone so resolveAccountHandle has something to
|
|
115
|
+
// await on — the `account` datatype lives there. The user's own
|
|
116
|
+
// module-settings URL is added lazily once it appears on the account doc.
|
|
117
|
+
const moduleWatcher = new ModuleWatcher(repo, nameSources(moduleSources), onModuleLoaded, unregisterPlugins,
|
|
212
118
|
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
213
119
|
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
214
120
|
importAutomergePackageViaWorker);
|
|
@@ -216,25 +122,25 @@ export async function bootPatchworkSite(config) {
|
|
|
216
122
|
storageKey: config.accountStorageKey,
|
|
217
123
|
hive,
|
|
218
124
|
}));
|
|
219
|
-
// TODO: something we (Orion & pvh) changed in the types made this necessary
|
|
220
|
-
// fix this before merging to main!
|
|
221
125
|
window.accountDocHandle = accountDocHandle;
|
|
222
|
-
|
|
223
|
-
primeRootElement(rootElement, accountDocHandle);
|
|
224
|
-
logToolRegistryWhenLoaded(moduleWatcher);
|
|
126
|
+
window.uncache = uncache;
|
|
225
127
|
window.patchwork = {
|
|
226
128
|
repo,
|
|
227
129
|
packages: moduleWatcher,
|
|
228
130
|
plugins,
|
|
229
131
|
accountDocHandle,
|
|
230
|
-
...(
|
|
132
|
+
...(signerIdentity ? { signer: signerIdentity } : {}),
|
|
231
133
|
sw: {
|
|
232
134
|
connectClassicSync: sw.connectClassicSync,
|
|
233
135
|
subscribeToRepoChannel: sw.subscribeToRepoChannel,
|
|
234
136
|
subscribeSyncState: sw.subscribeSyncState,
|
|
235
137
|
},
|
|
236
138
|
};
|
|
237
|
-
|
|
139
|
+
wireModuleSettings(accountDocHandle, moduleWatcher);
|
|
140
|
+
primeRootElement(rootElement, accountDocHandle);
|
|
141
|
+
moduleWatcher.doneLoading.then(() => log("doneLoading, tools registered:", getRegistry("patchwork:tool")
|
|
142
|
+
.all()
|
|
143
|
+
.map((t) => t.id)), (err) => console.error("doneLoading rejected:", err));
|
|
238
144
|
installHashRouting({
|
|
239
145
|
rootElement,
|
|
240
146
|
repo,
|
|
@@ -243,110 +149,150 @@ export async function bootPatchworkSite(config) {
|
|
|
243
149
|
});
|
|
244
150
|
return { repo, moduleWatcher, accountDocHandle };
|
|
245
151
|
}
|
|
246
|
-
// ─── Internals ──────────────────────────────────────────────────────────
|
|
247
152
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
153
|
+
* Resolve with the first repo port the worker delivers, calling `onRenewed` for
|
|
154
|
+
* every later one.
|
|
155
|
+
*
|
|
156
|
+
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
157
|
+
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
158
|
+
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
159
|
+
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
160
|
+
* a good port long before that timeout.
|
|
250
161
|
*/
|
|
162
|
+
function firstRepoPort(sw, onRenewed) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
let seen = false;
|
|
165
|
+
void sw.subscribeToRepoChannel((port) => {
|
|
166
|
+
if (seen)
|
|
167
|
+
return onRenewed(port);
|
|
168
|
+
seen = true;
|
|
169
|
+
resolve(port);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function createRepo(config, workerAdapter) {
|
|
174
|
+
if (config.keyhive) {
|
|
175
|
+
log("setting up keyhive");
|
|
176
|
+
initKeyhiveWasm();
|
|
177
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
178
|
+
createRepo: (repoConfig) => new Repo(repoConfig),
|
|
179
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
180
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
181
|
+
networkAdapter: workerAdapter,
|
|
182
|
+
automaticArchiveIngestion: true,
|
|
183
|
+
cachingMode: "periodic",
|
|
184
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
185
|
+
// ARK selects the relay via `syncServer`, defaulting to "subduction".
|
|
186
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
187
|
+
repo: {
|
|
188
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
189
|
+
enableRemoteHeadsGossiping: true,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
log("keyhive setup complete");
|
|
193
|
+
return { repo, hive };
|
|
194
|
+
}
|
|
195
|
+
// An explicit signer, rather than the Repo's internal default, so the tab's
|
|
196
|
+
// identity can be exposed on window.patchwork. The tab never connects via
|
|
197
|
+
// Subduction, so this id never goes on the wire.
|
|
198
|
+
const signer = new MemorySigner();
|
|
199
|
+
const repo = new Repo({
|
|
200
|
+
network: [workerAdapter],
|
|
201
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
202
|
+
signer,
|
|
203
|
+
async sharePolicy(peerId) {
|
|
204
|
+
return peerId.includes("automerge-worker");
|
|
205
|
+
},
|
|
206
|
+
enableRemoteHeadsGossiping: true,
|
|
207
|
+
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
208
|
+
});
|
|
209
|
+
const signerIdentity = {
|
|
210
|
+
peerId: signer.peerId().toString(),
|
|
211
|
+
verifyingKey: signer.verifyingKey().toHex(),
|
|
212
|
+
};
|
|
213
|
+
log("repo created, tab subduction identity:", signerIdentity);
|
|
214
|
+
return { repo, signerIdentity };
|
|
215
|
+
}
|
|
216
|
+
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
217
|
+
function removeAdapterFor(repo, stale, keep) {
|
|
218
|
+
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
219
|
+
if (adapter === keep)
|
|
220
|
+
continue;
|
|
221
|
+
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
222
|
+
const base = adapter.networkAdapter ?? adapter;
|
|
223
|
+
if (base !== stale)
|
|
224
|
+
continue;
|
|
225
|
+
try {
|
|
226
|
+
repo.networkSubsystem.removeNetworkAdapter(adapter);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error("failed to remove stale worker network adapter", err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
251
233
|
function isValidModuleSource(source) {
|
|
252
|
-
|
|
253
|
-
return true;
|
|
254
|
-
return (source.startsWith("/") ||
|
|
255
|
-
source.startsWith("http://") ||
|
|
256
|
-
source.startsWith("https://") ||
|
|
257
|
-
source.startsWith("./"));
|
|
234
|
+
return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
|
|
258
235
|
}
|
|
259
236
|
/**
|
|
260
|
-
*
|
|
261
|
-
* `localStorage.systemPackageListURL` dev override
|
|
262
|
-
* built-in
|
|
237
|
+
* The site's default module-list sources, honouring the
|
|
238
|
+
* `localStorage.systemPackageListURL` dev override, which replaces the entire
|
|
239
|
+
* built-in bundle. `defaultToolsUrl` is the pre-rename key.
|
|
263
240
|
*/
|
|
264
241
|
function resolveDefaultModules(config) {
|
|
265
|
-
const
|
|
266
|
-
const
|
|
242
|
+
const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
243
|
+
const builtin = (Array.isArray(configured) ? configured : [configured]).filter(Boolean);
|
|
267
244
|
const storage = globalThis.localStorage;
|
|
268
|
-
// `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
|
|
269
245
|
const override = storage?.getItem("systemPackageListURL") ??
|
|
270
246
|
storage?.getItem("defaultToolsUrl");
|
|
247
|
+
if (override && isValidModuleSource(override)) {
|
|
248
|
+
console.info(`using systemPackageListURL from localStorage: ${override}`);
|
|
249
|
+
return [override];
|
|
250
|
+
}
|
|
271
251
|
if (override) {
|
|
272
|
-
|
|
273
|
-
if (!builtinList.includes(override)) {
|
|
274
|
-
console.info(`using systemPackageListURL override from localStorage: ${override}`);
|
|
275
|
-
}
|
|
276
|
-
return [override];
|
|
277
|
-
}
|
|
278
|
-
console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
|
|
252
|
+
console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}`);
|
|
279
253
|
}
|
|
280
|
-
if (
|
|
254
|
+
if (builtin.length === 0) {
|
|
281
255
|
throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
|
|
282
256
|
}
|
|
283
|
-
return
|
|
257
|
+
return builtin;
|
|
284
258
|
}
|
|
285
259
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* per-account settings doc, which has branch-override precedence).
|
|
260
|
+
* Name the sources for the ModuleWatcher. The first keeps the canonical
|
|
261
|
+
* `system` name; the rest get suffixed. None may be `user`, which is reserved
|
|
262
|
+
* for the per-account settings doc and has branch-override precedence.
|
|
290
263
|
*/
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
sources.forEach((source, index) => {
|
|
294
|
-
const name = index === 0 ? "system" : `system-${index}`;
|
|
295
|
-
map[name] = source;
|
|
296
|
-
});
|
|
297
|
-
return map;
|
|
298
|
-
}
|
|
299
|
-
function installDevConsoleGlobals(repo, hive, getRepoChannel) {
|
|
300
|
-
window.repo = repo;
|
|
301
|
-
window.Automerge = Automerge;
|
|
302
|
-
window.AutomergeRepo = AutomergeRepo;
|
|
303
|
-
if (hive) {
|
|
304
|
-
window.hive = hive;
|
|
305
|
-
}
|
|
306
|
-
window.getRepoChannel = getRepoChannel;
|
|
264
|
+
function nameSources(sources) {
|
|
265
|
+
return Object.fromEntries(sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source]));
|
|
307
266
|
}
|
|
308
|
-
/**
|
|
309
|
-
*
|
|
310
|
-
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
311
|
-
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
312
|
-
*/
|
|
267
|
+
/** Page Lifecycle and connectivity transitions, to line up against the
|
|
268
|
+
* SharedWorker's sync-socket reaps. */
|
|
313
269
|
function installLifecycleLogging() {
|
|
314
270
|
if (typeof document === "undefined")
|
|
315
271
|
return;
|
|
316
272
|
const opts = { capture: true };
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
|
|
327
|
-
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
328
|
-
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
329
|
-
window.addEventListener("pageshow", (e) => note("pageshow", { persisted: e.persisted }), opts);
|
|
330
|
-
window.addEventListener("pagehide", (e) => note("pagehide", { persisted: e.persisted }), opts);
|
|
331
|
-
window.addEventListener("online", () => note("online"), opts);
|
|
332
|
-
window.addEventListener("offline", () => note("offline"), opts);
|
|
333
|
-
note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
|
|
273
|
+
const persisted = (e) => e.persisted;
|
|
274
|
+
document.addEventListener("visibilitychange", () => lifecycleLog("visibilitychange → %s", document.visibilityState), opts);
|
|
275
|
+
document.addEventListener("freeze", () => lifecycleLog("freeze (tab suspended)"), opts);
|
|
276
|
+
document.addEventListener("resume", () => lifecycleLog("resume (tab unsuspended)"), opts);
|
|
277
|
+
window.addEventListener("pageshow", (e) => lifecycleLog("pageshow persisted=%s", persisted(e)), opts);
|
|
278
|
+
window.addEventListener("pagehide", (e) => lifecycleLog("pagehide persisted=%s", persisted(e)), opts);
|
|
279
|
+
window.addEventListener("online", () => lifecycleLog("online"), opts);
|
|
280
|
+
window.addEventListener("offline", () => lifecycleLog("offline"), opts);
|
|
281
|
+
lifecycleLog("logging installed (visibilityState=%s, hasFocus=%s)", document.visibilityState, document.hasFocus());
|
|
334
282
|
}
|
|
335
283
|
function onModuleLoaded(name, mod) {
|
|
336
|
-
if (Array.isArray(mod.plugins)) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
console.warn(`module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
|
|
284
|
+
if (!Array.isArray(mod.plugins)) {
|
|
285
|
+
console.warn(`module ${name} has no plugins array`, Object.keys(mod));
|
|
286
|
+
return;
|
|
342
287
|
}
|
|
288
|
+
log(`registering ${mod.plugins.length} plugin(s) from ${name}`, mod.plugins.map((p) => `${p.type}:${p.id}`));
|
|
289
|
+
registerPlugins(mod.plugins, name);
|
|
343
290
|
}
|
|
344
291
|
/**
|
|
345
|
-
* The frame lazy-creates `moduleSettingsUrl` on first mount
|
|
346
|
-
* appear
|
|
347
|
-
* own tool bundle loads alongside the site default. Idempotent.
|
|
292
|
+
* The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
|
|
293
|
+
* appear and feed it to the ModuleWatcher.
|
|
348
294
|
*/
|
|
349
|
-
function
|
|
295
|
+
function wireModuleSettings(accountDocHandle, moduleWatcher) {
|
|
350
296
|
const wire = () => {
|
|
351
297
|
const url = accountDocHandle.doc()?.moduleSettingsUrl;
|
|
352
298
|
if (!url)
|
|
@@ -359,78 +305,53 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
|
|
|
359
305
|
accountDocHandle.on("change", wire);
|
|
360
306
|
}
|
|
361
307
|
}
|
|
362
|
-
/**
|
|
363
|
-
* Set initial `tool-id` / `doc-url` attributes on the root
|
|
364
|
-
* `<patchwork-view>` based on the URL hash (if it specifies a frame
|
|
365
|
-
* override) or the account doc's configured frame tool + the account doc
|
|
366
|
-
* itself.
|
|
367
|
-
*/
|
|
368
308
|
function primeRootElement(rootElement, accountDocHandle) {
|
|
369
309
|
rootElement.style.visibility = "hidden";
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
rootElement.setAttribute("doc-url", docUrl);
|
|
375
|
-
}
|
|
376
|
-
else {
|
|
377
|
-
rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
|
|
378
|
-
rootElement.setAttribute("doc-url", accountDocHandle.url);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
function logToolRegistryWhenLoaded(moduleWatcher) {
|
|
382
|
-
moduleWatcher.doneLoading
|
|
383
|
-
.then(() => {
|
|
384
|
-
const toolReg = getRegistry("patchwork:tool");
|
|
385
|
-
const tools = toolReg.all();
|
|
386
|
-
log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
|
|
387
|
-
})
|
|
388
|
-
.catch((err) => {
|
|
389
|
-
console.error("doneLoading rejected:", err);
|
|
390
|
-
});
|
|
310
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
311
|
+
const frame = params.get("frame");
|
|
312
|
+
rootElement.setAttribute("tool-id", frame ?? accountDocHandle.doc().frameToolId);
|
|
313
|
+
rootElement.setAttribute("doc-url", (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url);
|
|
391
314
|
}
|
|
315
|
+
// ── Loading animation ───────────────────────────────────────────────────
|
|
392
316
|
const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
|
|
393
317
|
const LOADING_ELEMENT_ID = "pw-bootloader-loading";
|
|
318
|
+
const LOADING_CSS = `
|
|
319
|
+
@keyframes pw-bootloader-pulse {
|
|
320
|
+
0%, 100% { opacity: 0.25; }
|
|
321
|
+
50% { opacity: 0.95; }
|
|
322
|
+
}
|
|
323
|
+
#${LOADING_ELEMENT_ID} {
|
|
324
|
+
position: fixed;
|
|
325
|
+
inset: 0;
|
|
326
|
+
z-index: 0;
|
|
327
|
+
pointer-events: none;
|
|
328
|
+
background-color: #fff;
|
|
329
|
+
background-image:
|
|
330
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
331
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
332
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
333
|
+
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
334
|
+
transition: opacity 0.6s ease-out;
|
|
335
|
+
}
|
|
336
|
+
@media (prefers-color-scheme: dark) {
|
|
337
|
+
#${LOADING_ELEMENT_ID} {
|
|
338
|
+
background-color: #000;
|
|
339
|
+
background-image:
|
|
340
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
341
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
342
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
346
|
+
opacity: 0;
|
|
347
|
+
animation: none;
|
|
348
|
+
}
|
|
349
|
+
`;
|
|
394
350
|
function showLoadingAnimation() {
|
|
395
351
|
if (!document.getElementById(LOADING_STYLE_ID)) {
|
|
396
352
|
const style = document.createElement("style");
|
|
397
353
|
style.id = LOADING_STYLE_ID;
|
|
398
|
-
style.textContent =
|
|
399
|
-
@keyframes pw-bootloader-pulse {
|
|
400
|
-
0%, 100% { opacity: 0.25; }
|
|
401
|
-
50% { opacity: 0.95; }
|
|
402
|
-
}
|
|
403
|
-
#${LOADING_ELEMENT_ID} {
|
|
404
|
-
position: fixed;
|
|
405
|
-
inset: 0;
|
|
406
|
-
z-index: 0;
|
|
407
|
-
pointer-events: none;
|
|
408
|
-
background-color: #fff;
|
|
409
|
-
background-image:
|
|
410
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
411
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
412
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
413
|
-
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
414
|
-
transition: opacity 0.6s ease-out;
|
|
415
|
-
top: 0;
|
|
416
|
-
left: 0;
|
|
417
|
-
right: 0;
|
|
418
|
-
bottom: 0;
|
|
419
|
-
}
|
|
420
|
-
@media (prefers-color-scheme: dark) {
|
|
421
|
-
#${LOADING_ELEMENT_ID} {
|
|
422
|
-
background-color: #000;
|
|
423
|
-
background-image:
|
|
424
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
425
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
426
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
430
|
-
opacity: 0;
|
|
431
|
-
animation: none;
|
|
432
|
-
}
|
|
433
|
-
`;
|
|
354
|
+
style.textContent = LOADING_CSS;
|
|
434
355
|
document.head.appendChild(style);
|
|
435
356
|
}
|
|
436
357
|
if (document.getElementById(LOADING_ELEMENT_ID))
|
|
@@ -450,40 +371,41 @@ async function uncache(match) {
|
|
|
450
371
|
for (const name of await caches.keys()) {
|
|
451
372
|
const cache = await caches.open(name);
|
|
452
373
|
for (const request of await cache.keys()) {
|
|
453
|
-
if (request.url.includes(match))
|
|
374
|
+
if (request.url.includes(match))
|
|
454
375
|
cache.delete(request);
|
|
455
|
-
}
|
|
456
376
|
}
|
|
457
377
|
}
|
|
458
378
|
}
|
|
459
|
-
//
|
|
460
|
-
//
|
|
379
|
+
// ── Hash routing ────────────────────────────────────────────────────────
|
|
380
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
381
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
382
|
+
// anchor on the `--` before the base58 document id rather than a strict slug
|
|
383
|
+
// charset.
|
|
384
|
+
const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
385
|
+
// The `doc=` value is an automerge URL, kept literal rather than
|
|
386
|
+
// percent-encoded so links stay readable.
|
|
461
387
|
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
462
|
-
//
|
|
463
|
-
//
|
|
388
|
+
// A stable order means re-serializing the same logical params is
|
|
389
|
+
// byte-identical, avoiding spurious `hashchange` round-trips.
|
|
464
390
|
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
465
391
|
function serializeHashParams(params) {
|
|
466
|
-
const
|
|
392
|
+
const keys = [...HASH_KEY_ORDER, ...params.keys()];
|
|
467
393
|
const parts = [];
|
|
468
|
-
const
|
|
394
|
+
const emitted = new Set();
|
|
395
|
+
for (const key of keys) {
|
|
469
396
|
if (emitted.has(key))
|
|
470
|
-
|
|
397
|
+
continue;
|
|
471
398
|
const value = params.get(key);
|
|
472
399
|
if (!value)
|
|
473
|
-
|
|
400
|
+
continue;
|
|
474
401
|
emitted.add(key);
|
|
475
402
|
parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
|
|
476
|
-
}
|
|
477
|
-
for (const key of HASH_KEY_ORDER)
|
|
478
|
-
emit(key);
|
|
479
|
-
for (const key of params.keys())
|
|
480
|
-
emit(key);
|
|
403
|
+
}
|
|
481
404
|
return parts.join("&");
|
|
482
405
|
}
|
|
483
406
|
/**
|
|
484
407
|
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
485
|
-
* (`automerge:<id>[#heads]`) or a bare document id for
|
|
486
|
-
* with older links.
|
|
408
|
+
* (`automerge:<id>[#heads]`) or a bare document id, for older links.
|
|
487
409
|
*/
|
|
488
410
|
function docParamToUrl(docParam) {
|
|
489
411
|
if (!docParam)
|
|
@@ -492,32 +414,67 @@ function docParamToUrl(docParam) {
|
|
|
492
414
|
return docParam;
|
|
493
415
|
}
|
|
494
416
|
const documentId = docParam.replace(/^automerge:/, "");
|
|
495
|
-
if (isValidDocumentId(documentId))
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
return undefined;
|
|
417
|
+
if (!isValidDocumentId(documentId))
|
|
418
|
+
return undefined;
|
|
419
|
+
return stringifyAutomergeUrl({ documentId: documentId });
|
|
499
420
|
}
|
|
500
|
-
function installHashRouting(
|
|
501
|
-
const
|
|
421
|
+
function installHashRouting({ rootElement, repo, accountDocHandle, titleSuffix, }) {
|
|
422
|
+
const handleHashChange = async () => {
|
|
423
|
+
const hash = window.location.hash.slice(1);
|
|
424
|
+
// Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
|
|
425
|
+
// routing re-run on the resulting hashchange.
|
|
426
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
427
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
428
|
+
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
429
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
430
|
+
}));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// Bare automerge URL: /#automerge:<documentId>
|
|
434
|
+
if (isValidAutomergeUrl(hash)) {
|
|
435
|
+
window.location.hash = "";
|
|
436
|
+
openDocument(rootElement, hash);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const params = new URLSearchParams(hash);
|
|
440
|
+
const docUrl = docParamToUrl(params.get("doc"));
|
|
441
|
+
const frame = params.get("frame");
|
|
442
|
+
if (frame) {
|
|
443
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
444
|
+
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
445
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
446
|
+
rootElement.setAttribute("tool-id", frame);
|
|
447
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (docUrl) {
|
|
451
|
+
rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
|
|
452
|
+
detail: {
|
|
453
|
+
url: docUrl,
|
|
454
|
+
toolId: params.get("tool"),
|
|
455
|
+
title: params.get("title"),
|
|
456
|
+
type: params.get("type"),
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
};
|
|
502
461
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
503
|
-
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
504
462
|
const { url, toolId, type, title } = event.detail;
|
|
505
|
-
|
|
506
|
-
// the
|
|
463
|
+
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
464
|
+
// `doc` is the full automerge URL, so heads live inside it and the separate
|
|
465
|
+
// `heads=` param is gone.
|
|
507
466
|
params.delete("heads");
|
|
508
467
|
params.set("doc", url);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
else
|
|
520
|
-
params.delete("type");
|
|
468
|
+
for (const [key, value] of [
|
|
469
|
+
["tool", toolId],
|
|
470
|
+
["title", title],
|
|
471
|
+
["type", type],
|
|
472
|
+
]) {
|
|
473
|
+
if (value)
|
|
474
|
+
params.set(key, value);
|
|
475
|
+
else
|
|
476
|
+
params.delete(key);
|
|
477
|
+
}
|
|
521
478
|
window.location.hash = serializeHashParams(params);
|
|
522
479
|
try {
|
|
523
480
|
const docHandle = await repo.find(url);
|
|
@@ -525,78 +482,36 @@ function installHashRouting(params) {
|
|
|
525
482
|
const docType = type || doc?.["@patchwork"]?.type;
|
|
526
483
|
if (!docType)
|
|
527
484
|
return;
|
|
528
|
-
const
|
|
529
|
-
const datatype = await registry.load(docType);
|
|
485
|
+
const datatype = await getRegistry("patchwork:datatype").load(docType);
|
|
530
486
|
if (!datatype)
|
|
531
487
|
return;
|
|
532
488
|
const docTitle = datatype.module.getTitle(doc);
|
|
533
|
-
if (docTitle)
|
|
489
|
+
if (docTitle)
|
|
534
490
|
document.title = `${docTitle} | ${titleSuffix}`;
|
|
535
|
-
}
|
|
536
491
|
}
|
|
537
492
|
catch (e) {
|
|
538
493
|
console.error("Failed to update document title", e);
|
|
539
494
|
}
|
|
540
495
|
});
|
|
541
|
-
let
|
|
496
|
+
let revealed = false;
|
|
542
497
|
const reveal = () => {
|
|
543
|
-
if (
|
|
498
|
+
if (revealed)
|
|
544
499
|
return;
|
|
545
|
-
|
|
500
|
+
revealed = true;
|
|
546
501
|
rootElement.style.visibility = "visible";
|
|
547
502
|
hideLoadingAnimation();
|
|
548
503
|
};
|
|
549
504
|
rootElement.addEventListener("patchwork:mounted", (event) => {
|
|
550
|
-
handleHashChange();
|
|
551
505
|
if (event.target !== rootElement)
|
|
552
506
|
return;
|
|
553
|
-
|
|
507
|
+
log("root element mounted");
|
|
508
|
+
void handleHashChange();
|
|
554
509
|
reveal();
|
|
555
|
-
//
|
|
556
|
-
// get a second chance to render.
|
|
510
|
+
// Deep-links from freshly-loaded tools get a second chance to render.
|
|
557
511
|
setTimeout(handleHashChange, 1000);
|
|
558
512
|
});
|
|
559
|
-
//
|
|
560
|
-
//
|
|
513
|
+
// If nothing ever mounts, reveal anyway so the user sees something rather
|
|
514
|
+
// than a blank page.
|
|
561
515
|
setTimeout(reveal, 12_000);
|
|
562
|
-
const handleHashChange = async () => {
|
|
563
|
-
const hash = window.location.hash.slice(1);
|
|
564
|
-
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
565
|
-
// `--` followed by a valid document id, normalize it to the canonical
|
|
566
|
-
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
567
|
-
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
568
|
-
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
569
|
-
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
570
|
-
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
571
|
-
}));
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
575
|
-
if (isValidAutomergeUrl(hash)) {
|
|
576
|
-
const url = hash;
|
|
577
|
-
window.location.hash = "";
|
|
578
|
-
openDocument(rootElement, url);
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
const params = new URLSearchParams(hash);
|
|
582
|
-
const docUrl = docParamToUrl(params.get("doc"));
|
|
583
|
-
const toolId = params.get("tool");
|
|
584
|
-
const title = params.get("title");
|
|
585
|
-
const type = params.get("type");
|
|
586
|
-
const frame = params.get("frame");
|
|
587
|
-
if (frame) {
|
|
588
|
-
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
589
|
-
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
590
|
-
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
591
|
-
rootElement.setAttribute("tool-id", frame);
|
|
592
|
-
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
if (docUrl) {
|
|
596
|
-
rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
|
|
597
|
-
detail: { url: docUrl, toolId, title, type },
|
|
598
|
-
}));
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
516
|
window.addEventListener("hashchange", handleHashChange);
|
|
602
517
|
}
|