@inkandswitch/patchwork-bootloader 0.4.4 → 0.5.2

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/src/global.css ADDED
@@ -0,0 +1,30 @@
1
+ html,
2
+ body {
3
+ overscroll-behavior: none;
4
+ font-family: system-ui, sans-serif;
5
+ font-size: 16px;
6
+ height: 100%;
7
+ height: 100vh;
8
+ height: 100dvh;
9
+
10
+ @media all and (display-mode: standalone) {
11
+ height: 100lvh;
12
+ }
13
+ }
14
+
15
+ patchwork-view {
16
+ display: block;
17
+ height: 100%;
18
+ width: 100%;
19
+ contain: layout;
20
+ }
21
+
22
+ @media (prefers-reduced-motion: reduce) {
23
+ *,
24
+ *:before,
25
+ *:after {
26
+ animation-duration: 0.001s !important;
27
+ transition-duration: 0.001s !important;
28
+ animation-iteration-count: 1 !important;
29
+ }
30
+ }
@@ -365,7 +365,12 @@ async function respond(
365
365
  handoffURL: URL | undefined
366
366
  ): Promise<Response> {
367
367
  const cache = await caches.open(cachename);
368
- const cached = await cache.match(fetchEvent.request);
368
+ // A cors request (e.g. the wasm `<link rel=preload crossorigin>`) can miss
369
+ // an entry that a url-keyed lookup finds, so fall back to the bare url —
370
+ // offline boot depends on this hitting.
371
+ const cached =
372
+ (await cache.match(fetchEvent.request)) ??
373
+ (await cache.match(fetchEvent.request.url));
369
374
 
370
375
  try {
371
376
  return handoffURL
@@ -1,7 +1,12 @@
1
1
  /** localStorage key: optional override for the classic sync WebSocket URL. */
2
2
  export const CLASSIC_SYNC_SERVER_KEY = "patchworkClassicSyncServer";
3
3
 
4
- export const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
4
+ declare const __CLASSIC_SYNC_SERVER__: string;
5
+
6
+ export const DEFAULT_CLASSIC_SYNC_SERVER =
7
+ typeof __CLASSIC_SYNC_SERVER__ !== "undefined"
8
+ ? __CLASSIC_SYNC_SERVER__
9
+ : "wss://sync3.automerge.org";
5
10
 
6
11
  export function readClassicSyncServer(
7
12
  storage: Pick<Storage, "getItem"> = globalThis.localStorage
package/dist/site.d.ts DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * Browser-app boot sequence for a Patchwork site.
3
- *
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.
7
- *
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.
11
- */
12
- import { type DocHandle, Repo, type AutomergeUrl } from "@automerge/vanillajs/slim";
13
- import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
14
- import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
15
- import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
16
- import * as plugins from "@inkandswitch/patchwork-plugins";
17
- import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "./types.js";
18
- type SignerIdentity = {
19
- peerId: string;
20
- verifyingKey: string;
21
- };
22
- declare global {
23
- interface Window {
24
- accountDocHandle: DocHandle<AccountDoc>;
25
- Automerge: typeof import("@automerge/automerge");
26
- AutomergeRepo: typeof import("@automerge/automerge-repo");
27
- repo: Repo;
28
- hive?: AutomergeRepoKeyhive;
29
- getRepoChannel: () => MessagePort;
30
- patchwork: {
31
- repo: Repo;
32
- packages: ModuleWatcher;
33
- plugins: typeof plugins;
34
- accountDocHandle: DocHandle<AccountDoc>;
35
- signer?: SignerIdentity;
36
- sw: {
37
- connectClassicSync: (server?: string) => Promise<void>;
38
- subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
39
- subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
40
- };
41
- };
42
- uncache: (match: string) => Promise<void>;
43
- }
44
- }
45
- export interface SiteConfig {
46
- /**
47
- * The site's default tool bundle — the tools every user of this site gets
48
- * out of the box. Must collectively contribute at least a
49
- * `patchwork:datatype` registration for `"account"` (typically the one
50
- * supplied by `@inkandswitch/patchwork-frame`).
51
- *
52
- * Each entry is a *module-list source* and may be either:
53
- * - an Automerge module-settings doc URL (`automerge:...`), which is
54
- * live-reloaded, or
55
- * - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
56
- * static JSON manifest of the shape `{ modules: string[], branches? }`,
57
- * fetched once at boot.
58
- *
59
- * The module URLs *inside* either kind of source may themselves be Automerge
60
- * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
61
- * mixed.
62
- *
63
- * Overridable at runtime with `localStorage.systemPackageListURL`.
64
- */
65
- defaultModules?: string | string[];
66
- /**
67
- * @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
68
- * compatibility with existing sites.
69
- */
70
- defaultModulesUrl?: AutomergeUrl;
71
- /**
72
- * `localStorage` key under which this site remembers which account document
73
- * belongs to the current user. Sites sharing an origin MUST use distinct
74
- * keys so they do not clobber each other's accounts.
75
- */
76
- accountStorageKey: string;
77
- /**
78
- * Brand word appended to the document title as `"<doc> | <titleSuffix>"`
79
- * when a document is open. The separator is provided for you.
80
- */
81
- titleSuffix: string;
82
- /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
83
- rootElementId?: string;
84
- /**
85
- * Initialize keyhive for access control. The Repo then uses keyhive's network
86
- * adapter, peerId and idFactory instead of a sharePolicy.
87
- */
88
- keyhive?: boolean;
89
- }
90
- export interface BootResult {
91
- repo: Repo;
92
- moduleWatcher: ModuleWatcher;
93
- accountDocHandle: DocHandle<AccountDoc>;
94
- }
95
- export declare function bootPatchworkSite(config: SiteConfig): Promise<BootResult>;
96
- export {};
package/dist/site.js DELETED
@@ -1,517 +0,0 @@
1
- /**
2
- * Browser-app boot sequence for a Patchwork site.
3
- *
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.
7
- *
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.
11
- */
12
- import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
13
- import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
14
- import * as Automerge from "@automerge/automerge/slim";
15
- import * as AutomergeRepo from "@automerge/automerge-repo/slim";
16
- import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@automerge/automerge-repo-keyhive";
17
- // eslint-disable-next-line
18
- // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
19
- import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
- import { MemorySigner } from "@automerge/automerge-subduction/slim";
21
- import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
22
- import { importAutomergePackageViaWorker } from "./module-loader.js";
23
- import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
24
- import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
25
- import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
26
- import * as plugins from "@inkandswitch/patchwork-plugins";
27
- import setupServiceWorker, { lifecycleLog } from "./setup.js";
28
- import debug from "debug";
29
- const log = debug("patchwork:bootloader:site");
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([
38
- fetch("/automerge.wasm").then((r) => r.bytes()),
39
- fetch("/subduction.wasm").then((r) => r.bytes()),
40
- ]);
41
- wasmFetches.catch(() => { });
42
- export async function bootPatchworkSite(config) {
43
- const moduleSources = resolveDefaultModules(config);
44
- showLoadingAnimation();
45
- log("booting", config);
46
- installLifecycleLogging();
47
- const [automergeWasm, subductionWasm] = await wasmFetches;
48
- await initializeWasm(automergeWasm);
49
- initSubductionSync(subductionWasm);
50
- const sw = await setupServiceWorker();
51
- if (!sw)
52
- throw new Error("Failed to set up service worker");
53
- log("workers ready");
54
- let hive;
55
- let repo;
56
- let signerIdentity;
57
- // Called with a fresh port when the automerge worker dies and is recreated.
58
- // Assigned once the repo exists.
59
- let onWorkerPortRenewed;
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.
62
- if (window.repo) {
63
- log("using existing Repo from window");
64
- repo = window.repo;
65
- hive = window.hive;
66
- }
67
- else {
68
- const workerPort = await firstRepoPort(sw, (port) => {
69
- if (onWorkerPortRenewed)
70
- onWorkerPortRenewed(port);
71
- else {
72
- console.warn("automerge worker port renewed before the repo existed; dropping it");
73
- }
74
- });
75
- let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
76
- ({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
77
- // The worker was recreated with cold state: wire the repo onto the fresh
78
- // port and drop the adapter stranded on the dead one.
79
- const bootHive = hive;
80
- onWorkerPortRenewed = (port) => {
81
- const fresh = new MessageChannelNetworkAdapter(port);
82
- // Mirror the boot wiring: a keyhive repo talks to the worker through a
83
- // keyhive adapter wrapped around the message channel.
84
- const registered = bootHive
85
- ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
86
- : fresh;
87
- repo.networkSubsystem.addNetworkAdapter(registered);
88
- removeAdapterFor(repo, workerAdapter, registered);
89
- workerAdapter = fresh;
90
- lifecycleLog("repo re-wired to the recreated automerge worker");
91
- };
92
- }
93
- window.repo = repo;
94
- window.Automerge = Automerge;
95
- window.AutomergeRepo = AutomergeRepo;
96
- window.getRepoChannel = sw.getRepoChannel;
97
- if (hive)
98
- window.hive = hive;
99
- await repo.networkSubsystem.whenReady();
100
- log("networkSubsystem ready");
101
- hive?.networkAdapter?.syncKeyhive?.();
102
- registerRepoProviderElement(repo);
103
- const rootElementId = config.rootElementId ?? "root";
104
- const rootElement = document.getElementById(rootElementId);
105
- if (!rootElement) {
106
- throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
107
- }
108
- // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
109
- // for any view outside a remapper, resolving to the requested url unchanged.
110
- const repoProvider = document.createElement("repo-provider");
111
- rootElement.parentElement.insertBefore(repoProvider, rootElement);
112
- repoProvider.appendChild(rootElement);
113
- registerPatchworkViewElement({ hive, repo });
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,
118
- // Discover an Automerge package's plugin descriptors off the main thread;
119
- // each plugin's load() re-imports the package (at heads) on this thread.
120
- importAutomergePackageViaWorker);
121
- const accountDocHandle = (await resolveAccountHandle(repo, {
122
- storageKey: config.accountStorageKey,
123
- hive,
124
- }));
125
- window.accountDocHandle = accountDocHandle;
126
- window.uncache = uncache;
127
- window.patchwork = {
128
- repo,
129
- packages: moduleWatcher,
130
- plugins,
131
- accountDocHandle,
132
- ...(signerIdentity ? { signer: signerIdentity } : {}),
133
- sw: {
134
- connectClassicSync: sw.connectClassicSync,
135
- subscribeToRepoChannel: sw.subscribeToRepoChannel,
136
- subscribeSyncState: sw.subscribeSyncState,
137
- },
138
- };
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));
144
- installHashRouting({
145
- rootElement,
146
- repo,
147
- accountDocHandle,
148
- titleSuffix: config.titleSuffix,
149
- });
150
- return { repo, moduleWatcher, accountDocHandle };
151
- }
152
- /**
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.
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
- }
233
- function isValidModuleSource(source) {
234
- return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
235
- }
236
- /**
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.
240
- */
241
- function resolveDefaultModules(config) {
242
- const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
243
- const builtin = (Array.isArray(configured) ? configured : [configured]).filter(Boolean);
244
- const storage = globalThis.localStorage;
245
- const override = storage?.getItem("systemPackageListURL") ??
246
- storage?.getItem("defaultToolsUrl");
247
- if (override && isValidModuleSource(override)) {
248
- console.info(`using systemPackageListURL from localStorage: ${override}`);
249
- return [override];
250
- }
251
- if (override) {
252
- console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}`);
253
- }
254
- if (builtin.length === 0) {
255
- throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
256
- }
257
- return builtin;
258
- }
259
- /**
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.
263
- */
264
- function nameSources(sources) {
265
- return Object.fromEntries(sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source]));
266
- }
267
- /** Page Lifecycle and connectivity transitions, to line up against the
268
- * SharedWorker's sync-socket reaps. */
269
- function installLifecycleLogging() {
270
- if (typeof document === "undefined")
271
- return;
272
- const opts = { capture: true };
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());
282
- }
283
- function onModuleLoaded(name, mod) {
284
- if (!Array.isArray(mod.plugins)) {
285
- console.warn(`module ${name} has no plugins array`, Object.keys(mod));
286
- return;
287
- }
288
- log(`registering ${mod.plugins.length} plugin(s) from ${name}`, mod.plugins.map((p) => `${p.type}:${p.id}`));
289
- registerPlugins(mod.plugins, name);
290
- }
291
- /**
292
- * The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
293
- * appear and feed it to the ModuleWatcher.
294
- */
295
- function wireModuleSettings(accountDocHandle, moduleWatcher) {
296
- const wire = () => {
297
- const url = accountDocHandle.doc()?.moduleSettingsUrl;
298
- if (!url)
299
- return;
300
- void moduleWatcher.addUrl("user", url);
301
- accountDocHandle.off("change", wire);
302
- };
303
- wire();
304
- if (!accountDocHandle.doc()?.moduleSettingsUrl) {
305
- accountDocHandle.on("change", wire);
306
- }
307
- }
308
- function primeRootElement(rootElement, accountDocHandle) {
309
- rootElement.style.visibility = "hidden";
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);
314
- }
315
- // ── Loading animation ───────────────────────────────────────────────────
316
- const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
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
- `;
350
- function showLoadingAnimation() {
351
- if (!document.getElementById(LOADING_STYLE_ID)) {
352
- const style = document.createElement("style");
353
- style.id = LOADING_STYLE_ID;
354
- style.textContent = LOADING_CSS;
355
- document.head.appendChild(style);
356
- }
357
- if (document.getElementById(LOADING_ELEMENT_ID))
358
- return;
359
- const el = document.createElement("div");
360
- el.id = LOADING_ELEMENT_ID;
361
- document.body.appendChild(el);
362
- }
363
- function hideLoadingAnimation() {
364
- const el = document.getElementById(LOADING_ELEMENT_ID);
365
- if (!el)
366
- return;
367
- el.classList.add("pw-bootloader-fading");
368
- setTimeout(() => el.remove(), 700);
369
- }
370
- async function uncache(match) {
371
- for (const name of await caches.keys()) {
372
- const cache = await caches.open(name);
373
- for (const request of await cache.keys()) {
374
- if (request.url.includes(match))
375
- cache.delete(request);
376
- }
377
- }
378
- }
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.
387
- const RAW_HASH_KEYS = new Set(["doc"]);
388
- // A stable order means re-serializing the same logical params is
389
- // byte-identical, avoiding spurious `hashchange` round-trips.
390
- const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
391
- function serializeHashParams(params) {
392
- const keys = [...HASH_KEY_ORDER, ...params.keys()];
393
- const parts = [];
394
- const emitted = new Set();
395
- for (const key of keys) {
396
- if (emitted.has(key))
397
- continue;
398
- const value = params.get(key);
399
- if (!value)
400
- continue;
401
- emitted.add(key);
402
- parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
403
- }
404
- return parts.join("&");
405
- }
406
- /**
407
- * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
408
- * (`automerge:<id>[#heads]`) or a bare document id, for older links.
409
- */
410
- function docParamToUrl(docParam) {
411
- if (!docParam)
412
- return undefined;
413
- if (isValidAutomergeUrl(docParam)) {
414
- return docParam;
415
- }
416
- const documentId = docParam.replace(/^automerge:/, "");
417
- if (!isValidDocumentId(documentId))
418
- return undefined;
419
- return stringifyAutomergeUrl({ documentId: documentId });
420
- }
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
- };
461
- rootElement.addEventListener("patchwork:open-document", async (event) => {
462
- const { url, toolId, type, title } = event.detail;
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.
466
- params.delete("heads");
467
- params.set("doc", url);
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
- }
478
- window.location.hash = serializeHashParams(params);
479
- try {
480
- const docHandle = await repo.find(url);
481
- const doc = docHandle.doc();
482
- const docType = type || doc?.["@patchwork"]?.type;
483
- if (!docType)
484
- return;
485
- const datatype = await getRegistry("patchwork:datatype").load(docType);
486
- if (!datatype)
487
- return;
488
- const docTitle = datatype.module.getTitle(doc);
489
- if (docTitle)
490
- document.title = `${docTitle} | ${titleSuffix}`;
491
- }
492
- catch (e) {
493
- console.error("Failed to update document title", e);
494
- }
495
- });
496
- let revealed = false;
497
- const reveal = () => {
498
- if (revealed)
499
- return;
500
- revealed = true;
501
- rootElement.style.visibility = "visible";
502
- hideLoadingAnimation();
503
- };
504
- rootElement.addEventListener("patchwork:mounted", (event) => {
505
- if (event.target !== rootElement)
506
- return;
507
- log("root element mounted");
508
- void handleHashChange();
509
- reveal();
510
- // Deep-links from freshly-loaded tools get a second chance to render.
511
- setTimeout(handleHashChange, 1000);
512
- });
513
- // If nothing ever mounts, reveal anyway so the user sees something rather
514
- // than a blank page.
515
- setTimeout(reveal, 12_000);
516
- window.addEventListener("hashchange", handleHashChange);
517
- }
@@ -1,4 +0,0 @@
1
- import type { Plugin } from "vite";
2
- import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
- export declare const builtins: Record<string, string>;
4
- export declare function importmap(options?: PatchworkVitePluginOptions): Plugin;