@inkandswitch/patchwork-bootloader 0.2.8 → 0.3.1
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 +14 -0
- package/dist/automerge-worker.js +137 -40
- package/dist/externals.js +2 -2
- package/dist/module-loader-worker.d.ts +1 -0
- package/dist/module-loader-worker.js +55 -0
- package/dist/module-loader.d.ts +13 -0
- package/dist/module-loader.js +72 -0
- package/dist/service-worker.js +61 -7
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +134 -2
- package/dist/site.d.ts +24 -13
- package/dist/site.js +154 -53
- package/dist/types.d.ts +8 -0
- package/dist/types.js +6 -0
- package/dist/vite/service-worker-plugin.js +4 -0
- package/package.json +19 -15
- package/src/automerge-worker.ts +148 -49
- package/src/externals.ts +2 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +81 -7
- package/src/setup.ts +141 -7
- package/src/site.ts +212 -72
- package/src/types.ts +9 -0
- package/src/vite/service-worker-plugin.ts +4 -0
package/src/setup.ts
CHANGED
|
@@ -12,6 +12,34 @@ import debug from "debug";
|
|
|
12
12
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
13
13
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
14
14
|
|
|
15
|
+
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
16
|
+
// localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
|
|
17
|
+
const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
|
|
18
|
+
export function lifecycleLoggingEnabled(): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
|
|
21
|
+
return v !== "off" && v !== "false" && v !== "0" && v !== "no";
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The SW can't read localStorage, so it always emits [lifecycle] markers and
|
|
28
|
+
// forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
|
|
29
|
+
let swLifecycleListenerInstalled = false;
|
|
30
|
+
function installServiceWorkerLogForwarding(): void {
|
|
31
|
+
if (swLifecycleListenerInstalled) return;
|
|
32
|
+
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
|
33
|
+
swLifecycleListenerInstalled = true;
|
|
34
|
+
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
|
|
35
|
+
const data = event.data;
|
|
36
|
+
if (data?.type !== "sw-lifecycle") return;
|
|
37
|
+
if (!lifecycleLoggingEnabled()) return;
|
|
38
|
+
const fn = (console as any)[data.level] ?? console.log;
|
|
39
|
+
fn(`[service-worker] ${data.msg}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
16
44
|
let nextRepoChannelId = 0;
|
|
17
45
|
|
|
@@ -67,7 +95,7 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
67
95
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
68
96
|
let automergeWorker: SharedWorker | undefined;
|
|
69
97
|
|
|
70
|
-
function getAutomergeWorker(): SharedWorker {
|
|
98
|
+
export function getAutomergeWorker(): SharedWorker {
|
|
71
99
|
if (!automergeWorker) {
|
|
72
100
|
automergeWorker = new SharedWorker(automergeWorkerPath, {
|
|
73
101
|
name: "patchwork-automerge",
|
|
@@ -76,11 +104,107 @@ function getAutomergeWorker(): SharedWorker {
|
|
|
76
104
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
77
105
|
// start() — we listen with addEventListener, not onmessage.
|
|
78
106
|
automergeWorker.port.start();
|
|
107
|
+
// Surface the SharedWorker's console output and uncaught errors in this
|
|
108
|
+
// tab's console (it has its own console that's awkward to find otherwise).
|
|
109
|
+
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
110
|
+
if (event.data?.type !== "console") return;
|
|
111
|
+
const { level, args } = event.data;
|
|
112
|
+
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
113
|
+
if (
|
|
114
|
+
!lifecycleLoggingEnabled() &&
|
|
115
|
+
typeof args?.[0] === "string" &&
|
|
116
|
+
args[0].includes("[lifecycle]")
|
|
117
|
+
) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const fn = (console as any)[level] ?? console.log;
|
|
121
|
+
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
122
|
+
// format directives in args[0] with CSS in the following args. Prefix
|
|
123
|
+
// the tag into the format string rather than as a separate positional,
|
|
124
|
+
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
125
|
+
if (typeof args[0] === "string") {
|
|
126
|
+
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
127
|
+
} else {
|
|
128
|
+
fn("[automerge-worker]", ...args);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
79
131
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
132
|
+
|
|
133
|
+
installWorkerDeathDetection(automergeWorker);
|
|
80
134
|
}
|
|
81
135
|
return automergeWorker;
|
|
82
136
|
}
|
|
83
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Detect when the automerge SharedWorker dies or restarts: control-port close,
|
|
140
|
+
* worker error, changed instance id, or an unanswered heartbeat while the tab
|
|
141
|
+
* is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
|
|
142
|
+
*/
|
|
143
|
+
function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
144
|
+
const stamp = () => new Date().toISOString();
|
|
145
|
+
const warn = (msg: string) => {
|
|
146
|
+
if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
147
|
+
};
|
|
148
|
+
const info = (msg: string) => {
|
|
149
|
+
if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
let instanceId: string | undefined;
|
|
153
|
+
let lastPongAt = Date.now();
|
|
154
|
+
let warnedUnresponsive = false;
|
|
155
|
+
|
|
156
|
+
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
157
|
+
const data = event.data;
|
|
158
|
+
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
159
|
+
if (data.type === "pong") {
|
|
160
|
+
lastPongAt = Date.now();
|
|
161
|
+
warnedUnresponsive = false;
|
|
162
|
+
}
|
|
163
|
+
if (instanceId === undefined) {
|
|
164
|
+
instanceId = data.instanceId;
|
|
165
|
+
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
166
|
+
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
167
|
+
warn(
|
|
168
|
+
`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
|
|
169
|
+
`was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`
|
|
170
|
+
);
|
|
171
|
+
instanceId = data.instanceId;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Fires when the SharedWorker is destroyed (where supported).
|
|
176
|
+
worker.port.addEventListener("close", () => {
|
|
177
|
+
warn("automerge SharedWorker control port CLOSED — worker terminated");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
worker.addEventListener("error", event => {
|
|
181
|
+
warn(`automerge SharedWorker error: ${(event as ErrorEvent).message || event}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// A missed pong while the tab is visible means the worker likely died (an
|
|
185
|
+
// active tab keeps it alive); a miss while hidden is more likely suspension.
|
|
186
|
+
const HEARTBEAT_MS = 10_000;
|
|
187
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
188
|
+
let seq = 0;
|
|
189
|
+
setInterval(() => {
|
|
190
|
+
try {
|
|
191
|
+
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
192
|
+
} catch {
|
|
193
|
+
// Port already torn down — the "close" handler covers that case.
|
|
194
|
+
}
|
|
195
|
+
const silentMs = Date.now() - lastPongAt;
|
|
196
|
+
const visible =
|
|
197
|
+
typeof document === "undefined" || document.visibilityState === "visible";
|
|
198
|
+
if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
|
|
199
|
+
warnedUnresponsive = true;
|
|
200
|
+
warn(
|
|
201
|
+
`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
|
|
202
|
+
`while tab visible — likely died/crashed`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}, HEARTBEAT_MS);
|
|
206
|
+
}
|
|
207
|
+
|
|
84
208
|
export function connectClassicSync(
|
|
85
209
|
server: string = readClassicSyncServer()
|
|
86
210
|
): Promise<void> {
|
|
@@ -104,11 +228,7 @@ export function connectClassicSync(
|
|
|
104
228
|
if (event.data?.type === "connect-classic-sync-ready") {
|
|
105
229
|
resolve();
|
|
106
230
|
} else {
|
|
107
|
-
reject(
|
|
108
|
-
new Error(
|
|
109
|
-
event.data?.error ?? "connect-classic-sync failed"
|
|
110
|
-
)
|
|
111
|
-
);
|
|
231
|
+
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
112
232
|
}
|
|
113
233
|
};
|
|
114
234
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
@@ -188,11 +308,16 @@ function getRepoChannel(): MessagePort {
|
|
|
188
308
|
export default async function setupServiceWorker(
|
|
189
309
|
options?: SetupServiceWorkerOptions
|
|
190
310
|
): Promise<SetupServiceWorkerResult> {
|
|
311
|
+
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
312
|
+
// install / activate markers from the controlling worker are rendered here.
|
|
313
|
+
installServiceWorkerLogForwarding();
|
|
314
|
+
|
|
191
315
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
192
316
|
|
|
193
317
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
194
318
|
// service worker installs.
|
|
195
|
-
getAutomergeWorker();
|
|
319
|
+
const shared = getAutomergeWorker();
|
|
320
|
+
// todo delete
|
|
196
321
|
|
|
197
322
|
const path = options?.path ?? "/service-worker.js";
|
|
198
323
|
// No controller at this point means the page loaded without a service
|
|
@@ -230,7 +355,16 @@ export default async function setupServiceWorker(
|
|
|
230
355
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
231
356
|
);
|
|
232
357
|
|
|
358
|
+
// todon't
|
|
359
|
+
(window as any).killsw = () => {
|
|
360
|
+
if (automergeWorker) {
|
|
361
|
+
automergeWorker.port.close();
|
|
362
|
+
automergeWorker = undefined;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
233
366
|
return {
|
|
367
|
+
shared,
|
|
234
368
|
connectClassicSync,
|
|
235
369
|
getRepoChannel,
|
|
236
370
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
package/src/site.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import {
|
|
15
15
|
type DocHandle,
|
|
16
|
-
IndexedDBStorageAdapter,
|
|
17
16
|
initializeWasm,
|
|
18
17
|
isValidAutomergeUrl,
|
|
19
18
|
isValidDocumentId,
|
|
@@ -23,14 +22,14 @@ import {
|
|
|
23
22
|
stringifyAutomergeUrl,
|
|
24
23
|
type AutomergeUrl,
|
|
25
24
|
type DocumentId,
|
|
26
|
-
type StorageId,
|
|
27
25
|
type UrlHeads,
|
|
28
26
|
} from "@automerge/vanillajs/slim";
|
|
27
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
29
28
|
import * as Automerge from "@automerge/automerge/slim";
|
|
30
29
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
31
30
|
import {
|
|
32
31
|
initKeyhiveWasm,
|
|
33
|
-
|
|
32
|
+
initializeAutomergeRepoKeyhiveWithRepo,
|
|
34
33
|
type AutomergeRepoKeyhive,
|
|
35
34
|
} from "@automerge/automerge-repo-keyhive";
|
|
36
35
|
// eslint-disable-next-line
|
|
@@ -41,7 +40,16 @@ declare const __SITE_NAME__: string;
|
|
|
41
40
|
const siteName =
|
|
42
41
|
typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
|
|
43
42
|
|
|
43
|
+
// Sync-server selection for keyhive. Defaults to "subduction". Build with
|
|
44
|
+
// KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
|
|
45
|
+
// the automerge-worker (SharedWorker) selection so the tab and the SW grant relay
|
|
46
|
+
// access to the same server.
|
|
47
|
+
declare const __KEYHIVE_SYNC_SERVER__: boolean;
|
|
48
|
+
const useKeyhiveSyncServer =
|
|
49
|
+
typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
50
|
+
|
|
44
51
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
52
|
+
import { importAutomergeModuleViaWorker } from "./module-loader.js";
|
|
45
53
|
import {
|
|
46
54
|
openDocument,
|
|
47
55
|
registerPatchworkViewElement,
|
|
@@ -58,7 +66,10 @@ import {
|
|
|
58
66
|
} from "@inkandswitch/patchwork-plugins";
|
|
59
67
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
60
68
|
|
|
61
|
-
import setupServiceWorker
|
|
69
|
+
import setupServiceWorker, {
|
|
70
|
+
getAutomergeWorker,
|
|
71
|
+
lifecycleLoggingEnabled,
|
|
72
|
+
} from "./setup.js";
|
|
62
73
|
import type { ServiceWorkerRepoChannelListener } from "./types.js";
|
|
63
74
|
import debug from "debug";
|
|
64
75
|
const log = debug("patchwork:bootloader:site");
|
|
@@ -89,16 +100,33 @@ declare global {
|
|
|
89
100
|
|
|
90
101
|
export interface SiteConfig {
|
|
91
102
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
103
|
+
* The site's default tool bundle — the tools every user of this site gets
|
|
104
|
+
* out of the box. Must collectively contribute at least a
|
|
105
|
+
* `patchwork:datatype` registration for `"account"` (typically the one
|
|
106
|
+
* supplied by `@inkandswitch/patchwork-frame`).
|
|
107
|
+
*
|
|
108
|
+
* Each entry is a *module-list source* and may be either:
|
|
109
|
+
* - an Automerge module-settings doc URL (`automerge:...`), which is
|
|
110
|
+
* live-reloaded, or
|
|
111
|
+
* - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
|
|
112
|
+
* static JSON manifest of the shape `{ modules: string[], branches? }`,
|
|
113
|
+
* fetched once at boot.
|
|
114
|
+
*
|
|
115
|
+
* The module URLs *inside* either kind of source may themselves be Automerge
|
|
116
|
+
* folder docs or plain HTTP(S) bundles, so deployment targets can be freely
|
|
117
|
+
* mixed.
|
|
96
118
|
*
|
|
97
119
|
* Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
|
|
98
|
-
* another automerge
|
|
99
|
-
* unpublished tool set.
|
|
120
|
+
* another `automerge:` URL or manifest URL — useful for local development
|
|
121
|
+
* against an unpublished tool set.
|
|
122
|
+
*/
|
|
123
|
+
defaultModules?: string | string[];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
|
|
127
|
+
* compatibility with existing sites.
|
|
100
128
|
*/
|
|
101
|
-
defaultModulesUrl
|
|
129
|
+
defaultModulesUrl?: AutomergeUrl;
|
|
102
130
|
|
|
103
131
|
/**
|
|
104
132
|
* `localStorage` key under which this site remembers which account document
|
|
@@ -119,12 +147,6 @@ export interface SiteConfig {
|
|
|
119
147
|
*/
|
|
120
148
|
rootElementId?: string;
|
|
121
149
|
|
|
122
|
-
/**
|
|
123
|
-
* Storage IDs to subscribe to for remote-heads gossiping. Defaults to
|
|
124
|
-
* Ink & Switch's production Subduction storage.
|
|
125
|
-
*/
|
|
126
|
-
remoteStorageIds?: StorageId[];
|
|
127
|
-
|
|
128
150
|
/**
|
|
129
151
|
* When true, initialize keyhive for access control.
|
|
130
152
|
* The Repo will use keyhive's network adapter, peerId, and idFactory
|
|
@@ -139,9 +161,6 @@ export interface BootResult {
|
|
|
139
161
|
accountDocHandle: DocHandle<AccountDoc>;
|
|
140
162
|
}
|
|
141
163
|
|
|
142
|
-
const DEFAULT_REMOTE_STORAGE_ID =
|
|
143
|
-
"3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
|
|
144
|
-
|
|
145
164
|
// Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
|
|
146
165
|
const BIG_PATCHWORK_HASH_REGEX =
|
|
147
166
|
/(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
|
|
@@ -163,49 +182,67 @@ const [automergeWasm, subductionWasm] = await Promise.all([
|
|
|
163
182
|
export async function bootPatchworkSite(
|
|
164
183
|
config: SiteConfig
|
|
165
184
|
): Promise<BootResult> {
|
|
166
|
-
const
|
|
185
|
+
const defaultModuleSources = resolveDefaultModules(config);
|
|
167
186
|
showLoadingAnimation();
|
|
168
187
|
log(`booting`, config);
|
|
188
|
+
installLifecycleLogging();
|
|
169
189
|
await initializeWasm(automergeWasm);
|
|
170
190
|
initSubductionSync(subductionWasm);
|
|
171
191
|
|
|
192
|
+
log("enabling workers");
|
|
172
193
|
const sw = await setupServiceWorker();
|
|
173
194
|
if (!sw) throw new Error("Failed to set up service worker");
|
|
195
|
+
log("workers ready");
|
|
174
196
|
|
|
175
197
|
let hive: AutomergeRepoKeyhive | undefined;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
automaticArchiveIngestion: true,
|
|
193
|
-
cachingMode: "periodic",
|
|
194
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
198
|
+
let repo: Repo;
|
|
199
|
+
|
|
200
|
+
// If a Repo is already on `window` — an embedding context provided one before
|
|
201
|
+
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
202
|
+
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
203
|
+
// Otherwise create our own below.
|
|
204
|
+
if (window.repo) {
|
|
205
|
+
log("using existing Repo from window");
|
|
206
|
+
repo = window.repo;
|
|
207
|
+
hive = window.hive;
|
|
208
|
+
} else {
|
|
209
|
+
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
210
|
+
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
211
|
+
let resolvePort!: (port: MessagePort) => void;
|
|
212
|
+
const portPromise = new Promise<MessagePort>((r) => {
|
|
213
|
+
resolvePort = r;
|
|
195
214
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
215
|
+
log("subscribing to repo channel");
|
|
216
|
+
await sw.subscribeToRepoChannel(resolvePort);
|
|
217
|
+
log("repo channel subscribed");
|
|
218
|
+
const workerPort = await portPromise;
|
|
219
|
+
|
|
220
|
+
if (config.keyhive) {
|
|
221
|
+
log("setting up keyhive");
|
|
222
|
+
initKeyhiveWasm();
|
|
223
|
+
|
|
224
|
+
({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
225
|
+
createRepo: (config) => new Repo(config),
|
|
226
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
227
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
228
|
+
networkAdapter: new MessageChannelNetworkAdapter(workerPort),
|
|
229
|
+
automaticArchiveIngestion: true,
|
|
230
|
+
cachingMode: "periodic",
|
|
231
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
232
|
+
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
233
|
+
// Defaults to "subduction".
|
|
234
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
235
|
+
repo: {
|
|
236
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
237
|
+
enableRemoteHeadsGossiping: true,
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
log("keyhive setup complete");
|
|
241
|
+
} else {
|
|
242
|
+
log("creating repo");
|
|
243
|
+
repo = new Repo({
|
|
207
244
|
network: [new MessageChannelNetworkAdapter(workerPort)],
|
|
208
|
-
storage: new
|
|
245
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
209
246
|
async sharePolicy(peerId) {
|
|
210
247
|
return peerId.includes("automerge-worker");
|
|
211
248
|
},
|
|
@@ -213,11 +250,16 @@ export async function bootPatchworkSite(
|
|
|
213
250
|
peerId:
|
|
214
251
|
`${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
215
252
|
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
253
|
+
log("repo created");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
log("popping repo on window");
|
|
257
|
+
window.repo = repo;
|
|
258
|
+
log("await repo.networkSubsystem.whenReady()");
|
|
219
259
|
|
|
220
260
|
await repo.networkSubsystem.whenReady();
|
|
261
|
+
log("networkSubsystem ready");
|
|
262
|
+
|
|
221
263
|
if (hive) {
|
|
222
264
|
(hive.networkAdapter as any).syncKeyhive?.();
|
|
223
265
|
}
|
|
@@ -246,9 +288,12 @@ export async function bootPatchworkSite(
|
|
|
246
288
|
// is added lazily once it appears on the account doc — see below.
|
|
247
289
|
const moduleWatcher = new ModuleWatcher(
|
|
248
290
|
repo,
|
|
249
|
-
|
|
291
|
+
buildSystemSources(defaultModuleSources),
|
|
250
292
|
onModuleLoaded,
|
|
251
|
-
unregisterPlugins
|
|
293
|
+
unregisterPlugins,
|
|
294
|
+
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
295
|
+
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
296
|
+
importAutomergeModuleViaWorker
|
|
252
297
|
);
|
|
253
298
|
|
|
254
299
|
const accountDocHandle = (await resolveAccountHandle(repo, {
|
|
@@ -290,21 +335,67 @@ export async function bootPatchworkSite(
|
|
|
290
335
|
|
|
291
336
|
// ─── Internals ──────────────────────────────────────────────────────────
|
|
292
337
|
|
|
293
|
-
|
|
338
|
+
/**
|
|
339
|
+
* A module-list source is valid if it is an Automerge URL or looks like an
|
|
340
|
+
* HTTP(S)/site-relative manifest URL.
|
|
341
|
+
*/
|
|
342
|
+
function isValidModuleSource(source: string): boolean {
|
|
343
|
+
if (isValidAutomergeUrl(source)) return true;
|
|
344
|
+
return (
|
|
345
|
+
source.startsWith("/") ||
|
|
346
|
+
source.startsWith("http://") ||
|
|
347
|
+
source.startsWith("https://") ||
|
|
348
|
+
source.startsWith("./")
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve the site's default module-list sources, honouring the
|
|
354
|
+
* `localStorage.defaultToolsUrl` dev override (which replaces the entire
|
|
355
|
+
* built-in default bundle).
|
|
356
|
+
*/
|
|
357
|
+
function resolveDefaultModules(config: SiteConfig): string[] {
|
|
358
|
+
const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
359
|
+
const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
|
|
360
|
+
Boolean
|
|
361
|
+
);
|
|
362
|
+
|
|
294
363
|
const override = globalThis.localStorage?.getItem("defaultToolsUrl");
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
364
|
+
if (override) {
|
|
365
|
+
if (isValidModuleSource(override)) {
|
|
366
|
+
if (!builtinList.includes(override)) {
|
|
367
|
+
console.info(
|
|
368
|
+
`using defaultToolsUrl override from localStorage: ${override}`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return [override];
|
|
301
372
|
}
|
|
302
|
-
|
|
373
|
+
console.warn(
|
|
374
|
+
`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
|
|
375
|
+
);
|
|
303
376
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
377
|
+
|
|
378
|
+
if (builtinList.length === 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
"bootPatchworkSite: no default module sources configured (set `defaultModules`)"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return builtinList;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Turn an ordered list of module-list sources into the name-keyed map the
|
|
388
|
+
* ModuleWatcher expects. The first source keeps the canonical `system` name;
|
|
389
|
+
* additional sources get suffixed names. None may be `user` (reserved for the
|
|
390
|
+
* per-account settings doc, which has branch-override precedence).
|
|
391
|
+
*/
|
|
392
|
+
function buildSystemSources(sources: string[]): Record<string, string> {
|
|
393
|
+
const map: Record<string, string> = {};
|
|
394
|
+
sources.forEach((source, index) => {
|
|
395
|
+
const name = index === 0 ? "system" : `system-${index}`;
|
|
396
|
+
map[name] = source;
|
|
397
|
+
});
|
|
398
|
+
return map;
|
|
308
399
|
}
|
|
309
400
|
|
|
310
401
|
function installDevConsoleGlobals(
|
|
@@ -321,6 +412,46 @@ function installDevConsoleGlobals(
|
|
|
321
412
|
window.getRepoChannel = getRepoChannel;
|
|
322
413
|
}
|
|
323
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Log this tab's Page Lifecycle + connectivity transitions (visibility,
|
|
417
|
+
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
418
|
+
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
419
|
+
*/
|
|
420
|
+
function installLifecycleLogging(): void {
|
|
421
|
+
if (typeof document === "undefined") return;
|
|
422
|
+
const opts = { capture: true } as const;
|
|
423
|
+
const note = (label: string, extra?: unknown) => {
|
|
424
|
+
if (!lifecycleLoggingEnabled()) return;
|
|
425
|
+
const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
|
|
426
|
+
if (extra === undefined) console.info(msg);
|
|
427
|
+
else console.info(msg, extra);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
document.addEventListener(
|
|
431
|
+
"visibilitychange",
|
|
432
|
+
() => note(`visibilitychange → ${document.visibilityState}`),
|
|
433
|
+
opts
|
|
434
|
+
);
|
|
435
|
+
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
436
|
+
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
437
|
+
window.addEventListener(
|
|
438
|
+
"pageshow",
|
|
439
|
+
e => note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
|
|
440
|
+
opts
|
|
441
|
+
);
|
|
442
|
+
window.addEventListener(
|
|
443
|
+
"pagehide",
|
|
444
|
+
e => note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
|
|
445
|
+
opts
|
|
446
|
+
);
|
|
447
|
+
window.addEventListener("online", () => note("online"), opts);
|
|
448
|
+
window.addEventListener("offline", () => note("offline"), opts);
|
|
449
|
+
|
|
450
|
+
note(
|
|
451
|
+
`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
324
455
|
function onModuleLoaded(name: string, mod: any): void {
|
|
325
456
|
if (Array.isArray(mod.plugins)) {
|
|
326
457
|
log(
|
|
@@ -372,7 +503,7 @@ function primeRootElement(
|
|
|
372
503
|
const initialParams = new URLSearchParams(location.hash.slice(1));
|
|
373
504
|
if (initialParams.has("frame")) {
|
|
374
505
|
rootElement.setAttribute("tool-id", initialParams.get("frame")!);
|
|
375
|
-
const docId = initialParams.get("doc");
|
|
506
|
+
const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
|
|
376
507
|
const docUrl = docId
|
|
377
508
|
? stringifyAutomergeUrl({ documentId: docId as DocumentId })
|
|
378
509
|
: accountDocHandle.url;
|
|
@@ -558,15 +689,24 @@ function installHashRouting(params: HashRoutingParams): void {
|
|
|
558
689
|
return;
|
|
559
690
|
}
|
|
560
691
|
|
|
692
|
+
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
693
|
+
if (isValidAutomergeUrl(hash as AutomergeUrl)) {
|
|
694
|
+
const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
|
|
695
|
+
window.location.hash = "";
|
|
696
|
+
openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
561
700
|
const params = new URLSearchParams(hash);
|
|
562
|
-
const documentId = params.get("doc");
|
|
701
|
+
const documentId = params.get("doc")?.replace(/^automerge:/, "");
|
|
563
702
|
const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
|
|
564
703
|
const toolId = params.get("tool");
|
|
565
704
|
const title = params.get("title");
|
|
566
705
|
const type = params.get("type");
|
|
567
706
|
const frame = params.get("frame");
|
|
568
707
|
if (frame) {
|
|
569
|
-
const docUrl =
|
|
708
|
+
const docUrl =
|
|
709
|
+
params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
|
|
570
710
|
if (
|
|
571
711
|
rootElement.getAttribute("tool-id") !== frame ||
|
|
572
712
|
rootElement.getAttribute("doc-url") !== docUrl
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const HANDOFF_CHANNEL = "@patchwork/handoff";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* BroadcastChannel on which the automerge shared worker announces remote
|
|
11
|
+
* heads it learns about from the sync server. Any tab can listen to stay
|
|
12
|
+
* informed of sync progress without repo-to-repo gossiping.
|
|
13
|
+
*/
|
|
14
|
+
export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* The special URL to resolve, plus enough of the {@link Request} the service
|
|
11
18
|
* worker is holding that the automerge worker can construct one that
|
|
@@ -105,6 +112,8 @@ export type ServiceWorkerRepoChannelListener = (
|
|
|
105
112
|
) => void | Promise<void>;
|
|
106
113
|
|
|
107
114
|
export type SetupServiceWorkerResult = {
|
|
115
|
+
shared?: SharedWorker;
|
|
116
|
+
kill?: () => void;
|
|
108
117
|
/** Open a classic Automerge sync WebSocket from the automerge worker. */
|
|
109
118
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
110
119
|
subscribeToRepoChannel: (
|
|
@@ -14,6 +14,10 @@ const workers = [
|
|
|
14
14
|
specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
|
|
15
15
|
fileName: "automerge-worker.js",
|
|
16
16
|
},
|
|
17
|
+
{
|
|
18
|
+
specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
|
|
19
|
+
fileName: "module-loader-worker.js",
|
|
20
|
+
},
|
|
17
21
|
];
|
|
18
22
|
|
|
19
23
|
export function serviceworker(): Plugin {
|