@inkandswitch/patchwork-bootloader 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/automerge-worker.js +91 -15
- package/dist/externals.js +5 -0
- package/dist/service-worker.js +76 -29
- package/dist/setup.js +66 -11
- package/dist/site.js +27 -52
- package/package.json +11 -11
- package/src/automerge-worker.ts +104 -16
- package/src/externals.ts +5 -0
- package/src/service-worker.ts +92 -32
- package/src/setup.ts +70 -10
- package/src/site.ts +35 -59
package/src/setup.ts
CHANGED
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
DEFAULT_CLASSIC_SYNC_SERVER,
|
|
10
10
|
} from "./sync-config.js";
|
|
11
11
|
import debug from "debug";
|
|
12
|
+
import {
|
|
13
|
+
donatePort,
|
|
14
|
+
isWorkerErrorMessage,
|
|
15
|
+
} from "@automerge/automerge-repo/worker-port";
|
|
12
16
|
|
|
13
17
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
14
18
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
@@ -42,6 +46,7 @@ function installServiceWorkerLogForwarding(): void {
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
49
|
+
const defaultServiceWorkerCacheName = "patchwork";
|
|
45
50
|
let nextRepoChannelId = 0;
|
|
46
51
|
|
|
47
52
|
function bumpServiceWorkerCacheVersion() {
|
|
@@ -54,19 +59,13 @@ function getServiceWorkerCacheVersion() {
|
|
|
54
59
|
return localStorage.getItem(key);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
function getOrCreateServiceWorkerCacheVersion() {
|
|
58
|
-
const existing = getServiceWorkerCacheVersion();
|
|
59
|
-
if (existing) return existing;
|
|
60
|
-
return bumpServiceWorkerCacheVersion();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
62
|
function setServiceWorkerCacheName(sw: ServiceWorker | null) {
|
|
64
63
|
if (!sw) {
|
|
65
64
|
throw new Error("no service worker!");
|
|
66
65
|
}
|
|
67
66
|
sw.postMessage({
|
|
68
67
|
type: "cachename",
|
|
69
|
-
cachename:
|
|
68
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
70
69
|
});
|
|
71
70
|
}
|
|
72
71
|
|
|
@@ -82,8 +81,10 @@ export function bumpServiceWorkerCache(
|
|
|
82
81
|
function configureServiceWorker(sw: ServiceWorker | null) {
|
|
83
82
|
if (!sw) return;
|
|
84
83
|
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
sw.postMessage({
|
|
85
|
+
type: "cachename",
|
|
86
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
87
|
+
});
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
@@ -96,9 +97,42 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
96
97
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
97
98
|
let automergeWorker: SharedWorker | undefined;
|
|
98
99
|
|
|
100
|
+
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
101
|
+
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
102
|
+
// port to the automerge worker (which requests one via its port provider).
|
|
103
|
+
// Being a SharedWorker itself, the proxy — and the donated worker↔worker
|
|
104
|
+
// port — outlives the donor tab. Emitted at /packages/... via externals.ts.
|
|
105
|
+
const SUBDUCTION_IO_WORKER_URL =
|
|
106
|
+
"/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
107
|
+
|
|
108
|
+
// Bench toggles for the subduction socket (see getSubductionEndpoints in
|
|
109
|
+
// automerge-worker.ts), passed as query params because SharedWorker scope
|
|
110
|
+
// has no localStorage — which also gives each configuration its own worker
|
|
111
|
+
// instance, so bench arms can't share state.
|
|
112
|
+
// localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
|
|
113
|
+
// localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
|
|
114
|
+
// windowFrames override
|
|
115
|
+
function workerBenchParams(): string {
|
|
116
|
+
const params = new URLSearchParams();
|
|
117
|
+
try {
|
|
118
|
+
for (const [key, param] of [
|
|
119
|
+
["patchwork:ws-mode", "ws-mode"],
|
|
120
|
+
["patchwork:ws-window", "ws-window"],
|
|
121
|
+
] as const) {
|
|
122
|
+
const value = globalThis.localStorage?.getItem(key);
|
|
123
|
+
if (value) params.set(param, value);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
127
|
+
}
|
|
128
|
+
const qs = params.toString();
|
|
129
|
+
return qs ? `?${qs}` : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
99
132
|
export function getAutomergeWorker(): SharedWorker {
|
|
100
133
|
if (!automergeWorker) {
|
|
101
|
-
|
|
134
|
+
const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
|
|
135
|
+
automergeWorker = new SharedWorker(workerUrl, {
|
|
102
136
|
name: "patchwork-automerge",
|
|
103
137
|
type: "module",
|
|
104
138
|
});
|
|
@@ -112,6 +146,21 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
112
146
|
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
113
147
|
return;
|
|
114
148
|
}
|
|
149
|
+
if (isWorkerErrorMessage(event.data)) {
|
|
150
|
+
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
151
|
+
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
152
|
+
// loudly — these otherwise only exist in chrome://inspect.
|
|
153
|
+
console.error("[subduction-io]", event.data);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.data?.type === "drift-samples") {
|
|
157
|
+
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
158
|
+
// bounded window global for the Playwright bench to harvest.
|
|
159
|
+
const sink = ((window as any).__driftSamples ??= []) as number[];
|
|
160
|
+
sink.push(...event.data.samples);
|
|
161
|
+
if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
115
164
|
if (event.data?.type !== "console") return;
|
|
116
165
|
const { level, args } = event.data;
|
|
117
166
|
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
@@ -135,6 +184,16 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
135
184
|
});
|
|
136
185
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
137
186
|
|
|
187
|
+
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
188
|
+
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
189
|
+
donatePort(automergeWorker.port, () => {
|
|
190
|
+
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
191
|
+
type: "module",
|
|
192
|
+
name: "subduction-websocket",
|
|
193
|
+
});
|
|
194
|
+
return io.port;
|
|
195
|
+
});
|
|
196
|
+
|
|
138
197
|
installWorkerDeathDetection(automergeWorker);
|
|
139
198
|
}
|
|
140
199
|
return automergeWorker;
|
|
@@ -363,6 +422,7 @@ export default async function setupServiceWorker(
|
|
|
363
422
|
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
364
423
|
// install / activate markers from the controlling worker are rendered here.
|
|
365
424
|
installServiceWorkerLogForwarding();
|
|
425
|
+
localStorage.removeItem(key);
|
|
366
426
|
|
|
367
427
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
368
428
|
|
package/src/site.ts
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
isValidAutomergeUrl,
|
|
18
18
|
isValidDocumentId,
|
|
19
19
|
MessageChannelNetworkAdapter,
|
|
20
|
-
parseAutomergeUrl,
|
|
21
20
|
Repo,
|
|
22
21
|
stringifyAutomergeUrl,
|
|
23
22
|
type AutomergeUrl,
|
|
@@ -172,9 +171,12 @@ export interface BootResult {
|
|
|
172
171
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
173
172
|
}
|
|
174
173
|
|
|
175
|
-
// Legacy big-patchwork hash shape:
|
|
174
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
175
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
176
|
+
// we anchor on the `--` before the base58 document id and allow any non-query
|
|
177
|
+
// characters ahead of it rather than a strict slug charset.
|
|
176
178
|
const BIG_PATCHWORK_HASH_REGEX =
|
|
177
|
-
|
|
179
|
+
/^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
178
180
|
|
|
179
181
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
180
182
|
fetch("/automerge.wasm?main").then((r) => r.bytes()),
|
|
@@ -355,7 +357,6 @@ export async function bootPatchworkSite(
|
|
|
355
357
|
rootElement,
|
|
356
358
|
repo,
|
|
357
359
|
accountDocHandle,
|
|
358
|
-
moduleWatcher,
|
|
359
360
|
titleSuffix: config.titleSuffix,
|
|
360
361
|
});
|
|
361
362
|
|
|
@@ -625,12 +626,12 @@ async function uncache(match: string): Promise<void> {
|
|
|
625
626
|
}
|
|
626
627
|
}
|
|
627
628
|
|
|
628
|
-
//
|
|
629
|
-
// heads) literal rather than percent-encoding
|
|
630
|
-
const RAW_HASH_KEYS = new Set(["doc"
|
|
631
|
-
// Emit hash params in a stable order so re-serializing the same logical
|
|
632
|
-
//
|
|
633
|
-
const HASH_KEY_ORDER = ["doc", "
|
|
629
|
+
// The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
|
|
630
|
+
// heads) literal rather than percent-encoding it so links stay readable.
|
|
631
|
+
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
632
|
+
// Emit hash params in a stable order so re-serializing the same logical params
|
|
633
|
+
// yields a byte-identical string (avoids spurious `hashchange` round-trips).
|
|
634
|
+
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
634
635
|
|
|
635
636
|
function serializeHashParams(params: URLSearchParams): string {
|
|
636
637
|
const emitted = new Set<string>();
|
|
@@ -640,7 +641,9 @@ function serializeHashParams(params: URLSearchParams): string {
|
|
|
640
641
|
const value = params.get(key);
|
|
641
642
|
if (!value) return;
|
|
642
643
|
emitted.add(key);
|
|
643
|
-
parts.push(
|
|
644
|
+
parts.push(
|
|
645
|
+
`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
|
|
646
|
+
);
|
|
644
647
|
};
|
|
645
648
|
for (const key of HASH_KEY_ORDER) emit(key);
|
|
646
649
|
for (const key of params.keys()) emit(key);
|
|
@@ -649,12 +652,14 @@ function serializeHashParams(params: URLSearchParams): string {
|
|
|
649
652
|
|
|
650
653
|
/**
|
|
651
654
|
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
652
|
-
* (`automerge:<id>[#heads]`) or a bare document id for backwards
|
|
653
|
-
*
|
|
655
|
+
* (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
|
|
656
|
+
* with older links.
|
|
654
657
|
*/
|
|
655
658
|
function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
|
|
656
659
|
if (!docParam) return undefined;
|
|
657
|
-
if (isValidAutomergeUrl(docParam as AutomergeUrl))
|
|
660
|
+
if (isValidAutomergeUrl(docParam as AutomergeUrl)) {
|
|
661
|
+
return docParam as AutomergeUrl;
|
|
662
|
+
}
|
|
658
663
|
const documentId = docParam.replace(/^automerge:/, "");
|
|
659
664
|
if (isValidDocumentId(documentId)) {
|
|
660
665
|
return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
|
|
@@ -666,13 +671,11 @@ interface HashRoutingParams {
|
|
|
666
671
|
rootElement: HTMLElement;
|
|
667
672
|
repo: Repo;
|
|
668
673
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
669
|
-
moduleWatcher: ModuleWatcher;
|
|
670
674
|
titleSuffix: string;
|
|
671
675
|
}
|
|
672
676
|
|
|
673
677
|
function installHashRouting(params: HashRoutingParams): void {
|
|
674
|
-
const { rootElement, repo, accountDocHandle,
|
|
675
|
-
params;
|
|
678
|
+
const { rootElement, repo, accountDocHandle, titleSuffix } = params;
|
|
676
679
|
|
|
677
680
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
678
681
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
@@ -682,8 +685,8 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
682
685
|
type?: string;
|
|
683
686
|
title?: string;
|
|
684
687
|
};
|
|
685
|
-
// `doc` is now the full automerge URL
|
|
686
|
-
//
|
|
688
|
+
// `doc` is now the full automerge URL — heads, if any, live inside it, so
|
|
689
|
+
// the separate `heads=` param is gone.
|
|
687
690
|
params.delete("heads");
|
|
688
691
|
params.set("doc", url);
|
|
689
692
|
if (toolId) params.set("tool", toolId);
|
|
@@ -695,8 +698,9 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
695
698
|
window.location.hash = serializeHashParams(params);
|
|
696
699
|
|
|
697
700
|
try {
|
|
698
|
-
const docHandle =
|
|
699
|
-
|
|
701
|
+
const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
|
|
702
|
+
url
|
|
703
|
+
);
|
|
700
704
|
const doc = docHandle.doc();
|
|
701
705
|
const docType = type || doc?.["@patchwork"]?.type;
|
|
702
706
|
if (!docType) return;
|
|
@@ -714,29 +718,6 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
714
718
|
}
|
|
715
719
|
});
|
|
716
720
|
|
|
717
|
-
// When a tool mounts we know the package actually rendering the top-level
|
|
718
|
-
// document, so record its (heads-pinned) importUrl in `package=`.
|
|
719
|
-
const recordInUsePackage = (event: Event) => {
|
|
720
|
-
const detail = (event as CustomEvent).detail;
|
|
721
|
-
if (!detail || !("url" in detail) || !detail.importUrl) return;
|
|
722
|
-
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
723
|
-
const docUrl = docParamToUrl(params.get("doc"));
|
|
724
|
-
if (!docUrl) return;
|
|
725
|
-
// Ignore nested branch / side-by-side views: only the top-level doc's
|
|
726
|
-
// package belongs in the hash.
|
|
727
|
-
if (
|
|
728
|
-
parseAutomergeUrl(docUrl).documentId !==
|
|
729
|
-
parseAutomergeUrl(detail.url as AutomergeUrl).documentId
|
|
730
|
-
) {
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
if (params.get("package") === detail.importUrl) return;
|
|
734
|
-
params.set("package", detail.importUrl);
|
|
735
|
-
// Replace rather than push: recording the in-use package shouldn't add a
|
|
736
|
-
// back-button entry (and replaceState avoids a `hashchange` round-trip).
|
|
737
|
-
history.replaceState(null, "", `#${serializeHashParams(params)}`);
|
|
738
|
-
};
|
|
739
|
-
|
|
740
721
|
let firstMount = true;
|
|
741
722
|
const reveal = () => {
|
|
742
723
|
if (!firstMount) return;
|
|
@@ -746,7 +727,6 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
746
727
|
};
|
|
747
728
|
|
|
748
729
|
rootElement.addEventListener("patchwork:mounted", (event) => {
|
|
749
|
-
recordInUsePackage(event);
|
|
750
730
|
handleHashChange();
|
|
751
731
|
if (event.target !== rootElement) return;
|
|
752
732
|
console.info("root element mounted");
|
|
@@ -762,13 +742,17 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
762
742
|
|
|
763
743
|
const handleHashChange = async () => {
|
|
764
744
|
const hash = window.location.hash.slice(1);
|
|
765
|
-
const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
|
|
766
745
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
746
|
+
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
747
|
+
// `--` followed by a valid document id, normalize it to the canonical
|
|
748
|
+
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
749
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
750
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
751
|
+
window.location.hash = serializeHashParams(
|
|
752
|
+
new URLSearchParams({
|
|
753
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
|
|
754
|
+
})
|
|
755
|
+
);
|
|
772
756
|
return;
|
|
773
757
|
}
|
|
774
758
|
|
|
@@ -782,18 +766,10 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
782
766
|
|
|
783
767
|
const params = new URLSearchParams(hash);
|
|
784
768
|
const docUrl = docParamToUrl(params.get("doc"));
|
|
785
|
-
const packageUrl = params.get("package");
|
|
786
769
|
const toolId = params.get("tool");
|
|
787
770
|
const title = params.get("title");
|
|
788
771
|
const type = params.get("type");
|
|
789
772
|
const frame = params.get("frame");
|
|
790
|
-
|
|
791
|
-
// Load the package that produced this document so its tool is available
|
|
792
|
-
// even when it isn't in the user's module settings.
|
|
793
|
-
if (packageUrl) {
|
|
794
|
-
void moduleWatcher.loadModules([packageUrl]);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
773
|
if (frame) {
|
|
798
774
|
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
799
775
|
if (
|