@inkandswitch/patchwork-bootloader 0.3.0 → 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 +124 -8
- package/dist/externals.js +1 -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 +58 -6
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +134 -2
- package/dist/site.d.ts +1 -6
- package/dist/site.js +94 -42
- 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 +18 -14
- package/src/automerge-worker.ts +135 -9
- package/src/externals.ts +1 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +78 -6
- package/src/setup.ts +141 -7
- package/src/site.ts +116 -62
- package/src/types.ts +9 -0
- package/src/vite/service-worker-plugin.ts +4 -0
package/dist/site.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* site's `main.ts`. Non-UI consumers should import the package default (which
|
|
12
12
|
* only does SW registration and the automerge-worker handoff).
|
|
13
13
|
*/
|
|
14
|
-
import { type DocHandle, Repo, type AutomergeUrl
|
|
14
|
+
import { type DocHandle, Repo, type AutomergeUrl } from "@automerge/vanillajs/slim";
|
|
15
15
|
import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
16
16
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
17
17
|
import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
|
|
@@ -82,11 +82,6 @@ export interface SiteConfig {
|
|
|
82
82
|
* Defaults to `"root"`.
|
|
83
83
|
*/
|
|
84
84
|
rootElementId?: string;
|
|
85
|
-
/**
|
|
86
|
-
* Storage IDs to subscribe to for remote-heads gossiping. Defaults to
|
|
87
|
-
* Ink & Switch's production Subduction storage.
|
|
88
|
-
*/
|
|
89
|
-
remoteStorageIds?: StorageId[];
|
|
90
85
|
/**
|
|
91
86
|
* When true, initialize keyhive for access control.
|
|
92
87
|
* The Repo will use keyhive's network adapter, peerId, and idFactory
|
package/dist/site.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* site's `main.ts`. Non-UI consumers should import the package default (which
|
|
12
12
|
* only does SW registration and the automerge-worker handoff).
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
|
|
15
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
15
16
|
import * as Automerge from "@automerge/automerge/slim";
|
|
16
17
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
17
18
|
import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@automerge/automerge-repo-keyhive";
|
|
@@ -21,14 +22,14 @@ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/
|
|
|
21
22
|
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
|
|
22
23
|
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
23
24
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
25
|
+
import { importAutomergeModuleViaWorker } from "./module-loader.js";
|
|
24
26
|
import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
|
|
25
27
|
import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
|
|
26
28
|
import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
|
|
27
29
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
28
|
-
import setupServiceWorker from "./setup.js";
|
|
30
|
+
import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
|
|
29
31
|
import debug from "debug";
|
|
30
32
|
const log = debug("patchwork:bootloader:site");
|
|
31
|
-
const DEFAULT_REMOTE_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d";
|
|
32
33
|
// Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
|
|
33
34
|
const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
|
|
34
35
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
@@ -48,53 +49,76 @@ export async function bootPatchworkSite(config) {
|
|
|
48
49
|
const defaultModuleSources = resolveDefaultModules(config);
|
|
49
50
|
showLoadingAnimation();
|
|
50
51
|
log(`booting`, config);
|
|
52
|
+
installLifecycleLogging();
|
|
51
53
|
await initializeWasm(automergeWasm);
|
|
52
54
|
initSubductionSync(subductionWasm);
|
|
55
|
+
log("enabling workers");
|
|
53
56
|
const sw = await setupServiceWorker();
|
|
54
57
|
if (!sw)
|
|
55
58
|
throw new Error("Failed to set up service worker");
|
|
59
|
+
log("workers ready");
|
|
56
60
|
let hive;
|
|
57
|
-
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
58
|
-
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
59
|
-
let resolvePort;
|
|
60
|
-
const portPromise = new Promise((r) => {
|
|
61
|
-
resolvePort = r;
|
|
62
|
-
});
|
|
63
|
-
await sw.subscribeToRepoChannel(resolvePort);
|
|
64
|
-
const workerPort = await portPromise;
|
|
65
61
|
let repo;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
cachingMode: "periodic",
|
|
75
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
76
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
77
|
-
// Defaults to "subduction".
|
|
78
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
79
|
-
repo: {
|
|
80
|
-
storage: new IndexedDBStorageAdapter(),
|
|
81
|
-
enableRemoteHeadsGossiping: true,
|
|
82
|
-
},
|
|
83
|
-
}));
|
|
62
|
+
// If a Repo is already on `window` — an embedding context provided one before
|
|
63
|
+
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
64
|
+
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
65
|
+
// Otherwise create our own below.
|
|
66
|
+
if (window.repo) {
|
|
67
|
+
log("using existing Repo from window");
|
|
68
|
+
repo = window.repo;
|
|
69
|
+
hive = window.hive;
|
|
84
70
|
}
|
|
85
71
|
else {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
},
|
|
92
|
-
enableRemoteHeadsGossiping: true,
|
|
93
|
-
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
72
|
+
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
73
|
+
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
74
|
+
let resolvePort;
|
|
75
|
+
const portPromise = new Promise((r) => {
|
|
76
|
+
resolvePort = r;
|
|
94
77
|
});
|
|
78
|
+
log("subscribing to repo channel");
|
|
79
|
+
await sw.subscribeToRepoChannel(resolvePort);
|
|
80
|
+
log("repo channel subscribed");
|
|
81
|
+
const workerPort = await portPromise;
|
|
82
|
+
if (config.keyhive) {
|
|
83
|
+
log("setting up keyhive");
|
|
84
|
+
initKeyhiveWasm();
|
|
85
|
+
({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
86
|
+
createRepo: (config) => new Repo(config),
|
|
87
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
88
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
89
|
+
networkAdapter: new MessageChannelNetworkAdapter(workerPort),
|
|
90
|
+
automaticArchiveIngestion: true,
|
|
91
|
+
cachingMode: "periodic",
|
|
92
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
93
|
+
// ARK selects the relay via `syncServer` ("keyhive" | "subduction").
|
|
94
|
+
// Defaults to "subduction".
|
|
95
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
96
|
+
repo: {
|
|
97
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
98
|
+
enableRemoteHeadsGossiping: true,
|
|
99
|
+
},
|
|
100
|
+
}));
|
|
101
|
+
log("keyhive setup complete");
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
log("creating repo");
|
|
105
|
+
repo = new Repo({
|
|
106
|
+
network: [new MessageChannelNetworkAdapter(workerPort)],
|
|
107
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
108
|
+
async sharePolicy(peerId) {
|
|
109
|
+
return peerId.includes("automerge-worker");
|
|
110
|
+
},
|
|
111
|
+
enableRemoteHeadsGossiping: true,
|
|
112
|
+
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
113
|
+
});
|
|
114
|
+
log("repo created");
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
|
-
|
|
117
|
+
log("popping repo on window");
|
|
118
|
+
window.repo = repo;
|
|
119
|
+
log("await repo.networkSubsystem.whenReady()");
|
|
97
120
|
await repo.networkSubsystem.whenReady();
|
|
121
|
+
log("networkSubsystem ready");
|
|
98
122
|
if (hive) {
|
|
99
123
|
hive.networkAdapter.syncKeyhive?.();
|
|
100
124
|
}
|
|
@@ -114,7 +138,10 @@ export async function bootPatchworkSite(config) {
|
|
|
114
138
|
// `resolveAccountHandle` below has something to await on (the `account`
|
|
115
139
|
// datatype lives in that bundle today). The user's own module-settings URL
|
|
116
140
|
// is added lazily once it appears on the account doc — see below.
|
|
117
|
-
const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins
|
|
141
|
+
const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
|
|
142
|
+
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
143
|
+
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
144
|
+
importAutomergeModuleViaWorker);
|
|
118
145
|
const accountDocHandle = (await resolveAccountHandle(repo, {
|
|
119
146
|
storageKey: config.accountStorageKey,
|
|
120
147
|
hive,
|
|
@@ -164,9 +191,7 @@ function isValidModuleSource(source) {
|
|
|
164
191
|
* built-in default bundle).
|
|
165
192
|
*/
|
|
166
193
|
function resolveDefaultModules(config) {
|
|
167
|
-
const builtin = config.defaultModules ??
|
|
168
|
-
config.defaultModulesUrl ??
|
|
169
|
-
[];
|
|
194
|
+
const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
170
195
|
const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
|
|
171
196
|
const override = globalThis.localStorage?.getItem("defaultToolsUrl");
|
|
172
197
|
if (override) {
|
|
@@ -206,6 +231,33 @@ function installDevConsoleGlobals(repo, hive, getRepoChannel) {
|
|
|
206
231
|
}
|
|
207
232
|
window.getRepoChannel = getRepoChannel;
|
|
208
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Log this tab's Page Lifecycle + connectivity transitions (visibility,
|
|
236
|
+
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
237
|
+
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
238
|
+
*/
|
|
239
|
+
function installLifecycleLogging() {
|
|
240
|
+
if (typeof document === "undefined")
|
|
241
|
+
return;
|
|
242
|
+
const opts = { capture: true };
|
|
243
|
+
const note = (label, extra) => {
|
|
244
|
+
if (!lifecycleLoggingEnabled())
|
|
245
|
+
return;
|
|
246
|
+
const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
|
|
247
|
+
if (extra === undefined)
|
|
248
|
+
console.info(msg);
|
|
249
|
+
else
|
|
250
|
+
console.info(msg, extra);
|
|
251
|
+
};
|
|
252
|
+
document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
|
|
253
|
+
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
254
|
+
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
255
|
+
window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
|
|
256
|
+
window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
|
|
257
|
+
window.addEventListener("online", () => note("online"), opts);
|
|
258
|
+
window.addEventListener("offline", () => note("offline"), opts);
|
|
259
|
+
note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
|
|
260
|
+
}
|
|
209
261
|
function onModuleLoaded(name, mod) {
|
|
210
262
|
if (Array.isArray(mod.plugins)) {
|
|
211
263
|
log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
|
package/dist/types.d.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* reintroduced when either of them restarts — and so tabs can listen in.
|
|
6
6
|
*/
|
|
7
7
|
export declare const HANDOFF_CHANNEL = "@patchwork/handoff";
|
|
8
|
+
/**
|
|
9
|
+
* BroadcastChannel on which the automerge shared worker announces remote
|
|
10
|
+
* heads it learns about from the sync server. Any tab can listen to stay
|
|
11
|
+
* informed of sync progress without repo-to-repo gossiping.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
|
|
8
14
|
/**
|
|
9
15
|
* The special URL to resolve, plus enough of the {@link Request} the service
|
|
10
16
|
* worker is holding that the automerge worker can construct one that
|
|
@@ -93,6 +99,8 @@ export type SetupServiceWorkerOptions = {
|
|
|
93
99
|
};
|
|
94
100
|
export type ServiceWorkerRepoChannelListener = (port: MessagePort) => void | Promise<void>;
|
|
95
101
|
export type SetupServiceWorkerResult = {
|
|
102
|
+
shared?: SharedWorker;
|
|
103
|
+
kill?: () => void;
|
|
96
104
|
/** Open a classic Automerge sync WebSocket from the automerge worker. */
|
|
97
105
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
98
106
|
subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
|
package/dist/types.js
CHANGED
|
@@ -5,3 +5,9 @@
|
|
|
5
5
|
* reintroduced when either of them restarts — and so tabs can listen in.
|
|
6
6
|
*/
|
|
7
7
|
export const HANDOFF_CHANNEL = "@patchwork/handoff";
|
|
8
|
+
/**
|
|
9
|
+
* BroadcastChannel on which the automerge shared worker announces remote
|
|
10
|
+
* heads it learns about from the sync server. Any tab can listen to stay
|
|
11
|
+
* informed of sync progress without repo-to-repo gossiping.
|
|
12
|
+
*/
|
|
13
|
+
export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
|
|
@@ -12,6 +12,10 @@ const workers = [
|
|
|
12
12
|
specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
|
|
13
13
|
fileName: "automerge-worker.js",
|
|
14
14
|
},
|
|
15
|
+
{
|
|
16
|
+
specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
|
|
17
|
+
fileName: "module-loader-worker.js",
|
|
18
|
+
},
|
|
15
19
|
];
|
|
16
20
|
export function serviceworker() {
|
|
17
21
|
const entryIds = new Set();
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inkandswitch/patchwork-bootloader",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"author": "chee",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"devDependencies": {
|
|
8
|
-
"@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.
|
|
8
|
+
"@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
|
|
9
9
|
"esbuild": "^0.23.1",
|
|
10
10
|
"rollup": "^4.61.1"
|
|
11
11
|
},
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"import": "./dist/automerge-worker.js",
|
|
31
31
|
"types": "./dist/automerge-worker.d.ts"
|
|
32
32
|
},
|
|
33
|
+
"./module-loader-worker": {
|
|
34
|
+
"import": "./dist/module-loader-worker.js",
|
|
35
|
+
"types": "./dist/module-loader-worker.d.ts"
|
|
36
|
+
},
|
|
33
37
|
"./site": {
|
|
34
38
|
"import": "./dist/site.js",
|
|
35
39
|
"types": "./dist/site.d.ts"
|
|
@@ -41,28 +45,28 @@
|
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@automerge/automerge": "3.3.0-fragments.1",
|
|
44
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
45
|
-
"@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.
|
|
46
|
-
"@automerge/automerge-repo-network-websocket": "2.6.0-subduction.
|
|
47
|
-
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.
|
|
48
|
+
"@automerge/automerge-repo": "2.6.0-subduction.39",
|
|
49
|
+
"@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.39",
|
|
50
|
+
"@automerge/automerge-repo-network-websocket": "2.6.0-subduction.39",
|
|
51
|
+
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.39",
|
|
48
52
|
"@automerge/automerge-subduction": "0.16.0",
|
|
49
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
50
|
-
"@keyhive/keyhive": "0.1.0-alpha.
|
|
53
|
+
"@automerge/vanillajs": "2.6.0-subduction.39",
|
|
54
|
+
"@keyhive/keyhive": "0.1.0-alpha.5",
|
|
51
55
|
"@types/debug": "^4.1.13",
|
|
52
56
|
"debug": "^4.4.3",
|
|
53
57
|
"resolve.exports": "^2.0.3",
|
|
54
58
|
"service-worker-types": "npm:@types/serviceworker@^0.0.153",
|
|
55
59
|
"tinyargs": "^0.1.4",
|
|
56
|
-
"@inkandswitch/patchwork-filesystem": "^0.1.
|
|
57
|
-
"@inkandswitch/patchwork-
|
|
60
|
+
"@inkandswitch/patchwork-filesystem": "^0.1.1",
|
|
61
|
+
"@inkandswitch/patchwork-elements": "^2.0.0",
|
|
58
62
|
"@inkandswitch/patchwork-providers": "^0.3.0",
|
|
59
|
-
"@inkandswitch/patchwork-
|
|
63
|
+
"@inkandswitch/patchwork-plugins": "^0.0.11"
|
|
60
64
|
},
|
|
61
65
|
"peerDependencies": {
|
|
62
66
|
"@automerge/automerge": "3.3.0-fragments.1",
|
|
63
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
64
|
-
"@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.
|
|
65
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
67
|
+
"@automerge/automerge-repo": "2.6.0-subduction.39",
|
|
68
|
+
"@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
|
|
69
|
+
"@automerge/vanillajs": "2.6.0-subduction.39"
|
|
66
70
|
},
|
|
67
71
|
"scripts": {
|
|
68
72
|
"build": "tsc",
|
package/src/automerge-worker.ts
CHANGED
|
@@ -30,9 +30,9 @@ import {
|
|
|
30
30
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
31
31
|
|
|
32
32
|
// Small adapters — bundled directly into the worker
|
|
33
|
-
import {
|
|
33
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
34
34
|
import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
|
|
35
|
-
import {
|
|
35
|
+
import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
36
36
|
import {
|
|
37
37
|
initializeAutomergeRepoKeyhiveRustWithRepo,
|
|
38
38
|
initKeyhiveWasm,
|
|
@@ -54,6 +54,103 @@ declare const __KEYHIVE_SYNC_SERVER__: boolean;
|
|
|
54
54
|
|
|
55
55
|
let debugging = false;
|
|
56
56
|
|
|
57
|
+
// Per-boot identity so a tab can detect a worker *restart*: a fresh instance
|
|
58
|
+
// means a new repo peerId + cold in-memory state, so the tab's docs must be
|
|
59
|
+
// re-subscribed. Sent in `hello` (on connect) and every `pong`.
|
|
60
|
+
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
61
|
+
const WORKER_BOOT_TIME = Date.now();
|
|
62
|
+
|
|
63
|
+
// ── Forward console output + uncaught errors to the main thread ─────────
|
|
64
|
+
// The SharedWorker has its own console that's a pain to find (chrome://inspect
|
|
65
|
+
// → shared workers). Patch console.* and the global error handlers to also
|
|
66
|
+
// post back over every connected tab's control port, tagged [automerge-worker].
|
|
67
|
+
|
|
68
|
+
const controlPorts = new Set<MessagePort>();
|
|
69
|
+
// Logs emitted before any tab has connected (e.g. during wasm boot) would
|
|
70
|
+
// otherwise be lost — buffer a bounded number and flush on first connect.
|
|
71
|
+
const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
|
|
72
|
+
const MAX_BUFFER = 200;
|
|
73
|
+
|
|
74
|
+
function serializeArg(arg: any): string {
|
|
75
|
+
if (typeof arg === "string") return arg;
|
|
76
|
+
if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(arg);
|
|
79
|
+
} catch {
|
|
80
|
+
return String(arg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function forwardToMainThread(level: string, rawArgs: any[]) {
|
|
85
|
+
const args = rawArgs.map(serializeArg);
|
|
86
|
+
if (!controlPorts.size) {
|
|
87
|
+
if (preConnectBuffer.length < MAX_BUFFER) {
|
|
88
|
+
preConnectBuffer.push({ level, args });
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const port of controlPorts) {
|
|
93
|
+
try {
|
|
94
|
+
port.postMessage({ type: "console", level, args });
|
|
95
|
+
} catch {
|
|
96
|
+
// Port may be closing — ignore.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const level of ["log", "info", "warn", "error", "debug"] as const) {
|
|
102
|
+
const original = console[level].bind(console);
|
|
103
|
+
console[level] = (...args: any[]) => {
|
|
104
|
+
original(...args);
|
|
105
|
+
forwardToMainThread(level, args);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
self.addEventListener("error", (event) => {
|
|
110
|
+
const e = event as ErrorEvent;
|
|
111
|
+
forwardToMainThread("error", [
|
|
112
|
+
`uncaught error: ${e.message}`,
|
|
113
|
+
e.error instanceof Error ? e.error.stack : undefined,
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
118
|
+
const reason = (event as PromiseRejectionEvent).reason;
|
|
119
|
+
forwardToMainThread("error", [
|
|
120
|
+
"unhandled rejection:",
|
|
121
|
+
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Boot marker, buffered until the first tab connects. A new instance id means
|
|
126
|
+
// the worker restarted (fresh peerId + cold state).
|
|
127
|
+
console.warn(
|
|
128
|
+
`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
|
|
129
|
+
`SharedWorker started (instance ${WORKER_INSTANCE_ID})`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// ── Suspension watchdog ─────────────────────────────────────────────────
|
|
133
|
+
// A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
|
|
134
|
+
// drift. A large gap means keepalive pongs stalled and the server may have
|
|
135
|
+
// reaped us.
|
|
136
|
+
const WATCHDOG_TICK_MS = 5_000;
|
|
137
|
+
const WATCHDOG_GAP_FACTOR = 2;
|
|
138
|
+
let watchdogLast = Date.now();
|
|
139
|
+
setInterval(() => {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const gap = now - watchdogLast;
|
|
142
|
+
watchdogLast = now;
|
|
143
|
+
if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
|
|
146
|
+
`(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
|
|
147
|
+
`suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
|
|
148
|
+
`during this window, so the sync server may have reaped us. at ` +
|
|
149
|
+
`${new Date(now).toISOString()}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}, WATCHDOG_TICK_MS);
|
|
153
|
+
|
|
57
154
|
// Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
|
|
58
155
|
// to target keyhive.sync.automerge.org.
|
|
59
156
|
const useKeyhiveSyncServer =
|
|
@@ -78,7 +175,7 @@ const RESOLVE_TIMEOUT_MS = 30_000;
|
|
|
78
175
|
const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
|
|
79
176
|
|
|
80
177
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
81
|
-
let classicSyncAdapter:
|
|
178
|
+
let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null;
|
|
82
179
|
let classicSyncConnectPromise: Promise<void> | null = null;
|
|
83
180
|
|
|
84
181
|
async function connectClassicSyncNetwork(server: string): Promise<void> {
|
|
@@ -97,7 +194,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
|
|
|
97
194
|
classicSyncConnectPromise = (async () => {
|
|
98
195
|
const { repo } = await getRepoHive();
|
|
99
196
|
if (!classicSyncAdapter) {
|
|
100
|
-
classicSyncAdapter = new
|
|
197
|
+
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
101
198
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
102
199
|
}
|
|
103
200
|
await classicSyncAdapter.whenReady();
|
|
@@ -153,7 +250,7 @@ function getRepoHive() {
|
|
|
153
250
|
const signer = await WebCryptoSigner.setup();
|
|
154
251
|
|
|
155
252
|
const repo = new Repo({
|
|
156
|
-
storage: new
|
|
253
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
157
254
|
signer,
|
|
158
255
|
peerId: ("automerge-worker-" +
|
|
159
256
|
Math.random()
|
|
@@ -181,7 +278,7 @@ function getRepoHive() {
|
|
|
181
278
|
// ARK variant for talking to the keyhive-enabled subduction sync server.
|
|
182
279
|
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
183
280
|
createRepo: (config) => new Repo(config),
|
|
184
|
-
storage: new
|
|
281
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
185
282
|
peerIdSuffix:
|
|
186
283
|
`${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
187
284
|
automaticArchiveIngestion: true,
|
|
@@ -191,7 +288,7 @@ function getRepoHive() {
|
|
|
191
288
|
// defaults to "subduction".
|
|
192
289
|
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
193
290
|
repo: {
|
|
194
|
-
storage: new
|
|
291
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
195
292
|
subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
|
|
196
293
|
enableRemoteHeadsGossiping: true,
|
|
197
294
|
},
|
|
@@ -362,6 +459,13 @@ function handleControlMessage(
|
|
|
362
459
|
});
|
|
363
460
|
replyPort?.close();
|
|
364
461
|
});
|
|
462
|
+
} else if (data?.type === "ping") {
|
|
463
|
+
// Heartbeat: reply so the tab can detect our death or restart.
|
|
464
|
+
controlPort.postMessage({
|
|
465
|
+
type: "pong",
|
|
466
|
+
id: data.id,
|
|
467
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
468
|
+
});
|
|
365
469
|
}
|
|
366
470
|
}
|
|
367
471
|
|
|
@@ -376,10 +480,32 @@ self.addEventListener("connect", (event) => {
|
|
|
376
480
|
// Fires when the owning page is destroyed. Browsers without the close
|
|
377
481
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
378
482
|
controlPort.addEventListener("close", () => {
|
|
483
|
+
controlPorts.delete(controlPort);
|
|
379
484
|
void dropConnection(connection);
|
|
380
485
|
});
|
|
381
486
|
|
|
382
487
|
controlPort.start();
|
|
488
|
+
|
|
489
|
+
// Greet the tab with our per-boot instance id so it can detect a restart
|
|
490
|
+
// (a different id than last seen) even if no port "close" fired.
|
|
491
|
+
controlPort.postMessage({
|
|
492
|
+
type: "hello",
|
|
493
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
494
|
+
bootTime: WORKER_BOOT_TIME,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Start forwarding console output to this tab, and flush anything buffered
|
|
498
|
+
// while no tab was connected (e.g. boot-time logs) to the first arrival.
|
|
499
|
+
controlPorts.add(controlPort);
|
|
500
|
+
if (preConnectBuffer.length) {
|
|
501
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
502
|
+
try {
|
|
503
|
+
controlPort.postMessage({ type: "console", level, args });
|
|
504
|
+
} catch {
|
|
505
|
+
// Port may already be gone — ignore.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
383
509
|
});
|
|
384
510
|
|
|
385
511
|
// ── Automerge URL resolution ───────────────────────────────────────────
|
|
@@ -555,7 +681,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
|
|
|
555
681
|
id,
|
|
556
682
|
type: "response",
|
|
557
683
|
response: {
|
|
558
|
-
status:
|
|
684
|
+
status: 557,
|
|
559
685
|
body,
|
|
560
686
|
headers: { "content-type": "text/plain" },
|
|
561
687
|
},
|
|
@@ -601,7 +727,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
|
|
|
601
727
|
id,
|
|
602
728
|
type: "response",
|
|
603
729
|
response: {
|
|
604
|
-
status:
|
|
730
|
+
status: 558,
|
|
605
731
|
body: String(error),
|
|
606
732
|
headers: { "content-type": "text/plain" },
|
|
607
733
|
},
|
package/src/externals.ts
CHANGED
|
@@ -7,10 +7,9 @@ const externals = [
|
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
9
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
|
+
"@automerge/automerge-repo-network-websocket",
|
|
10
11
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
11
12
|
"@automerge/automerge-repo-keyhive",
|
|
12
|
-
"@automerge/automerge-repo-network-messagechannel",
|
|
13
|
-
"@automerge/automerge-repo-storage-indexeddb",
|
|
14
13
|
"@automerge/automerge-subduction",
|
|
15
14
|
"@automerge/automerge-subduction/slim",
|
|
16
15
|
"@keyhive/keyhive",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Dedicated module worker for plugin-descriptor discovery.
|
|
2
|
+
//
|
|
3
|
+
// A module-settings doc lists Automerge folder-doc packages. To register the
|
|
4
|
+
// plugins a package provides we only need their *descriptions* (id, type,
|
|
5
|
+
// name, icon…), not their implementations. This worker imports a package's
|
|
6
|
+
// entry point off the main thread purely to read its exported `plugins` array,
|
|
7
|
+
// strips the non-cloneable `load()` / `import` machinery, and posts the plain
|
|
8
|
+
// descriptors back. The main thread re-imports the package (at the same heads)
|
|
9
|
+
// only when a plugin is actually loaded — see `importPluginFromFolderDocUrl`.
|
|
10
|
+
//
|
|
11
|
+
// Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
|
|
12
|
+
// entry points is served by the service worker that controls this worker.
|
|
13
|
+
|
|
14
|
+
import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
15
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
|
|
16
|
+
|
|
17
|
+
type DiscoverRequest = {
|
|
18
|
+
type: "discover";
|
|
19
|
+
id: number;
|
|
20
|
+
url: AutomergeUrl;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Keep only the structured-cloneable description fields. `load` is a closure
|
|
24
|
+
// and `module` is the (possibly already-loaded) implementation — neither can
|
|
25
|
+
// cross the worker boundary. `import` is droppable too: the main thread
|
|
26
|
+
// rebuilds loading by re-importing the package and calling the live plugin.
|
|
27
|
+
function toDescriptor(plugin: any): Record<string, unknown> {
|
|
28
|
+
if (!plugin || typeof plugin !== "object") return {};
|
|
29
|
+
const { load, import: _import, module, ...description } = plugin;
|
|
30
|
+
return description;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDiscoverRequest(data: unknown): data is DiscoverRequest {
|
|
34
|
+
return (
|
|
35
|
+
typeof data === "object" &&
|
|
36
|
+
data !== null &&
|
|
37
|
+
(data as any).type === "discover" &&
|
|
38
|
+
typeof (data as any).id === "number" &&
|
|
39
|
+
typeof (data as any).url === "string"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
self.addEventListener("message", (event: MessageEvent) => {
|
|
44
|
+
const data = event.data;
|
|
45
|
+
if (!isDiscoverRequest(data)) return;
|
|
46
|
+
const { id, url } = data;
|
|
47
|
+
|
|
48
|
+
importModuleFromFolderDocUrl(url)
|
|
49
|
+
.then((mod) => {
|
|
50
|
+
const plugins: any[] = Array.isArray(mod?.plugins) ? mod.plugins : [];
|
|
51
|
+
const descriptors = plugins.map(toDescriptor);
|
|
52
|
+
(self as unknown as Worker).postMessage({
|
|
53
|
+
type: "descriptors",
|
|
54
|
+
id,
|
|
55
|
+
descriptors,
|
|
56
|
+
});
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
(self as unknown as Worker).postMessage({
|
|
60
|
+
type: "error",
|
|
61
|
+
id,
|
|
62
|
+
error:
|
|
63
|
+
error instanceof Error
|
|
64
|
+
? (error.stack ?? error.message)
|
|
65
|
+
: String(error),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|