@inkandswitch/patchwork 0.7.3 → 0.8.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 +54 -0
- package/dist/head.js +1 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +37 -52
- package/dist/repo.d.ts +7 -15
- package/dist/repo.js +18 -53
- package/dist/site-kit/sync-servers.js +4 -3
- package/dist/types.d.ts +15 -4
- package/dist/vite/config-plugin.js +5 -5
- package/dist/vite/importmap-plugin.js +14 -2
- package/dist/vite/service-worker-plugin.js +2 -2
- package/package.json +11 -11
- package/src/index.ts +60 -51
- package/src/repo.ts +21 -67
- package/src/site-kit/sync-servers.ts +4 -3
- package/src/types.ts +16 -9
- package/src/vite/config-plugin.ts +7 -12
- package/src/vite/importmap-plugin.ts +14 -2
- package/src/vite/service-worker-plugin.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# @inkandswitch/patchwork
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2e71745: Every Repo on the origin is its own Subduction node. A tab holds this origin's IndexedDB, keeps its own WebSocket to the sync server, and meets the other tabs over a BroadcastChannel carrying Subduction (`siblingAdapters()` in `@inkandswitch/patchwork-bootloader/siblings`, passed to `new Repo({ subductionAdapters })`). The automerge SharedWorker no longer sits between tabs and storage — it is one more such node, kept only to resolve `automerge:` URLs for the service worker, which can't own a Repo itself. The websocket proxy worker is gone with it.
|
|
8
|
+
|
|
9
|
+
Benchmarked against the shared-worker arrangement (`sites/bench`): boot and memory are a wash or better, cross-tab propagation matches, and two shared-worker failures go away — a `find()` racing a sibling's `create()` settled as unavailable, and edits made just before a tab closed were lost, since a storageless tab had nothing to flush to. Each tab flushing its own IndexedDB closes both.
|
|
10
|
+
|
|
11
|
+
Keyhive sites use the subduction-backed hive in both the tab and the worker, each talking to the sync server directly.
|
|
12
|
+
|
|
13
|
+
`patchwork.sw.subscribeSyncState(documentId, listener)` stays, now a filter over the tab's own Repo's `subduction-remote-heads` event, replaying the server's current heads from `handle.getSyncInfo()` on subscribe; `SyncStateDocMessage` is exported from `@inkandswitch/patchwork`. Removed from `setupServiceWorker()`'s result: `subscribeToRepoChannel`, `getRepoChannel`, `subscribeSyncState`. The `@patchwork/syncstate` BroadcastChannel and the other `SyncState*` message types are gone too; a tab's own Repo has what they carried — `repo.isSubductionConnected()` and the `subduction-connection` event for the link, `repo.connectedSubductionPeerIds()` for which peers are the server, and `patchwork.signerIdentity` for this tab's peer id. `createRepo` in `@inkandswitch/patchwork` takes no arguments.
|
|
14
|
+
|
|
15
|
+
`@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap.
|
|
16
|
+
|
|
17
|
+
That worker is renamed for what it now does: `@inkandswitch/patchwork-bootloader/automerge-worker` is now `@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker`, emitted as `automerge-protocol-handler-worker.js` (the `workerPath` option on `setupServiceWorker` still overrides it), and `getAutomergeWorker()` is now `getAutomergeProtocolHandlerWorker()`.
|
|
18
|
+
|
|
19
|
+
Inter-tab sync is Subduction, not classic automerge sync. `connectSiblings(repo, hive)` is replaced by `siblingAdapters()`, which returns the `subductionAdapters` entries for a Repo rather than mutating one after the fact, so it is passed to the `Repo` constructor. The frames on the siblings BroadcastChannel are Subduction transport frames authenticated by each node's own signer; the keyhive network adapter no longer wraps that channel, since keyhive material reaches siblings the same way it reaches the sync server. The one remaining classic-sync path is the opt-in classic sync server the protocol handler worker connects to on request.
|
|
20
|
+
|
|
21
|
+
Subduction's handshake has an initiator and a responder, and a BroadcastChannel is a mesh, so the siblings adapter is passed with `role: "mesh"`, added to the automerge-repo fork's `subductionAdapters` by this repo's pnpm patch: for each pair of peers on the adapter, the one whose peer id sorts lower initiates the handshake and the other accepts.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 47bc4cf: `@automerge/automerge` goes to `3.4.1`, and `@automerge/automerge-repo-network-broadcastchannel` joins the automerge-repo family at `2.6.0-subduction.48`.
|
|
26
|
+
- Updated dependencies [47bc4cf]
|
|
27
|
+
- Updated dependencies [2e71745]
|
|
28
|
+
- @inkandswitch/patchwork-bootloader@0.7.0
|
|
29
|
+
- @inkandswitch/patchwork-filesystem@0.2.9
|
|
30
|
+
- @inkandswitch/patchwork-plugins@1.2.4
|
|
31
|
+
|
|
32
|
+
## 0.7.4
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- 7339066: The dev importmap now names each builtin's pre-bundled dep URL
|
|
37
|
+
(`/node_modules/.vite/deps/…?v=…`) instead of `/@id/<dep>`. Both URLs resolve to
|
|
38
|
+
the same file, but a module fetched under two URLs is evaluated twice — the site
|
|
39
|
+
imported solid through the pre-bundled URL and tool code imported it through the
|
|
40
|
+
importmap, so tools ran against a second solid with its own owner stack and
|
|
41
|
+
logged "computations created outside a `createRoot`" for every effect they
|
|
42
|
+
created. Builtins that aren't pre-bundled still fall back to `/@id/<dep>`.
|
|
43
|
+
- ebca53b: Move the automerge-repo subduction fork to 2.6.0-subduction.48, `@automerge/automerge-repo-keyhive` to 0.5.0-alpha.7, and `@keyhive/keyhive` to 0.1.0-alpha.8. These three are bumped together because the keyhive package pins its automerge-repo version exactly.
|
|
44
|
+
|
|
45
|
+
keyhive 0.5 renames the two hive flavours. The network-adapter hive is now `LegacyAutomergeRepoKeyhive`, built by `initializeLegacyAutomergeRepoKeyhive`; the subduction hive keeps the name `AutomergeRepoKeyhive` and is built by `initializeAutomergeRepoKeyhive`. Both extend `AutomergeRepoKeyhiveBase`, which is what Patchwork's `hive` fields are typed as, so a tool that only reads membership works against either.
|
|
46
|
+
|
|
47
|
+
`createKeyhiveNetworkAdapter` takes an options object instead of positional arguments, and `onlyShareWithHardcodedServerPeerId` is now `onlyShareWithSyncServer`.
|
|
48
|
+
|
|
49
|
+
- Updated dependencies [ebca53b]
|
|
50
|
+
- Updated dependencies [882eacd]
|
|
51
|
+
- @inkandswitch/patchwork-bootloader@0.6.3
|
|
52
|
+
- @inkandswitch/patchwork-elements@6.0.2
|
|
53
|
+
- @inkandswitch/patchwork-filesystem@0.2.8
|
|
54
|
+
- @inkandswitch/patchwork-plugins@1.2.3
|
|
55
|
+
- @inkandswitch/patchwork-providers@0.5.2
|
|
56
|
+
|
|
3
57
|
## 0.7.3
|
|
4
58
|
|
|
5
59
|
### Patch Changes
|
package/dist/head.js
CHANGED
|
@@ -6,11 +6,7 @@
|
|
|
6
6
|
"@automerge/automerge/slim",
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
|
-
|
|
10
|
-
// proxy entry and donate its port to the automerge worker (Chrome can't
|
|
11
|
-
// spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
|
|
12
|
-
"@automerge/automerge-repo/worker-port",
|
|
13
|
-
"@automerge/automerge-repo/subduction-websocket-worker-shared",
|
|
9
|
+
"@automerge/automerge-repo-network-broadcastchannel",
|
|
14
10
|
"@automerge/automerge-repo-network-messagechannel",
|
|
15
11
|
"@automerge/automerge-repo-network-websocket",
|
|
16
12
|
"@automerge/automerge-repo-storage-indexeddb",
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One import for a Patchwork site.
|
|
3
3
|
*
|
|
4
|
-
* `setup(options)` constructs the Repo, wires up the
|
|
5
|
-
* loads plugins via the
|
|
4
|
+
* `setup(options)` constructs the Repo, wires up the
|
|
5
|
+
* automerge-protocol-handler-worker port, loads plugins via the
|
|
6
|
+
* ModuleWatcher, resolves the user's account document,
|
|
6
7
|
* installs the router, and resolves with the site's runtime API — `repo`,
|
|
7
8
|
* `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
|
|
8
9
|
* site assigns to `window.patchwork`.
|
|
@@ -15,10 +16,10 @@
|
|
|
15
16
|
* Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
|
|
16
17
|
* `main.ts` only. Non-UI consumers should import
|
|
17
18
|
* `@inkandswitch/patchwork-bootloader` directly, which does SW registration
|
|
18
|
-
* and the automerge-worker handoff and nothing else.
|
|
19
|
+
* and the automerge-protocol-handler-worker handoff and nothing else.
|
|
19
20
|
*/
|
|
20
21
|
import { Repo } from "@automerge/vanillajs/slim";
|
|
21
|
-
import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
22
|
+
import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
22
23
|
import type { Patchwork, PatchworkOptions } from "./types.js";
|
|
23
24
|
declare global {
|
|
24
25
|
interface Window {
|
|
@@ -34,4 +35,4 @@ export default setup;
|
|
|
34
35
|
export { createRepo, initWasm } from "./repo.js";
|
|
35
36
|
export { createRouter } from "./router.js";
|
|
36
37
|
export { showLoadingAnimation, hideLoadingAnimation, showErrorScreen, } from "./loading.js";
|
|
37
|
-
export type { OpenOptions, Patchwork, PatchworkOptions, ServiceWorkerApi, SignerIdentity, } from "./types.js";
|
|
38
|
+
export type { OpenOptions, Patchwork, PatchworkOptions, ServiceWorkerApi, SignerIdentity, SyncStateDocMessage, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One import for a Patchwork site.
|
|
3
|
-
*
|
|
4
|
-
* `setup(options)` constructs the Repo, wires up the automerge-worker port,
|
|
5
|
-
* loads plugins via the ModuleWatcher, resolves the user's account document,
|
|
6
|
-
* installs the router, and resolves with the site's runtime API — `repo`,
|
|
7
|
-
* `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
|
|
8
|
-
* site assigns to `window.patchwork`.
|
|
9
|
-
*
|
|
10
|
-
* Setup owns page-wide state — the `window.repo`/`window.Automerge`/
|
|
11
|
-
* `window.AutomergeRepo`/`window.hive` globals, custom-element registration,
|
|
12
|
-
* document listeners — so it may run only once per page; a second call
|
|
13
|
-
* throws.
|
|
14
|
-
*
|
|
15
|
-
* Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
|
|
16
|
-
* `main.ts` only. Non-UI consumers should import
|
|
17
|
-
* `@inkandswitch/patchwork-bootloader` directly, which does SW registration
|
|
18
|
-
* and the automerge-worker handoff and nothing else.
|
|
19
|
-
*/
|
|
20
|
-
import { MessageChannelNetworkAdapter, } from "@automerge/vanillajs/slim";
|
|
21
1
|
import * as Automerge from "@automerge/automerge/slim";
|
|
22
2
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
23
3
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
@@ -28,7 +8,7 @@ import { createDocOfDatatype2, getRegistry, registerPlugins, resolveAccountHandl
|
|
|
28
8
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
29
9
|
import setupServiceWorker, { lifecycleLog, } from "@inkandswitch/patchwork-bootloader";
|
|
30
10
|
import debug from "debug";
|
|
31
|
-
import { createRepo,
|
|
11
|
+
import { createRepo, initWasm } from "./repo.js";
|
|
32
12
|
import { createRouter } from "./router.js";
|
|
33
13
|
import { createDefaultAccount } from "./createAccount.js";
|
|
34
14
|
const log = debug("patchwork:setup");
|
|
@@ -68,39 +48,13 @@ async function doSetup(options) {
|
|
|
68
48
|
let hive;
|
|
69
49
|
let repo;
|
|
70
50
|
let signerIdentity;
|
|
71
|
-
// Called with a fresh port when the automerge worker dies and is recreated.
|
|
72
|
-
// Assigned once the repo exists.
|
|
73
|
-
let onWorkerPortRenewed;
|
|
74
51
|
if (options.repo) {
|
|
75
52
|
log("using provided Repo");
|
|
76
53
|
repo = options.repo;
|
|
77
54
|
hive = options.hive;
|
|
78
55
|
}
|
|
79
56
|
else {
|
|
80
|
-
|
|
81
|
-
if (onWorkerPortRenewed)
|
|
82
|
-
onWorkerPortRenewed(port);
|
|
83
|
-
else {
|
|
84
|
-
console.warn("automerge worker port renewed before the repo existed; dropping it");
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
88
|
-
({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
|
|
89
|
-
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
90
|
-
// port and drop the adapter stranded on the dead one.
|
|
91
|
-
const bootHive = hive;
|
|
92
|
-
onWorkerPortRenewed = (port) => {
|
|
93
|
-
const fresh = new MessageChannelNetworkAdapter(port);
|
|
94
|
-
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
95
|
-
// keyhive adapter wrapped around the message channel.
|
|
96
|
-
const registered = bootHive
|
|
97
|
-
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
98
|
-
: fresh;
|
|
99
|
-
repo.networkSubsystem.addNetworkAdapter(registered);
|
|
100
|
-
removeAdapterFor(repo, workerAdapter, registered);
|
|
101
|
-
workerAdapter = fresh;
|
|
102
|
-
lifecycleLog("repo re-wired to the recreated automerge worker");
|
|
103
|
-
};
|
|
57
|
+
({ repo, hive, signerIdentity } = await createRepo());
|
|
104
58
|
}
|
|
105
59
|
// Dev-console / tool-runtime globals (e2e and loaded tools read these). The
|
|
106
60
|
// `window.patchwork` handle is deliberately not set here — the caller does
|
|
@@ -111,8 +65,6 @@ async function doSetup(options) {
|
|
|
111
65
|
AutomergeRepo;
|
|
112
66
|
if (hive)
|
|
113
67
|
window.hive = hive;
|
|
114
|
-
await repo.networkSubsystem.whenReady();
|
|
115
|
-
log("networkSubsystem ready");
|
|
116
68
|
hive?.networkAdapter?.syncKeyhive?.();
|
|
117
69
|
registerRepoProviderElement(repo);
|
|
118
70
|
const rootElementId = options.rootElementId ?? "root";
|
|
@@ -168,8 +120,7 @@ async function doSetup(options) {
|
|
|
168
120
|
plugins,
|
|
169
121
|
sw: {
|
|
170
122
|
connectClassicSync: sw.connectClassicSync,
|
|
171
|
-
|
|
172
|
-
subscribeSyncState: sw.subscribeSyncState,
|
|
123
|
+
subscribeSyncState: (documentId, listener) => subscribeSyncState(repo, documentId, listener),
|
|
173
124
|
},
|
|
174
125
|
async create(type, init) {
|
|
175
126
|
const datatype = await getRegistry("patchwork:datatype").load(type);
|
|
@@ -279,6 +230,40 @@ function installLifecycleLogging() {
|
|
|
279
230
|
window.addEventListener("offline", () => lifecycleLog("offline"), opts);
|
|
280
231
|
lifecycleLog("logging installed (visibilityState=%s, hasFocus=%s)", document.visibilityState, document.hasFocus());
|
|
281
232
|
}
|
|
233
|
+
// The tab's own Repo hears the server's heads directly, so this is a filter
|
|
234
|
+
// over its `subduction-remote-heads` event. The current value is replayed
|
|
235
|
+
// from the handle's sync info, when the server has reported any.
|
|
236
|
+
function subscribeSyncState(repo, documentId, listener) {
|
|
237
|
+
const onHeads = (payload) => {
|
|
238
|
+
if (payload.documentId !== documentId)
|
|
239
|
+
return;
|
|
240
|
+
listener({
|
|
241
|
+
type: "sync-state",
|
|
242
|
+
documentId,
|
|
243
|
+
storageId: payload.storageId,
|
|
244
|
+
heads: [...payload.heads],
|
|
245
|
+
timestamp: payload.timestamp,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
repo.on("subduction-remote-heads", onHeads);
|
|
249
|
+
void (async () => {
|
|
250
|
+
const handle = repo.handles[documentId];
|
|
251
|
+
if (!handle)
|
|
252
|
+
return;
|
|
253
|
+
for (const storageId of await repo.connectedSubductionPeerIds()) {
|
|
254
|
+
const info = handle.getSyncInfo(storageId);
|
|
255
|
+
if (info) {
|
|
256
|
+
onHeads({
|
|
257
|
+
documentId,
|
|
258
|
+
storageId,
|
|
259
|
+
heads: info.lastHeads,
|
|
260
|
+
timestamp: info.lastSyncTimestamp,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
return () => repo.off("subduction-remote-heads", onHeads);
|
|
266
|
+
}
|
|
282
267
|
// ── Named exports ────────────────────────────────────────────────────────
|
|
283
268
|
export { createRepo, initWasm } from "./repo.js";
|
|
284
269
|
export { createRouter } from "./router.js";
|
package/dist/repo.d.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Repo } from "@automerge/vanillajs/slim";
|
|
2
2
|
import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
3
|
-
import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
|
|
4
3
|
import type { SignerIdentity } from "./types.js";
|
|
5
4
|
export declare function initWasm(): Promise<void>;
|
|
6
|
-
export
|
|
5
|
+
export type TabRepo = {
|
|
7
6
|
repo: Repo;
|
|
8
7
|
hive?: AutomergeRepoKeyhive;
|
|
9
8
|
signerIdentity?: SignerIdentity;
|
|
10
|
-
}
|
|
9
|
+
};
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
* every
|
|
14
|
-
*
|
|
15
|
-
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
16
|
-
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
17
|
-
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
18
|
-
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
19
|
-
* a good port long before that timeout.
|
|
11
|
+
* The tab's own node: this origin's IndexedDB, a socket to the sync server,
|
|
12
|
+
* and the siblings channel to every other Repo on the origin. Nothing is
|
|
13
|
+
* shared with other tabs except the database underneath.
|
|
20
14
|
*/
|
|
21
|
-
export declare function
|
|
22
|
-
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
23
|
-
export declare function removeAdapterFor(repo: Repo, stale: MessageChannelNetworkAdapter, keep: unknown): void;
|
|
15
|
+
export declare function createRepo(): Promise<TabRepo>;
|
package/dist/repo.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { initializeWasm, Repo
|
|
1
|
+
import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
|
|
2
2
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
3
|
-
import {
|
|
3
|
+
import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
|
|
4
|
+
import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
|
|
4
5
|
// eslint-disable-next-line
|
|
5
6
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
6
7
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
@@ -28,41 +29,43 @@ export function initWasm() {
|
|
|
28
29
|
}
|
|
29
30
|
return wasmReady;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The tab's own node: this origin's IndexedDB, a socket to the sync server,
|
|
34
|
+
* and the siblings channel to every other Repo on the origin. Nothing is
|
|
35
|
+
* shared with other tabs except the database underneath.
|
|
36
|
+
*/
|
|
37
|
+
export async function createRepo() {
|
|
32
38
|
if (syncServer.keyhive) {
|
|
33
39
|
log("setting up keyhive");
|
|
34
40
|
initKeyhiveWasm();
|
|
35
|
-
const { hive, repo } = await
|
|
41
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhive({
|
|
36
42
|
createRepo: (repoConfig) => new Repo(repoConfig),
|
|
37
43
|
storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
|
|
38
44
|
peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
|
|
39
|
-
networkAdapter: workerAdapter,
|
|
40
45
|
automaticArchiveIngestion: true,
|
|
41
46
|
cachingMode: "periodic",
|
|
42
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
43
47
|
// ARK selects the relay via `syncServer`, defaulting to "subduction".
|
|
44
48
|
syncServer: syncServer.keyhive,
|
|
45
49
|
repo: {
|
|
46
50
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
51
|
+
subductionWebsocketEndpoints: [syncServer.url],
|
|
52
|
+
subductionAdapters: siblingAdapters(),
|
|
47
53
|
enableRemoteHeadsGossiping: true,
|
|
48
54
|
},
|
|
49
55
|
});
|
|
50
56
|
log("keyhive setup complete");
|
|
51
57
|
return { repo, hive };
|
|
52
58
|
}
|
|
53
|
-
//
|
|
54
|
-
// identity can be
|
|
55
|
-
// Subduction, so this id never goes on the wire.
|
|
59
|
+
// The signer is explicit rather than the Repo's internal default so the
|
|
60
|
+
// identity the tab presents to the server can be shown on window.patchwork.
|
|
56
61
|
const signer = new MemorySigner();
|
|
57
62
|
const repo = new Repo({
|
|
58
|
-
network: [workerAdapter],
|
|
59
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
60
63
|
signer,
|
|
61
|
-
|
|
62
|
-
return peerId.includes("automerge-worker");
|
|
63
|
-
},
|
|
64
|
-
enableRemoteHeadsGossiping: true,
|
|
64
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
65
65
|
peerId: `${storagePrefix}-tab-${crypto.randomUUID()}`,
|
|
66
|
+
subductionWebsocketEndpoints: [syncServer.url],
|
|
67
|
+
subductionAdapters: siblingAdapters(),
|
|
68
|
+
enableRemoteHeadsGossiping: true,
|
|
66
69
|
});
|
|
67
70
|
const signerIdentity = {
|
|
68
71
|
peerId: signer.peerId().toString(),
|
|
@@ -71,41 +74,3 @@ export async function createRepo(workerAdapter) {
|
|
|
71
74
|
log("repo created, tab subduction identity:", signerIdentity);
|
|
72
75
|
return { repo, signerIdentity };
|
|
73
76
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Resolve with the first repo port the worker delivers, calling `onRenewed` for
|
|
76
|
-
* every later one.
|
|
77
|
-
*
|
|
78
|
-
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
79
|
-
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
80
|
-
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
81
|
-
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
82
|
-
* a good port long before that timeout.
|
|
83
|
-
*/
|
|
84
|
-
export function firstRepoPort(sw, onRenewed) {
|
|
85
|
-
return new Promise((resolve) => {
|
|
86
|
-
let seen = false;
|
|
87
|
-
void sw.subscribeToRepoChannel((port) => {
|
|
88
|
-
if (seen)
|
|
89
|
-
return onRenewed(port);
|
|
90
|
-
seen = true;
|
|
91
|
-
resolve(port);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
96
|
-
export function removeAdapterFor(repo, stale, keep) {
|
|
97
|
-
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
98
|
-
if (adapter === keep)
|
|
99
|
-
continue;
|
|
100
|
-
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
101
|
-
const base = adapter.networkAdapter ?? adapter;
|
|
102
|
-
if (base !== stale)
|
|
103
|
-
continue;
|
|
104
|
-
try {
|
|
105
|
-
repo.networkSubsystem.removeNetworkAdapter(adapter);
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
console.error("failed to remove stale worker network adapter", err);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER
|
|
2
|
-
// and automerge-worker.ts's SUBDUCTION_SYNC_URL selection —
|
|
3
|
-
// plain constants (rather than importing those runtime modules)
|
|
4
|
-
// only needs the hostnames, not the browser-only logic that reads
|
|
2
|
+
// and automerge-protocol-handler-worker.ts's SUBDUCTION_SYNC_URL selection —
|
|
3
|
+
// kept here as plain constants (rather than importing those runtime modules)
|
|
4
|
+
// since this only needs the hostnames, not the browser-only logic that reads
|
|
5
|
+
// them.
|
|
5
6
|
export const DEFAULT_SYNC_SERVERS = {
|
|
6
7
|
classic: "wss://sync3.automerge.org",
|
|
7
8
|
subduction: "wss://subduction.sync.inkandswitch.com",
|
package/dist/types.d.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import type { AutomergeUrl, DocHandle, Repo } from "@automerge/vanillajs/slim";
|
|
2
|
-
import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
2
|
+
import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
3
3
|
import type { ModuleWatcher, HasPatchworkMetadata } from "@inkandswitch/patchwork-filesystem";
|
|
4
4
|
import type { AccountCreator, AccountDoc } from "@inkandswitch/patchwork-plugins";
|
|
5
|
-
import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "@inkandswitch/patchwork-bootloader/types";
|
|
6
5
|
import type * as pluginsNS from "@inkandswitch/patchwork-plugins";
|
|
7
6
|
export type PluginsApi = typeof pluginsNS;
|
|
8
7
|
export type SignerIdentity = {
|
|
9
8
|
peerId: string;
|
|
10
9
|
verifyingKey: string;
|
|
11
10
|
};
|
|
11
|
+
/** The sync server's last-known heads for one document, as it reports them. */
|
|
12
|
+
export interface SyncStateDocMessage {
|
|
13
|
+
type: "sync-state";
|
|
14
|
+
documentId: string;
|
|
15
|
+
storageId: string;
|
|
16
|
+
heads: string[];
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
12
19
|
export interface ServiceWorkerApi {
|
|
13
20
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
14
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Watch one document's heads at the sync server. Calls `listener` with
|
|
23
|
+
* what the server holds now, then on every update. Returns unsubscribe.
|
|
24
|
+
*/
|
|
15
25
|
subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
|
|
16
26
|
}
|
|
17
27
|
export interface OpenOptions {
|
|
@@ -70,7 +80,8 @@ export interface PatchworkOptions {
|
|
|
70
80
|
/**
|
|
71
81
|
* Bring your own Repo. When provided, setup skips wasm initialization and
|
|
72
82
|
* repo creation entirely — you are responsible for having initialized
|
|
73
|
-
* automerge/subduction and wired the automerge-worker
|
|
83
|
+
* automerge/subduction and wired the automerge-protocol-handler-worker
|
|
84
|
+
* port yourself.
|
|
74
85
|
*/
|
|
75
86
|
repo?: Repo;
|
|
76
87
|
/** The keyhive instance accompanying a provided {@link PatchworkOptions.repo}. */
|
|
@@ -69,11 +69,11 @@ export function config(options = {}) {
|
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
71
|
},
|
|
72
|
-
// The shared automerge-worker's chunk imports bypass the
|
|
73
|
-
// worker, so offline boot needs the browser's HTTP cache to
|
|
74
|
-
// without revalidating. Content hashes make that safe; a new
|
|
75
|
-
// new URLs. Production gets this from the generated _headers
|
|
76
|
-
// preview doesn't read that, so mirror it here.
|
|
72
|
+
// The shared automerge-protocol-handler-worker's chunk imports bypass the
|
|
73
|
+
// page's service worker, so offline boot needs the browser's HTTP cache to
|
|
74
|
+
// serve them without revalidating. Content hashes make that safe; a new
|
|
75
|
+
// build gets new URLs. Production gets this from the generated _headers
|
|
76
|
+
// file — vite preview doesn't read that, so mirror it here.
|
|
77
77
|
configurePreviewServer(server) {
|
|
78
78
|
if (options.netlify === false ||
|
|
79
79
|
options.netlify?.immutableAssets === false) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { relative } from "node:path";
|
|
2
3
|
/**
|
|
3
4
|
* bootloader owns resolving/emitting its own dependencies (it's the one that
|
|
4
5
|
* actually has automerge/keyhive/codemirror/solid/the other patchwork-*
|
|
@@ -87,11 +88,22 @@ export function importmap(options) {
|
|
|
87
88
|
},
|
|
88
89
|
transformIndexHtml: {
|
|
89
90
|
order: "pre",
|
|
90
|
-
handler(html, context) {
|
|
91
|
+
async handler(html, context) {
|
|
91
92
|
const activeImportmap = structuredClone(importmap);
|
|
92
93
|
if (context.server) {
|
|
94
|
+
// The importmap has to name the same URL the site's own imports get
|
|
95
|
+
// rewritten to, which is the pre-bundled dep. `/@id/<dep>` resolves
|
|
96
|
+
// to the same file but is a second URL, and a module fetched under
|
|
97
|
+
// two URLs is evaluated twice — two solids, two automerges.
|
|
98
|
+
const optimizer = context.server.environments.client.depsOptimizer;
|
|
99
|
+
await optimizer?.init();
|
|
100
|
+
const root = context.server.config.root;
|
|
93
101
|
for (const id of Object.keys(builtins)) {
|
|
94
|
-
|
|
102
|
+
const dependency = devDependencyId(id);
|
|
103
|
+
const optimized = optimizer?.metadata.optimized[dependency];
|
|
104
|
+
activeImportmap.imports[id] = optimized
|
|
105
|
+
? `/${relative(root, optimized.file)}?v=${optimized.browserHash}`
|
|
106
|
+
: `/@id/${dependency}`;
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
return {
|
|
@@ -16,8 +16,8 @@ export const workers = [
|
|
|
16
16
|
fileName: "service-worker.js",
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
|
|
20
|
-
fileName: "automerge-worker.js",
|
|
19
|
+
specifier: "@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker",
|
|
20
|
+
fileName: "automerge-protocol-handler-worker.js",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+https://github.com/inkandswitch/patchwork-system.git",
|
|
6
6
|
"directory": "core/patchwork"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.8.0",
|
|
9
9
|
"author": "Ink & Switch",
|
|
10
10
|
"type": "module",
|
|
11
11
|
"license": "MIT",
|
|
@@ -72,22 +72,22 @@
|
|
|
72
72
|
"./global.css": "./dist/global.css"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@automerge/automerge": "3.
|
|
76
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
77
|
-
"@automerge/automerge-repo-keyhive": "0.
|
|
78
|
-
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.
|
|
75
|
+
"@automerge/automerge": "3.4.1",
|
|
76
|
+
"@automerge/automerge-repo": "2.6.0-subduction.48",
|
|
77
|
+
"@automerge/automerge-repo-keyhive": "0.5.0-alpha.7",
|
|
78
|
+
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.48",
|
|
79
79
|
"@automerge/automerge-subduction": "0.16.1",
|
|
80
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
80
|
+
"@automerge/vanillajs": "2.6.0-subduction.48",
|
|
81
81
|
"@types/debug": "^4.1.13",
|
|
82
82
|
"debug": "^4.4.3",
|
|
83
83
|
"esbuild": "^0.23.1",
|
|
84
84
|
"sharp": "^0.35.3",
|
|
85
85
|
"vite-plugin-wasm": "^3.6.0",
|
|
86
|
-
"@inkandswitch/patchwork-bootloader": "^0.
|
|
87
|
-
"@inkandswitch/patchwork-
|
|
88
|
-
"@inkandswitch/patchwork-
|
|
89
|
-
"@inkandswitch/patchwork-
|
|
90
|
-
"@inkandswitch/patchwork-
|
|
86
|
+
"@inkandswitch/patchwork-bootloader": "^0.7.0",
|
|
87
|
+
"@inkandswitch/patchwork-filesystem": "^0.2.9",
|
|
88
|
+
"@inkandswitch/patchwork-plugins": "^1.2.4",
|
|
89
|
+
"@inkandswitch/patchwork-providers": "^0.5.2",
|
|
90
|
+
"@inkandswitch/patchwork-elements": "^6.0.2"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
93
|
"rollup": "^4.61.1",
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One import for a Patchwork site.
|
|
3
3
|
*
|
|
4
|
-
* `setup(options)` constructs the Repo, wires up the
|
|
5
|
-
* loads plugins via the
|
|
4
|
+
* `setup(options)` constructs the Repo, wires up the
|
|
5
|
+
* automerge-protocol-handler-worker port, loads plugins via the
|
|
6
|
+
* ModuleWatcher, resolves the user's account document,
|
|
6
7
|
* installs the router, and resolves with the site's runtime API — `repo`,
|
|
7
8
|
* `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
|
|
8
9
|
* site assigns to `window.patchwork`.
|
|
@@ -15,17 +16,18 @@
|
|
|
15
16
|
* Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
|
|
16
17
|
* `main.ts` only. Non-UI consumers should import
|
|
17
18
|
* `@inkandswitch/patchwork-bootloader` directly, which does SW registration
|
|
18
|
-
* and the automerge-worker handoff and nothing else.
|
|
19
|
+
* and the automerge-protocol-handler-worker handoff and nothing else.
|
|
19
20
|
*/
|
|
20
21
|
import {
|
|
21
22
|
type AutomergeUrl,
|
|
22
23
|
type DocHandle,
|
|
23
|
-
|
|
24
|
+
type DocumentId,
|
|
25
|
+
type StorageId,
|
|
24
26
|
Repo,
|
|
25
27
|
} from "@automerge/vanillajs/slim";
|
|
26
28
|
import * as Automerge from "@automerge/automerge/slim";
|
|
27
29
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
28
|
-
import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
30
|
+
import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
29
31
|
|
|
30
32
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
31
33
|
import { importAutomergePackageViaWorker } from "@inkandswitch/patchwork-bootloader/module-loader";
|
|
@@ -52,13 +54,9 @@ import type {
|
|
|
52
54
|
Patchwork,
|
|
53
55
|
PatchworkOptions,
|
|
54
56
|
SignerIdentity,
|
|
57
|
+
SyncStateDocMessage,
|
|
55
58
|
} from "./types.js";
|
|
56
|
-
import {
|
|
57
|
-
createRepo,
|
|
58
|
-
firstRepoPort,
|
|
59
|
-
initWasm,
|
|
60
|
-
removeAdapterFor,
|
|
61
|
-
} from "./repo.js";
|
|
59
|
+
import { createRepo, initWasm } from "./repo.js";
|
|
62
60
|
import { createRouter, type Router } from "./router.js";
|
|
63
61
|
import { createDefaultAccount } from "./createAccount.js";
|
|
64
62
|
|
|
@@ -97,9 +95,7 @@ export function setup(options: PatchworkOptions = {}): Promise<Patchwork> {
|
|
|
97
95
|
timer = setTimeout(
|
|
98
96
|
() =>
|
|
99
97
|
reject(
|
|
100
|
-
new Error(
|
|
101
|
-
`patchwork.setup: boot did not finish within ${timeout}ms`
|
|
102
|
-
)
|
|
98
|
+
new Error(`patchwork.setup: boot did not finish within ${timeout}ms`)
|
|
103
99
|
),
|
|
104
100
|
timeout
|
|
105
101
|
);
|
|
@@ -132,42 +128,13 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
|
|
|
132
128
|
let hive: AutomergeRepoKeyhive | undefined;
|
|
133
129
|
let repo: Repo;
|
|
134
130
|
let signerIdentity: SignerIdentity | undefined;
|
|
135
|
-
// Called with a fresh port when the automerge worker dies and is recreated.
|
|
136
|
-
// Assigned once the repo exists.
|
|
137
|
-
let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
|
|
138
131
|
|
|
139
132
|
if (options.repo) {
|
|
140
133
|
log("using provided Repo");
|
|
141
134
|
repo = options.repo;
|
|
142
135
|
hive = options.hive;
|
|
143
136
|
} else {
|
|
144
|
-
|
|
145
|
-
if (onWorkerPortRenewed) onWorkerPortRenewed(port);
|
|
146
|
-
else {
|
|
147
|
-
console.warn(
|
|
148
|
-
"automerge worker port renewed before the repo existed; dropping it"
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
154
|
-
({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
|
|
155
|
-
|
|
156
|
-
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
157
|
-
// port and drop the adapter stranded on the dead one.
|
|
158
|
-
const bootHive = hive;
|
|
159
|
-
onWorkerPortRenewed = (port) => {
|
|
160
|
-
const fresh = new MessageChannelNetworkAdapter(port);
|
|
161
|
-
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
162
|
-
// keyhive adapter wrapped around the message channel.
|
|
163
|
-
const registered = bootHive
|
|
164
|
-
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
165
|
-
: fresh;
|
|
166
|
-
repo.networkSubsystem.addNetworkAdapter(registered as any);
|
|
167
|
-
removeAdapterFor(repo, workerAdapter, registered);
|
|
168
|
-
workerAdapter = fresh;
|
|
169
|
-
lifecycleLog("repo re-wired to the recreated automerge worker");
|
|
170
|
-
};
|
|
137
|
+
({ repo, hive, signerIdentity } = await createRepo());
|
|
171
138
|
}
|
|
172
139
|
|
|
173
140
|
// Dev-console / tool-runtime globals (e2e and loaded tools read these). The
|
|
@@ -179,8 +146,6 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
|
|
|
179
146
|
AutomergeRepo as typeof import("@automerge/automerge-repo");
|
|
180
147
|
if (hive) window.hive = hive;
|
|
181
148
|
|
|
182
|
-
await repo.networkSubsystem.whenReady();
|
|
183
|
-
log("networkSubsystem ready");
|
|
184
149
|
(hive?.networkAdapter as any)?.syncKeyhive?.();
|
|
185
150
|
|
|
186
151
|
registerRepoProviderElement(repo as any);
|
|
@@ -259,14 +224,13 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
|
|
|
259
224
|
plugins,
|
|
260
225
|
sw: {
|
|
261
226
|
connectClassicSync: sw.connectClassicSync,
|
|
262
|
-
|
|
263
|
-
|
|
227
|
+
subscribeSyncState: (documentId, listener) =>
|
|
228
|
+
subscribeSyncState(repo, documentId, listener),
|
|
264
229
|
},
|
|
265
230
|
|
|
266
231
|
async create<D>(type: string, init?: (doc: D) => void) {
|
|
267
|
-
const datatype =
|
|
268
|
-
"patchwork:datatype"
|
|
269
|
-
).load(type);
|
|
232
|
+
const datatype =
|
|
233
|
+
await getRegistry<DatatypeDescription>("patchwork:datatype").load(type);
|
|
270
234
|
if (!datatype) {
|
|
271
235
|
throw new Error(
|
|
272
236
|
`patchwork.create: no datatype registered for "${type}"`
|
|
@@ -432,6 +396,50 @@ function installLifecycleLogging(): void {
|
|
|
432
396
|
);
|
|
433
397
|
}
|
|
434
398
|
|
|
399
|
+
// The tab's own Repo hears the server's heads directly, so this is a filter
|
|
400
|
+
// over its `subduction-remote-heads` event. The current value is replayed
|
|
401
|
+
// from the handle's sync info, when the server has reported any.
|
|
402
|
+
function subscribeSyncState(
|
|
403
|
+
repo: Repo,
|
|
404
|
+
documentId: string,
|
|
405
|
+
listener: (update: SyncStateDocMessage) => void
|
|
406
|
+
): () => void {
|
|
407
|
+
const onHeads = (payload: {
|
|
408
|
+
documentId: string;
|
|
409
|
+
storageId: string;
|
|
410
|
+
heads: readonly string[];
|
|
411
|
+
timestamp: number;
|
|
412
|
+
}) => {
|
|
413
|
+
if (payload.documentId !== documentId) return;
|
|
414
|
+
listener({
|
|
415
|
+
type: "sync-state",
|
|
416
|
+
documentId,
|
|
417
|
+
storageId: payload.storageId,
|
|
418
|
+
heads: [...payload.heads],
|
|
419
|
+
timestamp: payload.timestamp,
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
repo.on("subduction-remote-heads", onHeads);
|
|
423
|
+
|
|
424
|
+
void (async () => {
|
|
425
|
+
const handle = repo.handles[documentId as DocumentId];
|
|
426
|
+
if (!handle) return;
|
|
427
|
+
for (const storageId of await repo.connectedSubductionPeerIds()) {
|
|
428
|
+
const info = handle.getSyncInfo(storageId as StorageId);
|
|
429
|
+
if (info) {
|
|
430
|
+
onHeads({
|
|
431
|
+
documentId,
|
|
432
|
+
storageId,
|
|
433
|
+
heads: info.lastHeads,
|
|
434
|
+
timestamp: info.lastSyncTimestamp,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
439
|
+
|
|
440
|
+
return () => repo.off("subduction-remote-heads", onHeads);
|
|
441
|
+
}
|
|
442
|
+
|
|
435
443
|
// ── Named exports ────────────────────────────────────────────────────────
|
|
436
444
|
|
|
437
445
|
export { createRepo, initWasm } from "./repo.js";
|
|
@@ -447,4 +455,5 @@ export type {
|
|
|
447
455
|
PatchworkOptions,
|
|
448
456
|
ServiceWorkerApi,
|
|
449
457
|
SignerIdentity,
|
|
458
|
+
SyncStateDocMessage,
|
|
450
459
|
} from "./types.js";
|
package/src/repo.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
initializeWasm,
|
|
3
|
-
MessageChannelNetworkAdapter,
|
|
4
|
-
Repo,
|
|
5
|
-
type AutomergeUrl,
|
|
6
|
-
} from "@automerge/vanillajs/slim";
|
|
1
|
+
import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
|
|
7
2
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
3
|
+
import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
|
|
8
4
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
9
5
|
import {
|
|
10
6
|
initKeyhiveWasm,
|
|
11
|
-
|
|
7
|
+
initializeAutomergeRepoKeyhive,
|
|
12
8
|
type AutomergeRepoKeyhive,
|
|
13
9
|
type SyncServerSelection,
|
|
14
10
|
} from "@automerge/automerge-repo-keyhive";
|
|
@@ -16,7 +12,6 @@ import {
|
|
|
16
12
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
17
13
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
18
14
|
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
19
|
-
import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
|
|
20
15
|
import {
|
|
21
16
|
keyhiveStorageName,
|
|
22
17
|
storagePrefix,
|
|
@@ -53,28 +48,33 @@ export function initWasm(): Promise<void> {
|
|
|
53
48
|
return wasmReady;
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
export
|
|
57
|
-
workerAdapter: MessageChannelNetworkAdapter
|
|
58
|
-
): Promise<{
|
|
51
|
+
export type TabRepo = {
|
|
59
52
|
repo: Repo;
|
|
60
53
|
hive?: AutomergeRepoKeyhive;
|
|
61
54
|
signerIdentity?: SignerIdentity;
|
|
62
|
-
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The tab's own node: this origin's IndexedDB, a socket to the sync server,
|
|
59
|
+
* and the siblings channel to every other Repo on the origin. Nothing is
|
|
60
|
+
* shared with other tabs except the database underneath.
|
|
61
|
+
*/
|
|
62
|
+
export async function createRepo(): Promise<TabRepo> {
|
|
63
63
|
if (syncServer.keyhive) {
|
|
64
64
|
log("setting up keyhive");
|
|
65
65
|
initKeyhiveWasm();
|
|
66
|
-
const { hive, repo } = await
|
|
66
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhive({
|
|
67
67
|
createRepo: (repoConfig) => new Repo(repoConfig),
|
|
68
68
|
storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
|
|
69
69
|
peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
|
|
70
|
-
networkAdapter: workerAdapter,
|
|
71
70
|
automaticArchiveIngestion: true,
|
|
72
71
|
cachingMode: "periodic",
|
|
73
|
-
onlyShareWithHardcodedServerPeerId: false,
|
|
74
72
|
// ARK selects the relay via `syncServer`, defaulting to "subduction".
|
|
75
73
|
syncServer: syncServer.keyhive,
|
|
76
74
|
repo: {
|
|
77
75
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
76
|
+
subductionWebsocketEndpoints: [syncServer.url],
|
|
77
|
+
subductionAdapters: siblingAdapters(),
|
|
78
78
|
enableRemoteHeadsGossiping: true,
|
|
79
79
|
},
|
|
80
80
|
});
|
|
@@ -82,20 +82,17 @@ export async function createRepo(
|
|
|
82
82
|
return { repo, hive };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
//
|
|
86
|
-
// identity can be
|
|
87
|
-
// Subduction, so this id never goes on the wire.
|
|
85
|
+
// The signer is explicit rather than the Repo's internal default so the
|
|
86
|
+
// identity the tab presents to the server can be shown on window.patchwork.
|
|
88
87
|
const signer = new MemorySigner();
|
|
89
88
|
const repo = new Repo({
|
|
90
|
-
network: [workerAdapter],
|
|
91
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
92
89
|
signer,
|
|
93
|
-
|
|
94
|
-
return peerId.includes("automerge-worker");
|
|
95
|
-
},
|
|
96
|
-
enableRemoteHeadsGossiping: true,
|
|
90
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
97
91
|
peerId:
|
|
98
92
|
`${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
|
|
93
|
+
subductionWebsocketEndpoints: [syncServer.url],
|
|
94
|
+
subductionAdapters: siblingAdapters(),
|
|
95
|
+
enableRemoteHeadsGossiping: true,
|
|
99
96
|
});
|
|
100
97
|
const signerIdentity = {
|
|
101
98
|
peerId: signer.peerId().toString(),
|
|
@@ -108,46 +105,3 @@ export async function createRepo(
|
|
|
108
105
|
log("repo created, tab subduction identity:", signerIdentity);
|
|
109
106
|
return { repo, signerIdentity };
|
|
110
107
|
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Resolve with the first repo port the worker delivers, calling `onRenewed` for
|
|
114
|
-
* every later one.
|
|
115
|
-
*
|
|
116
|
-
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
117
|
-
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
118
|
-
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
119
|
-
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
120
|
-
* a good port long before that timeout.
|
|
121
|
-
*/
|
|
122
|
-
export function firstRepoPort(
|
|
123
|
-
sw: Awaited<ReturnType<typeof setupServiceWorker>>,
|
|
124
|
-
onRenewed: (port: MessagePort) => void
|
|
125
|
-
): Promise<MessagePort> {
|
|
126
|
-
return new Promise<MessagePort>((resolve) => {
|
|
127
|
-
let seen = false;
|
|
128
|
-
void sw.subscribeToRepoChannel((port) => {
|
|
129
|
-
if (seen) return onRenewed(port);
|
|
130
|
-
seen = true;
|
|
131
|
-
resolve(port);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
137
|
-
export function removeAdapterFor(
|
|
138
|
-
repo: Repo,
|
|
139
|
-
stale: MessageChannelNetworkAdapter,
|
|
140
|
-
keep: unknown
|
|
141
|
-
): void {
|
|
142
|
-
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
143
|
-
if (adapter === keep) continue;
|
|
144
|
-
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
145
|
-
const base = (adapter as any).networkAdapter ?? adapter;
|
|
146
|
-
if (base !== stale) continue;
|
|
147
|
-
try {
|
|
148
|
-
repo.networkSubsystem.removeNetworkAdapter(adapter as any);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
console.error("failed to remove stale worker network adapter", err);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -2,9 +2,10 @@ import type { PatchworkSiteOptions } from "./options.js";
|
|
|
2
2
|
import type { SyncServerSelection } from "@automerge/automerge-repo-keyhive";
|
|
3
3
|
|
|
4
4
|
// Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER
|
|
5
|
-
// and automerge-worker.ts's SUBDUCTION_SYNC_URL selection —
|
|
6
|
-
// plain constants (rather than importing those runtime modules)
|
|
7
|
-
// only needs the hostnames, not the browser-only logic that reads
|
|
5
|
+
// and automerge-protocol-handler-worker.ts's SUBDUCTION_SYNC_URL selection —
|
|
6
|
+
// kept here as plain constants (rather than importing those runtime modules)
|
|
7
|
+
// since this only needs the hostnames, not the browser-only logic that reads
|
|
8
|
+
// them.
|
|
8
9
|
export const DEFAULT_SYNC_SERVERS = {
|
|
9
10
|
classic: "wss://sync3.automerge.org",
|
|
10
11
|
subduction: "wss://subduction.sync.inkandswitch.com",
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AutomergeUrl, DocHandle, Repo } from "@automerge/vanillajs/slim";
|
|
2
|
-
import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
2
|
+
import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
|
|
3
3
|
import type {
|
|
4
4
|
ModuleWatcher,
|
|
5
5
|
HasPatchworkMetadata,
|
|
@@ -8,21 +8,27 @@ import type {
|
|
|
8
8
|
AccountCreator,
|
|
9
9
|
AccountDoc,
|
|
10
10
|
} from "@inkandswitch/patchwork-plugins";
|
|
11
|
-
import type {
|
|
12
|
-
ServiceWorkerRepoChannelListener,
|
|
13
|
-
SyncStateDocMessage,
|
|
14
|
-
} from "@inkandswitch/patchwork-bootloader/types";
|
|
15
11
|
import type * as pluginsNS from "@inkandswitch/patchwork-plugins";
|
|
16
12
|
|
|
17
13
|
export type PluginsApi = typeof pluginsNS;
|
|
18
14
|
|
|
19
15
|
export type SignerIdentity = { peerId: string; verifyingKey: string };
|
|
20
16
|
|
|
17
|
+
/** The sync server's last-known heads for one document, as it reports them. */
|
|
18
|
+
export interface SyncStateDocMessage {
|
|
19
|
+
type: "sync-state";
|
|
20
|
+
documentId: string;
|
|
21
|
+
storageId: string;
|
|
22
|
+
heads: string[];
|
|
23
|
+
timestamp: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
export interface ServiceWorkerApi {
|
|
22
27
|
connectClassicSync: (server?: string) => Promise<void>;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Watch one document's heads at the sync server. Calls `listener` with
|
|
30
|
+
* what the server holds now, then on every update. Returns unsubscribe.
|
|
31
|
+
*/
|
|
26
32
|
subscribeSyncState: (
|
|
27
33
|
documentId: string,
|
|
28
34
|
listener: (update: SyncStateDocMessage) => void
|
|
@@ -91,7 +97,8 @@ export interface PatchworkOptions {
|
|
|
91
97
|
/**
|
|
92
98
|
* Bring your own Repo. When provided, setup skips wasm initialization and
|
|
93
99
|
* repo creation entirely — you are responsible for having initialized
|
|
94
|
-
* automerge/subduction and wired the automerge-worker
|
|
100
|
+
* automerge/subduction and wired the automerge-protocol-handler-worker
|
|
101
|
+
* port yourself.
|
|
95
102
|
*/
|
|
96
103
|
repo?: Repo;
|
|
97
104
|
|
|
@@ -40,9 +40,7 @@ export function buildDefines(
|
|
|
40
40
|
* used to hand-write in its own vite.config.ts. Each is switched off
|
|
41
41
|
* individually via the matching `false` option.
|
|
42
42
|
*/
|
|
43
|
-
export function config(
|
|
44
|
-
options: PatchworkVitePluginOptions = {}
|
|
45
|
-
): Plugin {
|
|
43
|
+
export function config(options: PatchworkVitePluginOptions = {}): Plugin {
|
|
46
44
|
return {
|
|
47
45
|
name: "@patchwork/config",
|
|
48
46
|
config() {
|
|
@@ -87,11 +85,11 @@ export function config(
|
|
|
87
85
|
},
|
|
88
86
|
};
|
|
89
87
|
},
|
|
90
|
-
// The shared automerge-worker's chunk imports bypass the
|
|
91
|
-
// worker, so offline boot needs the browser's HTTP cache to
|
|
92
|
-
// without revalidating. Content hashes make that safe; a new
|
|
93
|
-
// new URLs. Production gets this from the generated _headers
|
|
94
|
-
// preview doesn't read that, so mirror it here.
|
|
88
|
+
// The shared automerge-protocol-handler-worker's chunk imports bypass the
|
|
89
|
+
// page's service worker, so offline boot needs the browser's HTTP cache to
|
|
90
|
+
// serve them without revalidating. Content hashes make that safe; a new
|
|
91
|
+
// build gets new URLs. Production gets this from the generated _headers
|
|
92
|
+
// file — vite preview doesn't read that, so mirror it here.
|
|
95
93
|
configurePreviewServer(server) {
|
|
96
94
|
if (
|
|
97
95
|
options.netlify === false ||
|
|
@@ -101,10 +99,7 @@ export function config(
|
|
|
101
99
|
}
|
|
102
100
|
server.middlewares.use((req, res, next) => {
|
|
103
101
|
if (req.url?.startsWith("/assets/")) {
|
|
104
|
-
res.setHeader(
|
|
105
|
-
"Cache-Control",
|
|
106
|
-
"public, max-age=31536000, immutable"
|
|
107
|
-
);
|
|
102
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
108
103
|
}
|
|
109
104
|
next();
|
|
110
105
|
});
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
PatchworkVitePluginOptions,
|
|
5
5
|
} from "./patchwork-plugin.js";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { relative } from "node:path";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* bootloader owns resolving/emitting its own dependencies (it's the one that
|
|
@@ -115,11 +116,22 @@ export function importmap(options?: PatchworkVitePluginOptions): Plugin {
|
|
|
115
116
|
},
|
|
116
117
|
transformIndexHtml: {
|
|
117
118
|
order: "pre",
|
|
118
|
-
handler(html, context) {
|
|
119
|
+
async handler(html, context) {
|
|
119
120
|
const activeImportmap = structuredClone(importmap);
|
|
120
121
|
if (context.server) {
|
|
122
|
+
// The importmap has to name the same URL the site's own imports get
|
|
123
|
+
// rewritten to, which is the pre-bundled dep. `/@id/<dep>` resolves
|
|
124
|
+
// to the same file but is a second URL, and a module fetched under
|
|
125
|
+
// two URLs is evaluated twice — two solids, two automerges.
|
|
126
|
+
const optimizer = context.server.environments.client.depsOptimizer;
|
|
127
|
+
await optimizer?.init();
|
|
128
|
+
const root = context.server.config.root;
|
|
121
129
|
for (const id of Object.keys(builtins)) {
|
|
122
|
-
|
|
130
|
+
const dependency = devDependencyId(id);
|
|
131
|
+
const optimized = optimizer?.metadata.optimized[dependency];
|
|
132
|
+
activeImportmap.imports[id] = optimized
|
|
133
|
+
? `/${relative(root, optimized.file)}?v=${optimized.browserHash}`
|
|
134
|
+
: `/@id/${dependency}`;
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
return {
|
|
@@ -19,8 +19,9 @@ export const workers = [
|
|
|
19
19
|
fileName: "service-worker.js",
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
specifier:
|
|
23
|
-
|
|
22
|
+
specifier:
|
|
23
|
+
"@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker",
|
|
24
|
+
fileName: "automerge-protocol-handler-worker.js",
|
|
24
25
|
},
|
|
25
26
|
{
|
|
26
27
|
specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
|