Initial Commit
This commit is contained in:
149
packages/networking/network/network-discovery-crdt/README.md
Normal file
149
packages/networking/network/network-discovery-crdt/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@suaveplan/network-discovery-crdt",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"browser": "./dist/index.js",
|
||||
"description": "Browser-tier CRDT DiscoveryStrategy: dual Yjs / Automerge awareness backends behind a single CRDTDiscovery class. Produces deduped PeerInfo[] for @suaveplan/network-bootstrap chains with @suaveplan/events, @suaveplan/timing, AbortSignal, OTel and Zod boundaries.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./types": {
|
||||
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
|
||||
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
|
||||
},
|
||||
"./crdt-discovery": {
|
||||
"import": { "types": "./dist/core/crdt-discovery/crdt-discovery.d.ts", "default": "./dist/core/crdt-discovery/crdt-discovery.js" },
|
||||
"require": { "types": "./dist/core/crdt-discovery/crdt-discovery.d.ts", "default": "./dist/core/crdt-discovery/crdt-discovery.js" }
|
||||
},
|
||||
"./crdt-peer-manager": {
|
||||
"import": { "types": "./dist/core/crdt-peer-manager/crdt-peer-manager.d.ts", "default": "./dist/core/crdt-peer-manager/crdt-peer-manager.js" },
|
||||
"require": { "types": "./dist/core/crdt-peer-manager/crdt-peer-manager.d.ts", "default": "./dist/core/crdt-peer-manager/crdt-peer-manager.js" }
|
||||
},
|
||||
"./crdt-intervals": {
|
||||
"import": { "types": "./dist/core/crdt-intervals/crdt-intervals.d.ts", "default": "./dist/core/crdt-intervals/crdt-intervals.js" },
|
||||
"require": { "types": "./dist/core/crdt-intervals/crdt-intervals.d.ts", "default": "./dist/core/crdt-intervals/crdt-intervals.js" }
|
||||
},
|
||||
"./yjs-provider": {
|
||||
"import": { "types": "./dist/core/yjs-provider/yjs-provider.d.ts", "default": "./dist/core/yjs-provider/yjs-provider.js" },
|
||||
"require": { "types": "./dist/core/yjs-provider/yjs-provider.d.ts", "default": "./dist/core/yjs-provider/yjs-provider.js" }
|
||||
},
|
||||
"./automerge-provider": {
|
||||
"import": { "types": "./dist/core/automerge-provider/automerge-provider.d.ts", "default": "./dist/core/automerge-provider/automerge-provider.js" },
|
||||
"require": { "types": "./dist/core/automerge-provider/automerge-provider.d.ts", "default": "./dist/core/automerge-provider/automerge-provider.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/network": "workspace:^",
|
||||
"@suaveplan/network-bootstrap": "workspace:^",
|
||||
"@suaveplan/service-discovery": "workspace:^",
|
||||
"@suaveplan/timing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "catalog:network-discovery",
|
||||
"y-webrtc": "catalog:network-discovery",
|
||||
"@automerge/automerge-repo": "catalog:network-discovery",
|
||||
"@automerge/automerge-repo-network-broadcastchannel": "catalog:network-discovery",
|
||||
"@automerge/automerge-repo-network-websocket": "catalog:network-discovery",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "catalog:network-discovery"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"yjs": { "optional": true },
|
||||
"y-webrtc": { "optional": true },
|
||||
"@automerge/automerge-repo": { "optional": true },
|
||||
"@automerge/automerge-repo-network-broadcastchannel": { "optional": true },
|
||||
"@automerge/automerge-repo-network-websocket": { "optional": true },
|
||||
"@automerge/automerge-repo-storage-indexeddb": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# automerge-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Automerge-backed implementation of the `CRDTProviderAdapter` contract. Wraps an `AutomergeRepoLike` (built by the caller-supplied `AutomergeRepoFactory`) that hides the layered network-adapter setup (`BroadcastChannelNetworkAdapter`, `BrowserWebSocketClientAdapter`) plus the optional `IndexedDBStorageAdapter` from the orchestrator. The orchestrator only ever talks to the structural surface; production wires the factory to a real `@automerge/automerge-repo` `Repo`, and tests wire a deterministic in-memory `FakeAutomergeRepo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure adapter — never reaches into `@automerge/automerge-repo` directly.
|
||||
- Repo `peer` event fans out into `onPeer` callback for every advertised peer the repo currently knows about (the structural `listPeers()` call lets the orchestrator stay synchronous with no awareness payloads of its own).
|
||||
- Repo `peer-disconnected` event mirrors into `onPeerLost`.
|
||||
- Initial `onSynced()` + a snapshot flush of `listPeers()` so a late-joining peer immediately sees the full set.
|
||||
- Idempotent `destroy()` calls `repo.shutdown()` and swallows individual library errors.
|
||||
- Factory wrapped in try / catch — failures map to `AUTOMERGE_SYNC_FAILED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{ enableBroadcastChannel: true },
|
||||
(room, opts) => makeFakeRepo(room, opts)
|
||||
);
|
||||
|
||||
const myId = await adapter.initialize({
|
||||
onPeer: (m) => console.log("peer", m.id),
|
||||
onPeerLost: (id) => console.log("lost", id),
|
||||
onSynced: () => console.log("synced"),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
import { Repo } from "@automerge/automerge-repo";
|
||||
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
|
||||
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
||||
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
|
||||
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"team-A",
|
||||
{
|
||||
websocketUrl: "wss://sync.internal.example",
|
||||
enableBroadcastChannel: true,
|
||||
enableIndexedDB: true,
|
||||
storageKey: "team-a",
|
||||
},
|
||||
(room, opts) => {
|
||||
const network = [];
|
||||
if (opts.enableBroadcastChannel !== false)
|
||||
network.push(new BroadcastChannelNetworkAdapter());
|
||||
if (opts.websocketUrl)
|
||||
network.push(new BrowserWebSocketClientAdapter(opts.websocketUrl));
|
||||
const storage = opts.enableIndexedDB !== false
|
||||
? new IndexedDBStorageAdapter(opts.storageKey ?? "automerge-discovery")
|
||||
: undefined;
|
||||
const repo = new Repo({ network, storage });
|
||||
return wrapAsRepoLike(repo, room);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new AutomergeProviderAdapter(room, opts, factory)`** — `opts: AutomergeConfig`, `factory: AutomergeRepoFactory`.
|
||||
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(AUTOMERGE_SYNC_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
|
||||
- **`announce(metadata)`** — delegates to `repo.announce(metadata)`.
|
||||
- **`leave()`** — no-op (Automerge has no first-class leave; remote peers drop us via the cleanup-loop TTL).
|
||||
- **`listKnownPeers()`** — snapshot of `repo.listPeers()`.
|
||||
- **`destroy()`** — `repo.shutdown()`; idempotent, swallows errors.
|
||||
- **`isConnected: boolean`** — `true` while the repo is alive.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Automerge's `Repo` does not expose a first-class peer-list at the time of this migration — the structural `listPeers()` on `AutomergeRepoLike` is the contract the production factory has to synthesise from `Repo` internals, and the test fake implements directly. The `leave()` no-op is deliberate: Automerge does not have a notion of "I am leaving the swarm" the way `y-webrtc` does, and crafting one synthetically (e.g. by writing a tombstone) would conflict with the `Repo`'s own GC semantics. Stale peers are eventually dropped by the cleanup loop in `CRDTIntervals`. The `factory` is the only point of contact with the real Automerge libraries, which keeps the adapter shippable as a universal package — no `import "@automerge/automerge-repo"` ever fires at module-load time.
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeAutomergeBroker,
|
||||
fakeAutomergeRepoFactory,
|
||||
} from "../../../tests/setup/fake-automerge.js";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
CRDTProviderCallbacks,
|
||||
} from "../../types/types.js";
|
||||
import { AutomergeProviderAdapter } from "./automerge-provider.js";
|
||||
|
||||
function makeCallbacks() {
|
||||
const seen: CRDTPeerMetadata[] = [];
|
||||
const lost: string[] = [];
|
||||
let syncs = 0;
|
||||
const callbacks: CRDTProviderCallbacks = {
|
||||
onPeer: (m) => seen.push(m),
|
||||
onPeerLost: (id) => lost.push(id),
|
||||
onSynced: () => {
|
||||
syncs += 1;
|
||||
},
|
||||
};
|
||||
return { callbacks, seen, lost, syncCount: () => syncs };
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function makePeer(id: string): CRDTPeerMetadata {
|
||||
return {
|
||||
id,
|
||||
addresses: [`addr-${id}`],
|
||||
metadata: {},
|
||||
lastSeen: 1,
|
||||
provider: "automerge",
|
||||
};
|
||||
}
|
||||
|
||||
describe("AutomergeProviderAdapter.initialize", () => {
|
||||
it("returns peer id and fires synced", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const { callbacks, syncCount } = makeCallbacks();
|
||||
const id = await adapter.initialize(callbacks);
|
||||
expect(id).toMatch(/^fake-am-/);
|
||||
expect(syncCount()).toBe(1);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("maps factory failure to AUTOMERGE_SYNC_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
broker.broken = true;
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(adapter.initialize(callbacks)).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with DISPOSED after destroy", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
adapter.destroy();
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(adapter.initialize(callbacks)).rejects.toBeInstanceOf(
|
||||
NetDiscCRDTError
|
||||
);
|
||||
});
|
||||
|
||||
it("honours pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(
|
||||
adapter.initialize(callbacks, ctrl.signal)
|
||||
).rejects.toBeDefined();
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("forges AbortError when signal.reason is non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
Object.defineProperty(ctrl.signal, "aborted", { value: true });
|
||||
Object.defineProperty(ctrl.signal, "reason", { value: "string" });
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(
|
||||
adapter.initialize(callbacks, ctrl.signal)
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomergeProviderAdapter cross-peer awareness", () => {
|
||||
it("propagates announces between adapters", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const factory = fakeAutomergeRepoFactory(broker);
|
||||
const a = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const b = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
a.announce(makePeer("a-self"));
|
||||
await flushMicrotasks();
|
||||
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("forwards peer-disconnected to onPeerLost", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const factory = fakeAutomergeRepoFactory(broker);
|
||||
const a = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const b = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
const idA = await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
a.destroy();
|
||||
await flushMicrotasks();
|
||||
expect(cbB.lost).toContain(idA);
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("listKnownPeers + isConnected work end-to-end", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const factory = fakeAutomergeRepoFactory(broker);
|
||||
const a = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const b = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
a.announce(makePeer("a-self"));
|
||||
await flushMicrotasks();
|
||||
expect(b.listKnownPeers().some((p) => p.id === "a-self")).toBe(true);
|
||||
expect(a.isConnected).toBe(true);
|
||||
a.destroy();
|
||||
expect(a.isConnected).toBe(false);
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("leave is a no-op", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
adapter.leave();
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
adapter.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomergeProviderAdapter edge cases", () => {
|
||||
it("announce / leave / listKnownPeers before init are safe", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
adapter.announce(makePeer("x"));
|
||||
adapter.leave();
|
||||
expect(adapter.listKnownPeers()).toEqual([]);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("destroy is idempotent", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const adapter = new AutomergeProviderAdapter(
|
||||
"demo",
|
||||
{},
|
||||
fakeAutomergeRepoFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
adapter.destroy();
|
||||
adapter.destroy();
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("initialize flushes pre-existing known peers via listPeers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const factory = fakeAutomergeRepoFactory(broker);
|
||||
const a = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbA = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
a.announce(makePeer("a-payload"));
|
||||
await flushMicrotasks();
|
||||
// b joins late — its `listPeers()` already includes a-payload thanks
|
||||
// to the fake broker's replay, exercising the for-of body in initialize.
|
||||
const b = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbB = makeCallbacks();
|
||||
await b.initialize(cbB.callbacks);
|
||||
expect(cbB.seen.some((p) => p.id === "a-payload")).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("peer event with known metadata routes through onPeer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const factory = fakeAutomergeRepoFactory(broker);
|
||||
const a = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const b = new AutomergeProviderAdapter("demo", {}, factory);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
// b announces — broker stores in a.known + fires peer event on a.
|
||||
b.announce(makePeer("b-payload"));
|
||||
await flushMicrotasks();
|
||||
expect(cbA.seen.some((p) => p.id === "b-payload")).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type {
|
||||
AutomergeConfig,
|
||||
AutomergeRepoFactory,
|
||||
AutomergeRepoLike,
|
||||
CRDTPeerMetadata,
|
||||
CRDTProviderAdapter,
|
||||
CRDTProviderCallbacks,
|
||||
} from "../../types/types.js";
|
||||
|
||||
/**
|
||||
* Automerge-backed `CRDTProviderAdapter`. Wraps an `AutomergeRepoLike` built
|
||||
* by the caller-supplied `AutomergeRepoFactory`. Owns the repo peer-event
|
||||
* handlers and the destroy semantics.
|
||||
*/
|
||||
export class AutomergeProviderAdapter implements CRDTProviderAdapter {
|
||||
public readonly kind = "automerge" as const;
|
||||
|
||||
private readonly room: string;
|
||||
private readonly opts: AutomergeConfig;
|
||||
private readonly factory: AutomergeRepoFactory;
|
||||
|
||||
private repo: AutomergeRepoLike | null = null;
|
||||
private myPeerId: string | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
room: string,
|
||||
opts: AutomergeConfig,
|
||||
factory: AutomergeRepoFactory
|
||||
) {
|
||||
this.room = room;
|
||||
this.opts = opts;
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
public async initialize(
|
||||
callbacks: CRDTProviderCallbacks,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
if (signal?.aborted) throw toAbortError(signal);
|
||||
if (this.destroyed) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed"
|
||||
);
|
||||
}
|
||||
let built: AutomergeRepoLike;
|
||||
try {
|
||||
built = this.factory(this.room, this.opts);
|
||||
} catch (err) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED,
|
||||
"errors.crdt.automerge_init_failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
this.repo = built;
|
||||
this.myPeerId = built.peerId;
|
||||
|
||||
built.on("peer", (e) => {
|
||||
const matches = built.listPeers().filter((p) => p.id === e.peerId);
|
||||
if (matches.length > 0) {
|
||||
for (const m of matches) callbacks.onPeer(m);
|
||||
}
|
||||
});
|
||||
built.on("peer-disconnected", (e) => {
|
||||
callbacks.onPeerLost(e.peerId);
|
||||
});
|
||||
|
||||
// Automerge repos are "synced" once at least one network adapter is
|
||||
// ready; we surface that immediately so the orchestrator can flush.
|
||||
callbacks.onSynced();
|
||||
for (const peer of built.listPeers()) callbacks.onPeer(peer);
|
||||
|
||||
return this.myPeerId;
|
||||
}
|
||||
|
||||
public announce(metadata: CRDTPeerMetadata): void {
|
||||
if (this.repo === null) return;
|
||||
this.repo.announce(metadata);
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
if (this.repo === null || this.myPeerId === null) return;
|
||||
// Best-effort — Automerge has no first-class "leave"; the cleanup
|
||||
// loop on remote peers will eventually drop us via `lastSeen` TTL.
|
||||
}
|
||||
|
||||
public listKnownPeers(): readonly CRDTPeerMetadata[] {
|
||||
if (this.repo === null) return [];
|
||||
return this.repo.listPeers();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (this.repo !== null) {
|
||||
try {
|
||||
this.repo.shutdown();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
this.repo = null;
|
||||
}
|
||||
this.myPeerId = null;
|
||||
}
|
||||
|
||||
public get isConnected(): boolean {
|
||||
return this.repo !== null;
|
||||
}
|
||||
}
|
||||
|
||||
function toAbortError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AutomergeProviderAdapter } from "./automerge-provider.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# crdt-discovery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Orchestrator class for `@suaveplan/network-discovery-crdt`. Implements `DiscoveryStrategy` from `@suaveplan/network-bootstrap` so a `CRDTDiscovery` instance plugs into a bootstrap chain unchanged. Owns the lifecycle (`start` / `stop` / `dispose`), wires the configured provider adapter (Yjs or Automerge) into the shared `CRDTPeerManager` + `CRDTIntervals` infrastructure, and translates between the package-private `CRDTPeerMetadata` shape and the bootstrap-canonical `PeerInfo` shape on `discover()`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict construction-time provider selection (`provider: "yjs" | "automerge"`) — never branches on provider strings at runtime once constructed.
|
||||
- Zod-validated `CRDTDiscoveryConfig` with per-provider gates (`yjs` block + `yjsDocFactory` are mandatory when `provider === "yjs"`; same for automerge).
|
||||
- AbortSignal threaded through every async path (`start` / `stop` / `discover` / `isAvailable` / `announce`).
|
||||
- Optional `EventBusLike<CRDTEventMap>` integration — emits `crdt:started`, `crdt:peer-discovered`, `crdt:peer-lost`, `crdt:sync-failed`.
|
||||
- Optional `@suaveplan/service-discovery` `ServiceRegistry` integration — discovered peers auto-register / auto-deregister.
|
||||
- Idempotent `start()` / `stop()` / `dispose()`; `dispose()` cannot be undone.
|
||||
- OTel spans (`disc_crdt.start`, `disc_crdt.provider.<yjs|automerge>.sync`) + counters / histograms.
|
||||
- Status snapshots (`getSyncStatus`, `getStatus`) for dashboards.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const discovery = new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://signal.local"] },
|
||||
yjsDocFactory: (room, opts) => ({
|
||||
doc: makeDoc(room),
|
||||
provider: makeProvider(opts),
|
||||
}),
|
||||
});
|
||||
await discovery.start();
|
||||
const peers = await discovery.discover(1000);
|
||||
await discovery.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const discovery = new CRDTDiscovery({
|
||||
provider: "automerge",
|
||||
room: "team-A",
|
||||
automerge: { websocketUrl: "wss://sync.example", enableIndexedDB: true },
|
||||
automergeRepoFactory: (room, opts) => makeAutomergeRepo(room, opts),
|
||||
interval: 5_000,
|
||||
timeout: 30_000,
|
||||
bus,
|
||||
registry,
|
||||
});
|
||||
|
||||
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
|
||||
const ctrl = new AbortController();
|
||||
await discovery.start(ctrl.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTDiscovery(config, clock?)`** — `clock` defaults to `Date.now`. Throws `NetDiscCRDTError(INVALID_OPTIONS)` for any schema failure or missing per-provider factory.
|
||||
- **`start(signal?)`** — initialise adapter + schedule loops; emits `crdt:started`. Throws `DISPOSED` after dispose.
|
||||
- **`stop(signal?)`** — leave + tear down. Idempotent. Pre-aborted signal skips the leave broadcast.
|
||||
- **`discover(timeoutMs, signal?)`** — returns `PeerInfo[]` (bootstrap shape). Throws `DISPOSED` after dispose.
|
||||
- **`isAvailable(signal?)`** — `true` while running + not disposed.
|
||||
- **`announce(peerInfo, signal?)`** — push an explicit announce into the shared CRDT. Throws `PROVIDER_FAILED` when not running.
|
||||
- **`getMyPeerId()` / `getPeers()` / `getPeerCount()` / `getProvider()` / `getSyncStatus()` / `getStatus()`** — read accessors.
|
||||
- **`dispose()`** — terminal; safe to call repeatedly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The construction branch on `provider` is the only place the orchestrator ever sees the provider string. From that point on every call goes through `this.adapter` — switching backends would mean instantiating a new `CRDTDiscovery`, which is intentional: Yjs and Automerge documents are not interchangeable, and a runtime swap would silently lose state. The `discover()` method always returns the snapshot the peer-manager currently holds; awareness updates flow asynchronously through the adapter callbacks, so a caller that needs deterministic ordering should subscribe to `crdt:peer-discovered` on the bus instead of polling `discover()`.
|
||||
|
||||
The optional `bus` and `registry` integrations live in the `CRDTPeerManager`; the orchestrator only owns the lifecycle-level `crdt:started` / `crdt:sync-failed` emissions. `crdt:sync-failed` is reused for two distinct conditions — provider init failure (fatal, surfaces the thrown error reason) and `PEER_REJECTED` from a peer-manager admit (non-fatal, reason `"room_full"`). Consumers branching on it should inspect the reason string.
|
||||
@@ -0,0 +1,584 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
FakeAutomergeBroker,
|
||||
fakeAutomergeRepoFactory,
|
||||
} from "../../../tests/setup/fake-automerge.js";
|
||||
import {
|
||||
FakeYjsBroker,
|
||||
fakeYjsDocFactory,
|
||||
flushMicrotasks,
|
||||
} from "../../../tests/setup/fake-yjs.js";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type { CRDTDiscoveryConfig, CRDTEventMap } from "../../types/types.js";
|
||||
import { CRDTDiscovery } from "./crdt-discovery.js";
|
||||
|
||||
function yjsConfig(
|
||||
broker: FakeYjsBroker,
|
||||
over: Partial<CRDTDiscoveryConfig> = {}
|
||||
): CRDTDiscoveryConfig {
|
||||
return {
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://x.invalid"] },
|
||||
yjsDocFactory: fakeYjsDocFactory(broker),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function automergeConfig(
|
||||
broker: FakeAutomergeBroker,
|
||||
over: Partial<CRDTDiscoveryConfig> = {}
|
||||
): CRDTDiscoveryConfig {
|
||||
return {
|
||||
provider: "automerge",
|
||||
room: "demo",
|
||||
automerge: {},
|
||||
automergeRepoFactory: fakeAutomergeRepoFactory(broker),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CRDTDiscovery constructor — rejection paths", () => {
|
||||
it("rejects malformed config with INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "",
|
||||
yjs: { signaling: ["wss://x"] },
|
||||
yjsDocFactory: () => ({}) as never,
|
||||
} as never);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(NetDiscCRDTError);
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects yjs without yjs block", { timeout: 5000 }, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
let caught: unknown;
|
||||
try {
|
||||
new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjsDocFactory: fakeYjsDocFactory(broker),
|
||||
} as CRDTDiscoveryConfig);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects yjs without yjsDocFactory", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://x"] },
|
||||
} as CRDTDiscoveryConfig);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects automerge without automerge block", { timeout: 5000 }, () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
let caught: unknown;
|
||||
try {
|
||||
new CRDTDiscovery({
|
||||
provider: "automerge",
|
||||
room: "demo",
|
||||
automergeRepoFactory: fakeAutomergeRepoFactory(broker),
|
||||
} as CRDTDiscoveryConfig);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects automerge without automergeRepoFactory", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
new CRDTDiscovery({
|
||||
provider: "automerge",
|
||||
room: "demo",
|
||||
automerge: {},
|
||||
} as CRDTDiscoveryConfig);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("applies Zod defaults", { timeout: 5000 }, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
expect(d.config.interval).toBe(30_000);
|
||||
expect(d.config.timeout).toBe(60_000);
|
||||
expect(d.config.maxPeers).toBe(100);
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("name is 'crdt'", { timeout: 5000 }, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
expect(d.name).toBe("crdt");
|
||||
d.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery lifecycle (Yjs)", () => {
|
||||
it("start emits crdt:started", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const started: string[] = [];
|
||||
bus.on("crdt:started", (e) => started.push(e.peerId));
|
||||
const d = new CRDTDiscovery(yjsConfig(broker, { bus }));
|
||||
await d.start();
|
||||
expect(d.getMyPeerId()).not.toBeNull();
|
||||
expect(started).toHaveLength(1);
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("start is idempotent", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
await d.start();
|
||||
expect(await d.isAvailable()).toBe(true);
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("start with factory throw emits crdt:sync-failed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const reasons: string[] = [];
|
||||
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
|
||||
const d = new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://x.invalid"] },
|
||||
yjsDocFactory: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
bus,
|
||||
});
|
||||
await expect(d.start()).rejects.toBeDefined();
|
||||
expect(reasons.length).toBeGreaterThanOrEqual(1);
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("start throws DISPOSED after dispose", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
d.dispose();
|
||||
await expect(d.start()).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.DISPOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("stop is a no-op when not running", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.stop();
|
||||
expect(await d.isAvailable()).toBe(false);
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("stop with aborted signal skips leave", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await d.stop(ctrl.signal);
|
||||
expect(d.getMyPeerId()).toBeNull();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
d.dispose();
|
||||
d.dispose();
|
||||
expect(await d.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery lifecycle (Automerge)", () => {
|
||||
it("start emits crdt:started with automerge provider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
let captured = "";
|
||||
bus.on("crdt:started", (e) => {
|
||||
captured = e.provider;
|
||||
});
|
||||
const d = new CRDTDiscovery(automergeConfig(broker, { bus }));
|
||||
await d.start();
|
||||
expect(captured).toBe("automerge");
|
||||
expect(d.getProvider()).toBe("automerge");
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("works end-to-end between two automerge peers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const a = new CRDTDiscovery(automergeConfig(broker));
|
||||
const b = new CRDTDiscovery(automergeConfig(broker));
|
||||
await a.start();
|
||||
await b.start();
|
||||
await flushMicrotasks();
|
||||
// a explicitly announces b's id so a's peer-manager learns it.
|
||||
await a.announce({
|
||||
peerId: b.getMyPeerId()!,
|
||||
addresses: ["b-addr"],
|
||||
lastSeen: 1,
|
||||
source: "crdt",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
const peers = await a.discover(1000);
|
||||
expect(peers.map((p) => p.peerId)).toContain(b.getMyPeerId());
|
||||
await a.stop();
|
||||
await b.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery discover + announce + signals", () => {
|
||||
it("discover throws when disposed", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
d.dispose();
|
||||
await expect(d.discover(1000)).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.DISPOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("discover honours pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(d.discover(1000, ctrl.signal)).rejects.toBeDefined();
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("isAvailable honours pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(d.isAvailable(ctrl.signal)).rejects.toBeDefined();
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("announce throws when not running", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await expect(
|
||||
d.announce({
|
||||
peerId: "x",
|
||||
addresses: [],
|
||||
lastSeen: 0,
|
||||
source: "crdt",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.PROVIDER_FAILED,
|
||||
});
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("announce throws DISPOSED after dispose", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
d.dispose();
|
||||
await expect(
|
||||
d.announce({
|
||||
peerId: "x",
|
||||
addresses: [],
|
||||
lastSeen: 0,
|
||||
source: "crdt",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.DISPOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("announce honours pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
d.announce(
|
||||
{ peerId: "x", addresses: [], lastSeen: 0, source: "crdt" },
|
||||
ctrl.signal
|
||||
)
|
||||
).rejects.toBeDefined();
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("forges AbortError when signal.reason is non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
const ctrl = new AbortController();
|
||||
Object.defineProperty(ctrl.signal, "aborted", { value: true });
|
||||
Object.defineProperty(ctrl.signal, "reason", { value: "string" });
|
||||
await expect(d.discover(1000, ctrl.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("integrates with a ServiceRegistry", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const registry = new ServiceRegistry();
|
||||
const a = new CRDTDiscovery(yjsConfig(broker, { registry }));
|
||||
const b = new CRDTDiscovery(yjsConfig(broker));
|
||||
await a.start();
|
||||
await b.start();
|
||||
// b announces; broker propagates to a; a's peer-manager registers.
|
||||
await flushMicrotasks();
|
||||
await b.announce({
|
||||
peerId: b.getMyPeerId()!,
|
||||
addresses: ["b-addr"],
|
||||
lastSeen: 1,
|
||||
source: "crdt",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(registry.get(`crdt:demo:${b.getMyPeerId()}`)).toBeDefined();
|
||||
await a.stop();
|
||||
await b.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery status accessors", () => {
|
||||
it("getStatus / getSyncStatus / getPeerCount work", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
await d.start();
|
||||
const status = d.getStatus();
|
||||
expect(status.provider).toBe("yjs");
|
||||
expect(status.connected).toBe(true);
|
||||
const sync = d.getSyncStatus();
|
||||
expect(sync.synced).toBe(true);
|
||||
expect(d.getPeerCount()).toBe(0);
|
||||
expect(d.getPeers()).toEqual([]);
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("getSyncStatus when not connected", { timeout: 5000 }, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
const sync = d.getSyncStatus();
|
||||
expect(sync.synced).toBe(false);
|
||||
d.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery room-full + interval coverage", () => {
|
||||
it("emits crdt:sync-failed when peer-manager rejects on room-full", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const reasons: string[] = [];
|
||||
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
|
||||
const a = new CRDTDiscovery(
|
||||
automergeConfig(broker, { bus, maxPeers: 1 })
|
||||
);
|
||||
const b = new CRDTDiscovery(automergeConfig(broker));
|
||||
const c = new CRDTDiscovery(automergeConfig(broker));
|
||||
await a.start();
|
||||
await b.start();
|
||||
await c.start();
|
||||
await flushMicrotasks();
|
||||
await b.announce({
|
||||
peerId: b.getMyPeerId()!,
|
||||
addresses: [],
|
||||
lastSeen: 0,
|
||||
source: "crdt",
|
||||
});
|
||||
await c.announce({
|
||||
peerId: c.getMyPeerId()!,
|
||||
addresses: [],
|
||||
lastSeen: 0,
|
||||
source: "crdt",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(reasons).toContain("room_full");
|
||||
await a.stop();
|
||||
await b.stop();
|
||||
await c.stop();
|
||||
});
|
||||
|
||||
it("fires periodic loops with small intervals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(
|
||||
yjsConfig(broker, {
|
||||
interval: 1,
|
||||
heartbeatInterval: 1,
|
||||
cleanupInterval: 1,
|
||||
timeout: 1,
|
||||
})
|
||||
);
|
||||
await d.start();
|
||||
// Seed a stale peer directly so the cleanup loop drops it.
|
||||
const peers = (
|
||||
d as unknown as { peers: { getMap: () => Map<string, unknown> } }
|
||||
).peers.getMap();
|
||||
peers.set("stale", {
|
||||
id: "stale",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: -1_000_000,
|
||||
provider: "yjs",
|
||||
} as never);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(d.getMyPeerId()).not.toBeNull();
|
||||
await d.stop();
|
||||
});
|
||||
|
||||
it("selfMetadata returns empty id before start", { timeout: 5000 }, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const d = new CRDTDiscovery(yjsConfig(broker));
|
||||
const meta = (
|
||||
d as unknown as { selfMetadata: () => { id: string } }
|
||||
).selfMetadata();
|
||||
expect(meta.id).toBe("");
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("start with factory throwing non-Error emits string reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const reasons: string[] = [];
|
||||
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
|
||||
const d = new CRDTDiscovery({
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://x.invalid"] },
|
||||
yjsDocFactory: () => {
|
||||
// Throw a string — not an Error subclass. The orchestrator's
|
||||
// emit must use `String(err)` to surface a reason.
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "stringly-bad";
|
||||
},
|
||||
bus,
|
||||
});
|
||||
await expect(d.start()).rejects.toBeDefined();
|
||||
// The adapter wraps factory throws in NetDiscCRDTError before the
|
||||
// orchestrator's catch — orchestrator sees an Error, but the cause
|
||||
// chain holds the raw string. Reason therefore comes from `.message`,
|
||||
// not the string fallback.
|
||||
expect(reasons).toHaveLength(1);
|
||||
d.dispose();
|
||||
});
|
||||
|
||||
it("re-throws unexpected admit errors from handlePeer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const d = new CRDTDiscovery(automergeConfig(broker));
|
||||
await d.start();
|
||||
// Swap admit with a non-PEER_REJECTED throw — handlePeer must rethrow.
|
||||
const peers = (
|
||||
d as unknown as {
|
||||
peers: { admit: (m: unknown) => unknown };
|
||||
}
|
||||
).peers;
|
||||
const origAdmit = peers.admit.bind(peers);
|
||||
peers.admit = () => {
|
||||
throw new Error("unexpected");
|
||||
};
|
||||
let thrown: unknown;
|
||||
try {
|
||||
(d as unknown as { handlePeer: (m: unknown) => void }).handlePeer({
|
||||
id: "x",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "automerge",
|
||||
});
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect((thrown as Error).message).toBe("unexpected");
|
||||
peers.admit = origAdmit;
|
||||
await d.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTDiscovery property tests", () => {
|
||||
it("peer-dedup is idempotent under arbitrary admit order", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc
|
||||
.string({ minLength: 1, maxLength: 8 })
|
||||
.filter((s) => s.length > 0),
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
),
|
||||
async (ops) => {
|
||||
const broker = new FakeAutomergeBroker();
|
||||
const d = new CRDTDiscovery(automergeConfig(broker));
|
||||
await d.start();
|
||||
for (const op of ops) {
|
||||
await d.announce({
|
||||
peerId: op.id,
|
||||
addresses: [],
|
||||
lastSeen: 0,
|
||||
source: "crdt",
|
||||
});
|
||||
}
|
||||
await flushMicrotasks();
|
||||
await d.stop();
|
||||
}
|
||||
),
|
||||
{ numRuns: 8 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import { withProviderSyncSpan, withStartSpan } from "../../otel/otel.js";
|
||||
import type {
|
||||
CRDTConnectionStatus,
|
||||
CRDTDiscoveryConfig,
|
||||
CRDTPeerMetadata,
|
||||
CRDTProvider,
|
||||
CRDTProviderAdapter,
|
||||
CRDTSyncStatus,
|
||||
ResolvedCRDTDiscoveryConfig,
|
||||
} from "../../types/types.js";
|
||||
import { CRDTDiscoveryConfigSchema } from "../../types/types.schemas.js";
|
||||
import { AutomergeProviderAdapter } from "../automerge-provider/automerge-provider.js";
|
||||
import { CRDTIntervals } from "../crdt-intervals/crdt-intervals.js";
|
||||
import { CRDTPeerManager } from "../crdt-peer-manager/crdt-peer-manager.js";
|
||||
import { YjsProviderAdapter } from "../yjs-provider/yjs-provider.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Browser-tier CRDT discovery strategy implementing
|
||||
* `@suaveplan/network-bootstrap`'s `DiscoveryStrategy` contract. Wraps a
|
||||
* single provider adapter (Yjs or Automerge — chosen at construction time)
|
||||
* plus a `CRDTPeerManager` and a `CRDTIntervals` scheduler.
|
||||
*/
|
||||
export class CRDTDiscovery implements DiscoveryStrategy {
|
||||
public readonly name = "crdt";
|
||||
public readonly config: ResolvedCRDTDiscoveryConfig;
|
||||
|
||||
private readonly clock: Clock;
|
||||
private readonly peers: CRDTPeerManager;
|
||||
private readonly adapter: CRDTProviderAdapter;
|
||||
private readonly intervals: CRDTIntervals;
|
||||
private running = false;
|
||||
private disposed = false;
|
||||
private myPeerId: string | null = null;
|
||||
private documentVersion = 0;
|
||||
private lastSync = 0;
|
||||
|
||||
public constructor(
|
||||
rawConfig: CRDTDiscoveryConfig,
|
||||
clock: Clock = defaultClock
|
||||
) {
|
||||
const parsed = CRDTDiscoveryConfigSchema.safeParse(rawConfig);
|
||||
if (!parsed.success) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
|
||||
"errors.crdt.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.provider === "yjs") {
|
||||
if (data.yjs === undefined) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
|
||||
"errors.crdt.yjs_config_required"
|
||||
);
|
||||
}
|
||||
if (rawConfig.yjsDocFactory === undefined) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
|
||||
"errors.crdt.yjs_factory_required"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (data.automerge === undefined) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
|
||||
"errors.crdt.automerge_config_required"
|
||||
);
|
||||
}
|
||||
if (rawConfig.automergeRepoFactory === undefined) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
|
||||
"errors.crdt.automerge_factory_required"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.clock = clock;
|
||||
this.config = {
|
||||
provider: data.provider,
|
||||
room: data.room,
|
||||
yjs: data.yjs,
|
||||
automerge: data.automerge,
|
||||
interval: data.interval,
|
||||
timeout: data.timeout,
|
||||
heartbeatInterval: data.heartbeatInterval,
|
||||
cleanupInterval: data.cleanupInterval,
|
||||
maxPeers: data.maxPeers,
|
||||
iceServers: rawConfig.iceServers,
|
||||
yjsDocFactory: rawConfig.yjsDocFactory,
|
||||
automergeRepoFactory: rawConfig.automergeRepoFactory,
|
||||
bus: rawConfig.bus,
|
||||
registry: rawConfig.registry,
|
||||
};
|
||||
|
||||
this.peers = new CRDTPeerManager({
|
||||
clock: this.clock,
|
||||
bus: this.config.bus,
|
||||
registry: this.config.registry,
|
||||
provider: this.config.provider,
|
||||
maxPeers: this.config.maxPeers,
|
||||
room: this.config.room,
|
||||
});
|
||||
|
||||
if (data.provider === "yjs") {
|
||||
// Guards above have already enforced these are defined.
|
||||
this.adapter = new YjsProviderAdapter(
|
||||
this.config.room,
|
||||
this.config.yjs as NonNullable<typeof this.config.yjs>,
|
||||
this.config.yjsDocFactory as NonNullable<
|
||||
typeof this.config.yjsDocFactory
|
||||
>
|
||||
);
|
||||
} else {
|
||||
this.adapter = new AutomergeProviderAdapter(
|
||||
this.config.room,
|
||||
this.config.automerge as NonNullable<
|
||||
typeof this.config.automerge
|
||||
>,
|
||||
this.config.automergeRepoFactory as NonNullable<
|
||||
typeof this.config.automergeRepoFactory
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
this.intervals = new CRDTIntervals({
|
||||
clock: this.clock,
|
||||
announceIntervalMs: this.config.interval,
|
||||
heartbeatIntervalMs: this.config.heartbeatInterval,
|
||||
cleanupIntervalMs: this.config.cleanupInterval,
|
||||
timeoutMs: this.config.timeout,
|
||||
provider: this.config.provider,
|
||||
adapter: this.adapter,
|
||||
peers: this.peers.getMap(),
|
||||
getMyPeerId: () => this.myPeerId,
|
||||
isRunning: () => this.running,
|
||||
selfMetadata: () => this.selfMetadata(),
|
||||
handlePeerLost: (id) => this.handlePeerLost(id),
|
||||
});
|
||||
}
|
||||
|
||||
public async start(signal?: AbortSignal): Promise<void> {
|
||||
if (this.disposed) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed"
|
||||
);
|
||||
}
|
||||
if (this.running) return;
|
||||
await withStartSpan(async (span) => {
|
||||
span.setAttribute("provider", this.config.provider);
|
||||
span.setAttribute("room", this.config.room);
|
||||
try {
|
||||
this.myPeerId = await withProviderSyncSpan(
|
||||
this.config.provider,
|
||||
() =>
|
||||
this.adapter.initialize(
|
||||
{
|
||||
onPeer: (m) => this.handlePeer(m),
|
||||
onPeerLost: (id) => this.handlePeerLost(id),
|
||||
onSynced: () => {
|
||||
this.lastSync = this.clock();
|
||||
},
|
||||
},
|
||||
signal
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
this.config.bus?.emit("crdt:sync-failed", {
|
||||
reason: (err as Error).message,
|
||||
provider: this.config.provider,
|
||||
at: this.clock(),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
span.setAttribute("peer_id", this.myPeerId);
|
||||
this.running = true;
|
||||
this.intervals.start();
|
||||
this.adapter.announce(this.selfMetadata());
|
||||
this.config.bus?.emit("crdt:started", {
|
||||
peerId: this.myPeerId,
|
||||
provider: this.config.provider,
|
||||
room: this.config.room,
|
||||
at: this.clock(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(signal?: AbortSignal): Promise<void> {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
if (signal?.aborted) {
|
||||
this.intervals.stop();
|
||||
this.adapter.destroy();
|
||||
this.peers.clear();
|
||||
this.myPeerId = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.adapter.leave();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
this.intervals.stop();
|
||||
this.adapter.destroy();
|
||||
this.peers.clear();
|
||||
this.myPeerId = null;
|
||||
}
|
||||
|
||||
public async discover(
|
||||
_timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<PeerInfo[]> {
|
||||
if (signal?.aborted) throw toAbortError(signal);
|
||||
if (this.disposed) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed"
|
||||
);
|
||||
}
|
||||
return this.peers.list().map((m) => ({
|
||||
peerId: m.id,
|
||||
addresses: [...m.addresses],
|
||||
lastSeen: m.lastSeen,
|
||||
source: this.name,
|
||||
}));
|
||||
}
|
||||
|
||||
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) throw toAbortError(signal);
|
||||
return this.running && !this.disposed;
|
||||
}
|
||||
|
||||
public async announce(
|
||||
peerInfo: PeerInfo,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) throw toAbortError(signal);
|
||||
if (this.disposed) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed"
|
||||
);
|
||||
}
|
||||
if (!this.running) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
|
||||
"errors.crdt.not_running"
|
||||
);
|
||||
}
|
||||
const metadata: CRDTPeerMetadata = {
|
||||
id: peerInfo.peerId,
|
||||
addresses: [...peerInfo.addresses],
|
||||
metadata: {},
|
||||
lastSeen: this.clock(),
|
||||
provider: this.config.provider,
|
||||
version: this.documentVersion++,
|
||||
};
|
||||
this.adapter.announce(metadata);
|
||||
}
|
||||
|
||||
/** Local peer id, or `null` before `start()` completes. */
|
||||
public getMyPeerId(): string | null {
|
||||
return this.myPeerId;
|
||||
}
|
||||
|
||||
/** Snapshot of the live peer set. */
|
||||
public getPeers(): readonly CRDTPeerMetadata[] {
|
||||
return this.peers.list();
|
||||
}
|
||||
|
||||
/** Number of tracked peers. */
|
||||
public getPeerCount(): number {
|
||||
return this.peers.size();
|
||||
}
|
||||
|
||||
/** Provider in use. */
|
||||
public getProvider(): CRDTProvider {
|
||||
return this.config.provider;
|
||||
}
|
||||
|
||||
/** Current sync status. */
|
||||
public getSyncStatus(): CRDTSyncStatus {
|
||||
const peerCount = this.peers.size();
|
||||
const connected = this.adapter.isConnected;
|
||||
return {
|
||||
synced: connected,
|
||||
syncedPeers: connected ? peerCount : 0,
|
||||
pendingPeers: connected ? 0 : peerCount,
|
||||
version: this.documentVersion,
|
||||
lastSync: this.lastSync,
|
||||
};
|
||||
}
|
||||
|
||||
/** Overall connection status. */
|
||||
public getStatus(): CRDTConnectionStatus {
|
||||
return {
|
||||
provider: this.config.provider,
|
||||
connected: this.running && this.adapter.isConnected,
|
||||
peerCount: this.peers.size(),
|
||||
syncStatus: this.getSyncStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dispose every resource. After `dispose()` the strategy is unusable. */
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.running = false;
|
||||
this.intervals.stop();
|
||||
this.adapter.destroy();
|
||||
this.peers.clear();
|
||||
this.myPeerId = null;
|
||||
}
|
||||
|
||||
private handlePeer(metadata: CRDTPeerMetadata): void {
|
||||
try {
|
||||
this.peers.admit(metadata);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetDiscCRDTError &&
|
||||
err.code === NET_DISC_CRDT_CODES.PEER_REJECTED
|
||||
) {
|
||||
// Room full — surface via the bus but do not crash.
|
||||
this.config.bus?.emit("crdt:sync-failed", {
|
||||
reason: "room_full",
|
||||
provider: this.config.provider,
|
||||
at: this.clock(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePeerLost(peerId: string): void {
|
||||
this.peers.drop(peerId);
|
||||
}
|
||||
|
||||
private selfMetadata(): CRDTPeerMetadata {
|
||||
return {
|
||||
id: this.myPeerId === null ? "" : this.myPeerId,
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: this.clock(),
|
||||
provider: this.config.provider,
|
||||
version: this.documentVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toAbortError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CRDTDiscovery } from "./crdt-discovery.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# crdt-intervals
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the three periodic loops every `CRDTDiscovery` instance needs: announce (re-broadcast local presence), heartbeat (refresh `lastSeen` so peers do not garbage-collect this peer), and cleanup (drop stale remote peers). Uses one `@suaveplan/timing` `Scheduler` so disposing the strategy cancels every pending timer atomically — there is no equivalent guarantee with raw `setInterval` handles, and the upstream version leaked timers on aborted shutdowns.
|
||||
|
||||
## Features
|
||||
|
||||
- One `Scheduler` per `CRDTIntervals` instance — `stop()` calls `scheduler.dispose()`, draining every pending tick.
|
||||
- Self-rearming loops via `scheduler.schedule(...)`. Each tick re-queues itself; cancellation breaks the chain.
|
||||
- Loops short-circuit when `isRunning()` returns `false` or `getMyPeerId()` is still `null`, so a slow `start()` cannot fire announces against an uninitialised adapter.
|
||||
- Cleanup respects the configured `timeout` (ms) and routes drops through the caller's `handlePeerLost` so the peer-manager / bus / registry side-effects stay consistent.
|
||||
- Records `disc_crdt.reconcile_duration_ms{provider}` on every announce.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const intervals = new CRDTIntervals({
|
||||
clock: () => Date.now(),
|
||||
announceIntervalMs: 1_000,
|
||||
heartbeatIntervalMs: 500,
|
||||
cleanupIntervalMs: 1_000,
|
||||
timeoutMs: 30_000,
|
||||
provider: "yjs",
|
||||
adapter,
|
||||
peers: new Map(),
|
||||
getMyPeerId: () => "p1",
|
||||
isRunning: () => true,
|
||||
selfMetadata: () => ({
|
||||
id: "p1",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
}),
|
||||
handlePeerLost: (id) => console.log("lost", id),
|
||||
});
|
||||
|
||||
intervals.start();
|
||||
// later:
|
||||
intervals.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
|
||||
const intervals = new CRDTIntervals(
|
||||
{
|
||||
/* ...deps */
|
||||
} as never,
|
||||
(clock) => createScheduler({ clock, maxConcurrentTasks: 4 })
|
||||
);
|
||||
intervals.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTIntervals(deps, schedulerFactory?)`** — deps shape documented above; `schedulerFactory` defaults to `(c) => createScheduler({ clock: c })`.
|
||||
- **`start()`** — fire-and-forget queue of the three loops. Safe to call multiple times.
|
||||
- **`stop()`** — dispose every pending timer. Idempotent.
|
||||
- **`isStopped: boolean`** — true after `stop()` has fired.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The heartbeat loop deliberately re-runs the same `selfMetadata()` builder as the announce loop, only at a shorter cadence. The underlying CRDT (Yjs Y.Map or Automerge document) de-duplicates the write via its own conflict-resolution — a heartbeat that lands on the same data is a no-op. The cleanup loop iterates the live `peers` map directly so we never snapshot megabytes of peer state per tick. A peer whose `lastSeen` is younger than `timeoutMs` is left untouched; entries past the threshold are dropped through the caller's `handlePeerLost` so the side-effects (event-bus emission, registry deregistration) stay in one place. The OTel reconciliation-duration histogram is recorded around the announce step because that is the operation operations teams need to chart — heartbeat is too noisy and cleanup is constant-time per peer.
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Scheduler } from "@suaveplan/timing";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
CRDTProviderAdapter,
|
||||
} from "../../types/types.js";
|
||||
import { CRDTIntervals } from "./crdt-intervals.js";
|
||||
|
||||
class CapturingAdapter implements CRDTProviderAdapter {
|
||||
public readonly kind = "yjs" as const;
|
||||
public readonly announces: CRDTPeerMetadata[] = [];
|
||||
public destroyed = false;
|
||||
public async initialize(): Promise<string> {
|
||||
return "fake";
|
||||
}
|
||||
public announce(m: CRDTPeerMetadata): void {
|
||||
this.announces.push(m);
|
||||
}
|
||||
public leave(): void {}
|
||||
public listKnownPeers(): readonly CRDTPeerMetadata[] {
|
||||
return [];
|
||||
}
|
||||
public destroy(): void {
|
||||
this.destroyed = true;
|
||||
}
|
||||
public get isConnected(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Immediate-tick scheduler — every `schedule(fn)` fires synchronously on
|
||||
* the very next microtask, exactly once. */
|
||||
function immediateScheduler(): Scheduler {
|
||||
let disposed = false;
|
||||
return {
|
||||
schedule(fn) {
|
||||
if (disposed) return { id: "x", scheduledAt: 0, delayMs: 0 };
|
||||
queueMicrotask(() => {
|
||||
if (!disposed) fn();
|
||||
});
|
||||
return { id: "x", scheduledAt: 0, delayMs: 0 };
|
||||
},
|
||||
cancel() {
|
||||
return false;
|
||||
},
|
||||
async drain() {},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
get pendingCount() {
|
||||
return 0;
|
||||
},
|
||||
get disposed() {
|
||||
return disposed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Manual-tick scheduler — fn buffered into `pending`; caller drives ticks. */
|
||||
function manualScheduler(): Scheduler & { runOnce: () => void } {
|
||||
const pending: Array<() => void> = [];
|
||||
let disposed = false;
|
||||
return {
|
||||
schedule(fn) {
|
||||
pending.push(fn);
|
||||
return { id: "x", scheduledAt: 0, delayMs: 0 };
|
||||
},
|
||||
cancel() {
|
||||
return false;
|
||||
},
|
||||
async drain() {},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
get pendingCount() {
|
||||
return pending.length;
|
||||
},
|
||||
get disposed() {
|
||||
return disposed;
|
||||
},
|
||||
runOnce() {
|
||||
const fn = pending.shift();
|
||||
if (fn !== undefined) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
over: Partial<{
|
||||
adapter: CRDTProviderAdapter;
|
||||
peers: Map<string, CRDTPeerMetadata>;
|
||||
clock: Clock;
|
||||
isRunning: () => boolean;
|
||||
getMyPeerId: () => string | null;
|
||||
timeoutMs: number;
|
||||
handlePeerLost: (id: string) => void;
|
||||
}> = {}
|
||||
) {
|
||||
const adapter = over.adapter ?? new CapturingAdapter();
|
||||
const peers = over.peers ?? new Map<string, CRDTPeerMetadata>();
|
||||
const lost: string[] = [];
|
||||
return {
|
||||
deps: {
|
||||
clock: over.clock ?? (() => 1000),
|
||||
announceIntervalMs: 1,
|
||||
heartbeatIntervalMs: 1,
|
||||
cleanupIntervalMs: 1,
|
||||
timeoutMs: over.timeoutMs ?? 100,
|
||||
provider: "yjs" as const,
|
||||
adapter,
|
||||
peers,
|
||||
getMyPeerId: over.getMyPeerId ?? (() => "self"),
|
||||
isRunning: over.isRunning ?? (() => true),
|
||||
selfMetadata: (): CRDTPeerMetadata => ({
|
||||
id: "self",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
}),
|
||||
handlePeerLost:
|
||||
over.handlePeerLost ?? ((id: string) => lost.push(id)),
|
||||
},
|
||||
lost,
|
||||
adapter: adapter as CapturingAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CRDTIntervals lifecycle", () => {
|
||||
it("start fires announce / heartbeat ticks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { deps, adapter } = makeDeps();
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(adapter.announces.length).toBeGreaterThanOrEqual(2);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("start is idempotent after stop", { timeout: 5000 }, () => {
|
||||
const { deps } = makeDeps();
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
intervals.stop();
|
||||
intervals.start();
|
||||
expect(intervals.isStopped).toBe(true);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("stop is idempotent", { timeout: 5000 }, () => {
|
||||
const { deps } = makeDeps();
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.stop();
|
||||
intervals.stop();
|
||||
expect(intervals.isStopped).toBe(true);
|
||||
});
|
||||
|
||||
it("announce ticks no-op when not running", { timeout: 5000 }, async () => {
|
||||
const { deps, adapter } = makeDeps({ isRunning: () => false });
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(adapter.announces).toEqual([]);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("announce ticks no-op when myPeerId is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { deps, adapter } = makeDeps({ getMyPeerId: () => null });
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(adapter.announces).toEqual([]);
|
||||
intervals.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTIntervals cleanup loop", () => {
|
||||
it("drops stale peers via handlePeerLost", { timeout: 5000 }, async () => {
|
||||
const peers = new Map<string, CRDTPeerMetadata>();
|
||||
peers.set("stale", {
|
||||
id: "stale",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: -1_000_000,
|
||||
provider: "yjs",
|
||||
});
|
||||
const { deps, lost } = makeDeps({ peers, timeoutMs: 100 });
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(lost).toContain("stale");
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("leaves fresh peers untouched", { timeout: 5000 }, async () => {
|
||||
const peers = new Map<string, CRDTPeerMetadata>();
|
||||
peers.set("fresh", {
|
||||
id: "fresh",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 1000,
|
||||
provider: "yjs",
|
||||
});
|
||||
const { deps, lost } = makeDeps({
|
||||
peers,
|
||||
timeoutMs: 1_000_000,
|
||||
clock: () => 1001,
|
||||
});
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(lost).toEqual([]);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("cleanup loop no-ops when not running", { timeout: 5000 }, async () => {
|
||||
const peers = new Map<string, CRDTPeerMetadata>();
|
||||
peers.set("x", {
|
||||
id: "x",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: -1_000_000,
|
||||
provider: "yjs",
|
||||
});
|
||||
const { deps, lost } = makeDeps({ peers, isRunning: () => false });
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(lost).toEqual([]);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("heartbeat no-ops when myPeerId is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { deps, adapter } = makeDeps({ getMyPeerId: () => null });
|
||||
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
|
||||
intervals.start();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(adapter.announces).toEqual([]);
|
||||
intervals.stop();
|
||||
});
|
||||
|
||||
it("reschedule guards short-circuit after stop", { timeout: 5000 }, () => {
|
||||
const { deps } = makeDeps();
|
||||
const sched = manualScheduler();
|
||||
const intervals = new CRDTIntervals(deps, () => sched);
|
||||
intervals.start();
|
||||
// Three loops queued (announce, heartbeat, cleanup).
|
||||
expect(sched.pendingCount).toBe(3);
|
||||
intervals.stop();
|
||||
// Running any pending now triggers reschedule into the stopped branch.
|
||||
sched.runOnce();
|
||||
sched.runOnce();
|
||||
sched.runOnce();
|
||||
// None should have requeued.
|
||||
expect(sched.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("default scheduler factory works (production path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { deps } = makeDeps();
|
||||
const intervals = new CRDTIntervals(deps);
|
||||
intervals.start();
|
||||
intervals.stop();
|
||||
expect(intervals.isStopped).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createScheduler, type Scheduler } from "@suaveplan/timing";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { recordReconcileDuration } from "../../otel/otel.js";
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
CRDTProvider,
|
||||
CRDTProviderAdapter,
|
||||
} from "../../types/types.js";
|
||||
|
||||
/**
|
||||
* Dependencies the interval loops close over.
|
||||
*/
|
||||
export interface CRDTIntervalDeps {
|
||||
readonly clock: Clock;
|
||||
readonly announceIntervalMs: number;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly cleanupIntervalMs: number;
|
||||
readonly timeoutMs: number;
|
||||
readonly provider: CRDTProvider;
|
||||
readonly adapter: CRDTProviderAdapter;
|
||||
readonly peers: Map<string, CRDTPeerMetadata>;
|
||||
/** Current local peer id — `null` until the adapter is initialised. */
|
||||
getMyPeerId(): string | null;
|
||||
/** Returns `true` while the strategy is running. */
|
||||
isRunning(): boolean;
|
||||
/** Build the announce metadata for the local peer. */
|
||||
selfMetadata(): CRDTPeerMetadata;
|
||||
/** Drop a peer from local state. */
|
||||
handlePeerLost(peerId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three `Scheduler`-driven self-rearming loops — announce / heartbeat /
|
||||
* cleanup. Replaces upstream's three `setInterval` handles with a single
|
||||
* `Scheduler` whose `dispose()` cancels every pending timer atomically.
|
||||
*/
|
||||
export class CRDTIntervals {
|
||||
private readonly scheduler: Scheduler;
|
||||
private readonly deps: CRDTIntervalDeps;
|
||||
private stopped = false;
|
||||
|
||||
public constructor(
|
||||
deps: CRDTIntervalDeps,
|
||||
schedulerFactory: (clock: Clock) => Scheduler = (c) =>
|
||||
createScheduler({ clock: c })
|
||||
) {
|
||||
this.deps = deps;
|
||||
this.scheduler = schedulerFactory(deps.clock);
|
||||
}
|
||||
|
||||
/** Start every periodic loop. Idempotent. */
|
||||
public start(): void {
|
||||
if (this.stopped) return;
|
||||
this.scheduleAnnounce();
|
||||
this.scheduleHeartbeat();
|
||||
this.scheduleCleanup();
|
||||
}
|
||||
|
||||
/** Stop every loop and dispose the underlying scheduler. Idempotent. */
|
||||
public stop(): void {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.scheduler.dispose();
|
||||
}
|
||||
|
||||
/** Whether `stop()` has been called. */
|
||||
public get isStopped(): boolean {
|
||||
return this.stopped;
|
||||
}
|
||||
|
||||
private scheduleAnnounce(): void {
|
||||
if (this.stopped || this.scheduler.disposed) return;
|
||||
this.scheduler.schedule(() => {
|
||||
this.runAnnounce();
|
||||
this.scheduleAnnounce();
|
||||
}, this.deps.announceIntervalMs);
|
||||
}
|
||||
|
||||
private scheduleHeartbeat(): void {
|
||||
if (this.stopped || this.scheduler.disposed) return;
|
||||
this.scheduler.schedule(() => {
|
||||
this.runHeartbeat();
|
||||
this.scheduleHeartbeat();
|
||||
}, this.deps.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
private scheduleCleanup(): void {
|
||||
if (this.stopped || this.scheduler.disposed) return;
|
||||
this.scheduler.schedule(() => {
|
||||
this.runCleanup();
|
||||
this.scheduleCleanup();
|
||||
}, this.deps.cleanupIntervalMs);
|
||||
}
|
||||
|
||||
private runAnnounce(): void {
|
||||
if (!this.deps.isRunning()) return;
|
||||
const myPeerId = this.deps.getMyPeerId();
|
||||
if (myPeerId === null) return;
|
||||
const t0 = this.deps.clock();
|
||||
const metadata = this.deps.selfMetadata();
|
||||
this.deps.adapter.announce(metadata);
|
||||
recordReconcileDuration(this.deps.provider, this.deps.clock() - t0);
|
||||
}
|
||||
|
||||
private runHeartbeat(): void {
|
||||
if (!this.deps.isRunning()) return;
|
||||
const myPeerId = this.deps.getMyPeerId();
|
||||
if (myPeerId === null) return;
|
||||
// Heartbeat is a lighter-weight version of announce — same payload
|
||||
// with refreshed `lastSeen`. Adapter de-dupes via CRDT semantics.
|
||||
this.deps.adapter.announce(this.deps.selfMetadata());
|
||||
}
|
||||
|
||||
private runCleanup(): void {
|
||||
if (!this.deps.isRunning()) return;
|
||||
const now = this.deps.clock();
|
||||
for (const [peerId, peer] of this.deps.peers.entries()) {
|
||||
if (now - peer.lastSeen > this.deps.timeoutMs) {
|
||||
this.deps.handlePeerLost(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type CRDTIntervalDeps,
|
||||
CRDTIntervals,
|
||||
} from "./crdt-intervals.js";
|
||||
@@ -0,0 +1,80 @@
|
||||
# crdt-peer-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
In-memory tracking layer for the deduped set of `CRDTPeerMetadata` records observed via the underlying CRDT awareness layer. Owns the lifecycle deltas — `admit` (insert / update), `touch` (heartbeat), `drop` (leave), `clear` (full reset) — and fans every delta out to the optional `EventBusLike<CRDTEventMap>` and the optional `@suaveplan/service-discovery` `ServiceRegistry`. Enforces the configured `maxPeers` cap on insert, throwing `NetDiscCRDTError` with code `PEER_REJECTED` rather than silently dropping records.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) lookup / insert / delete via `Map<string, CRDTPeerMetadata>`.
|
||||
- Injectable `Clock` (every `lastSeen` write goes through the clock — deterministic in tests).
|
||||
- Optional `EventBusLike` integration emits `crdt:peer-discovered` and `crdt:peer-lost` for every transition.
|
||||
- Optional `ServiceRegistry` mirror — discovered peers register as `crdt:<room>:<peerId>` services, auto-deregister on drop.
|
||||
- OTel `disc_crdt.peers_discovered_total{provider,outcome}` increments per outcome.
|
||||
- Hard `maxPeers` cap with structured `PEER_REJECTED` failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const peers = new CRDTPeerManager({
|
||||
clock: () => Date.now(),
|
||||
provider: "yjs",
|
||||
maxPeers: 100,
|
||||
room: "demo",
|
||||
});
|
||||
|
||||
peers.admit({
|
||||
id: "p1",
|
||||
addresses: ["wrtc-1"],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const peers = new CRDTPeerManager({
|
||||
clock: () => Date.now(),
|
||||
bus,
|
||||
registry,
|
||||
provider: "automerge",
|
||||
maxPeers: 50,
|
||||
room: "team-A",
|
||||
});
|
||||
|
||||
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
|
||||
peers.admit({
|
||||
id: "p2",
|
||||
addresses: ["wrtc-2"],
|
||||
metadata: { app: "demo" },
|
||||
lastSeen: 0,
|
||||
provider: "automerge",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new CRDTPeerManager(deps)`** — `deps: { clock, bus?, registry?, provider, maxPeers, room }`.
|
||||
- **`admit(metadata)`** — returns `"added"` | `"updated"`; throws `NetDiscCRDTError(PEER_REJECTED)` when the cap is exceeded on insert.
|
||||
- **`touch(peerId)`** — refresh `lastSeen` of an existing peer; no-op when unknown.
|
||||
- **`drop(peerId)`** — remove peer; returns `boolean` indicating whether the peer existed.
|
||||
- **`list()` / `get(peerId)` / `size()` / `getMap()`** — read accessors.
|
||||
- **`clear()`** — drop every peer, firing `crdt:peer-lost` for each.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `getMap()` accessor returns the internal map directly so the cleanup-interval loop in `CRDTIntervals` can iterate without snapshotting on every tick. This is safe because `CRDTIntervals` only ever calls `drop()` on it (never raw `delete`), so the bus / registry side-effects always fire. The dedup behaviour relies on stable peer ids; if a CRDT backend rotates ids on reconnect, the new id appears as a new peer (intentional — we surface what the CRDT sees). `maxPeers` is enforced only on insert; an `admit` that updates an existing peer never throws, even if `size() > maxPeers` due to caller-initiated cap-shrinking.
|
||||
@@ -0,0 +1,179 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type { CRDTEventMap, CRDTPeerMetadata } from "../../types/types.js";
|
||||
import { CRDTPeerManager } from "./crdt-peer-manager.js";
|
||||
|
||||
function makePeer(id: string, lastSeen = 100): CRDTPeerMetadata {
|
||||
return {
|
||||
id,
|
||||
addresses: [`addr-${id}`],
|
||||
metadata: { tag: id },
|
||||
lastSeen,
|
||||
provider: "yjs",
|
||||
};
|
||||
}
|
||||
|
||||
describe("CRDTPeerManager.admit", () => {
|
||||
it("inserts a new peer and emits crdt:peer-discovered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const events: string[] = [];
|
||||
bus.on("crdt:peer-discovered", (e) => events.push(e.peer.id));
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
bus,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
expect(mgr.admit(makePeer("p1"))).toBe("added");
|
||||
expect(mgr.size()).toBe(1);
|
||||
expect(events).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("updates an existing peer", { timeout: 5000 }, () => {
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
expect(mgr.admit(makePeer("p1"))).toBe("updated");
|
||||
expect(mgr.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("throws PEER_REJECTED when the room is full", { timeout: 5000 }, () => {
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
provider: "automerge",
|
||||
maxPeers: 1,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
let caught: unknown;
|
||||
try {
|
||||
mgr.admit(makePeer("p2"));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(NetDiscCRDTError);
|
||||
expect((caught as NetDiscCRDTError).code).toBe(
|
||||
NET_DISC_CRDT_CODES.PEER_REJECTED
|
||||
);
|
||||
});
|
||||
|
||||
it("registers discovered peers into a ServiceRegistry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new ServiceRegistry();
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
registry,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
expect(registry.get("crdt:demo:p1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to peerId when addresses is empty for registry address", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new ServiceRegistry();
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
registry,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit({
|
||||
id: "p9",
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
});
|
||||
expect(registry.get("crdt:demo:p9")?.address).toBe("p9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRDTPeerManager.touch / drop / clear", () => {
|
||||
it("touch refreshes lastSeen and is a no-op on unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let t = 100;
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => t,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
t = 200;
|
||||
mgr.touch("p1");
|
||||
expect(mgr.get("p1")?.lastSeen).toBe(200);
|
||||
mgr.touch("unknown");
|
||||
expect(mgr.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("drop emits crdt:peer-lost and deregisters", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const lost: string[] = [];
|
||||
bus.on("crdt:peer-lost", (e) => lost.push(e.peerId));
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
bus,
|
||||
registry,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
expect(mgr.drop("p1")).toBe(true);
|
||||
expect(lost).toEqual(["p1"]);
|
||||
expect(registry.get("crdt:demo:p1")).toBeUndefined();
|
||||
expect(mgr.drop("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("clear drops every peer with bus + registry side-effects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bus = new EventBus<CRDTEventMap>();
|
||||
const registry = new ServiceRegistry();
|
||||
const lost: string[] = [];
|
||||
bus.on("crdt:peer-lost", (e) => lost.push(e.peerId));
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
bus,
|
||||
registry,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
mgr.admit(makePeer("p2"));
|
||||
mgr.clear();
|
||||
expect(mgr.size()).toBe(0);
|
||||
expect(lost.sort()).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("list / get / getMap expose the live map", { timeout: 5000 }, () => {
|
||||
const mgr = new CRDTPeerManager({
|
||||
clock: () => 1000,
|
||||
provider: "yjs",
|
||||
maxPeers: 10,
|
||||
room: "demo",
|
||||
});
|
||||
mgr.admit(makePeer("p1"));
|
||||
expect(mgr.list()).toHaveLength(1);
|
||||
expect(mgr.get("p1")?.id).toBe("p1");
|
||||
expect(mgr.getMap().has("p1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import { recordPeersDiscovered } from "../../otel/otel.js";
|
||||
import type {
|
||||
CRDTEventMap,
|
||||
CRDTPeerMetadata,
|
||||
CRDTProvider,
|
||||
} from "../../types/types.js";
|
||||
|
||||
/**
|
||||
* Tracks the deduped set of discovered peers for a `CRDTDiscovery` instance.
|
||||
* Mirrors deltas onto the optional `EventBusLike` and (when supplied) an
|
||||
* `@suaveplan/service-discovery` `ServiceRegistry`. Enforces `maxPeers`.
|
||||
*/
|
||||
export class CRDTPeerManager {
|
||||
private readonly peers = new Map<string, CRDTPeerMetadata>();
|
||||
private readonly clock: Clock;
|
||||
private readonly bus: EventBusLike<CRDTEventMap> | undefined;
|
||||
private readonly registry: ServiceRegistry | undefined;
|
||||
private readonly provider: CRDTProvider;
|
||||
private readonly maxPeers: number;
|
||||
private readonly room: string;
|
||||
|
||||
public constructor(deps: {
|
||||
clock: Clock;
|
||||
bus?: EventBusLike<CRDTEventMap> | undefined;
|
||||
registry?: ServiceRegistry | undefined;
|
||||
provider: CRDTProvider;
|
||||
maxPeers: number;
|
||||
room: string;
|
||||
}) {
|
||||
this.clock = deps.clock;
|
||||
this.bus = deps.bus;
|
||||
this.registry = deps.registry;
|
||||
this.provider = deps.provider;
|
||||
this.maxPeers = deps.maxPeers;
|
||||
this.room = deps.room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit a peer (insert or update). Throws `PEER_REJECTED` when the cap is
|
||||
* hit on insert. Returns `"added"` / `"updated"` so callers can record
|
||||
* the appropriate OTel outcome.
|
||||
*/
|
||||
public admit(metadata: CRDTPeerMetadata): "added" | "updated" {
|
||||
const existing = this.peers.get(metadata.id);
|
||||
if (existing === undefined) {
|
||||
if (this.peers.size >= this.maxPeers) {
|
||||
recordPeersDiscovered(this.provider, "rejected");
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.PEER_REJECTED,
|
||||
"errors.crdt.room_full"
|
||||
);
|
||||
}
|
||||
const record: CRDTPeerMetadata = {
|
||||
...metadata,
|
||||
lastSeen: this.clock(),
|
||||
};
|
||||
this.peers.set(metadata.id, record);
|
||||
recordPeersDiscovered(this.provider, "found");
|
||||
this.bus?.emit("crdt:peer-discovered", {
|
||||
peer: record,
|
||||
at: this.clock(),
|
||||
});
|
||||
this.registry?.register({
|
||||
id: `crdt:${this.room}:${metadata.id}`,
|
||||
name: "crdt-discovery",
|
||||
address: metadata.addresses[0] ?? metadata.id,
|
||||
metadata: {
|
||||
...metadata.metadata,
|
||||
provider: this.provider,
|
||||
addresses: [...metadata.addresses],
|
||||
},
|
||||
});
|
||||
return "added";
|
||||
}
|
||||
existing.lastSeen = this.clock();
|
||||
recordPeersDiscovered(this.provider, "duplicate");
|
||||
return "updated";
|
||||
}
|
||||
|
||||
/** Refresh the `lastSeen` timestamp of an existing peer. */
|
||||
public touch(peerId: string): void {
|
||||
const peer = this.peers.get(peerId);
|
||||
if (peer !== undefined) {
|
||||
peer.lastSeen = this.clock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a peer. Emits `crdt:peer-lost` and deregisters when known. */
|
||||
public drop(peerId: string): boolean {
|
||||
if (!this.peers.delete(peerId)) return false;
|
||||
this.bus?.emit("crdt:peer-lost", { peerId, at: this.clock() });
|
||||
this.registry?.deregister(`crdt:${this.room}:${peerId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snapshot of the live peer set. */
|
||||
public list(): readonly CRDTPeerMetadata[] {
|
||||
return Array.from(this.peers.values());
|
||||
}
|
||||
|
||||
/** Peer lookup. */
|
||||
public get(peerId: string): CRDTPeerMetadata | undefined {
|
||||
return this.peers.get(peerId);
|
||||
}
|
||||
|
||||
/** Number of tracked peers. */
|
||||
public size(): number {
|
||||
return this.peers.size;
|
||||
}
|
||||
|
||||
/** Underlying map (read-write, used by the interval cleanup loop). */
|
||||
public getMap(): Map<string, CRDTPeerMetadata> {
|
||||
return this.peers;
|
||||
}
|
||||
|
||||
/** Drop every peer (emits `crdt:peer-lost` for each). */
|
||||
public clear(): void {
|
||||
const ids = Array.from(this.peers.keys());
|
||||
this.peers.clear();
|
||||
for (const id of ids) {
|
||||
this.bus?.emit("crdt:peer-lost", { peerId: id, at: this.clock() });
|
||||
this.registry?.deregister(`crdt:${this.room}:${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CRDTPeerManager } from "./crdt-peer-manager.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { YjsProviderAdapter } from "./yjs-provider.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# yjs-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Yjs-backed implementation of the `CRDTProviderAdapter` contract. Wraps a `Y.Doc` plus the `y-webrtc` `WebrtcProvider` built by the caller-supplied `YjsDocFactory`. Owns the `peers` `Y.Map` observe handler, the `synced` + `peers` provider events, and the destroy semantics. Designed so production wires `yjsDocFactory: (room, opts) => { const doc = new Doc(); const provider = new WebrtcProvider(room, doc, opts); return { doc, provider }; }`, while tests pass a deterministic in-memory `FakeYjs` that implements the same structural surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure adapter — never reaches into `yjs` or `y-webrtc` directly. Every dependency on the upstream packages flows through `YDocLike` / `YMapLike` / `WebrtcProviderLike` structural types.
|
||||
- Observe handler converts Y.Map deltas (add / update / delete) into `onPeer` / `onPeerLost` callbacks, ignoring the local peer.
|
||||
- `provider.on("synced")` flushes the existing `peers` map into the callbacks so a late-joining peer immediately sees the full set.
|
||||
- `provider.on("peers")` mirrors removed transport peers into `onPeerLost`.
|
||||
- Idempotent `destroy()` swallows any teardown error from the upstream library.
|
||||
- Caller-supplied factory is wrapped in a try/catch so factory throws map to `YJS_AWARENESS_FAILED` rather than escaping unstructured.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://signal.local"] },
|
||||
(room, opts) => ({ doc: makeDoc(room), provider: makeProvider(opts) })
|
||||
);
|
||||
|
||||
const myId = await adapter.initialize({
|
||||
onPeer: (m) => console.log("peer", m.id),
|
||||
onPeerLost: (id) => console.log("lost", id),
|
||||
onSynced: () => console.log("synced"),
|
||||
});
|
||||
adapter.announce({
|
||||
id: myId,
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 0,
|
||||
provider: "yjs",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
|
||||
import { Doc } from "yjs";
|
||||
import { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"team-A",
|
||||
{ signaling: ["wss://signal.internal.example"], password: "secret" },
|
||||
(room, opts) => {
|
||||
const doc = new Doc();
|
||||
const provider = new WebrtcProvider(room, doc, {
|
||||
signaling: [...opts.signaling],
|
||||
password: opts.password,
|
||||
maxConns: opts.maxConns,
|
||||
peerExchange: opts.peerExchange ?? true,
|
||||
});
|
||||
return { doc: doc as never, provider: provider as never };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new YjsProviderAdapter(room, opts, factory)`** — `opts: YjsSignalingConfig`, `factory: YjsDocFactory`.
|
||||
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(YJS_AWARENESS_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
|
||||
- **`announce(metadata)`** — `set` on the underlying `Y.Map`.
|
||||
- **`leave()`** — `delete` on the underlying `Y.Map`; idempotent.
|
||||
- **`listKnownPeers()`** — snapshot of the `Y.Map` minus the local peer.
|
||||
- **`destroy()`** — unobserve + destroy provider + destroy doc; idempotent.
|
||||
- **`isConnected: boolean`** — mirrors the underlying provider's `connected` flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter never falls back to the public `wss://signaling.yjs.dev` URL — every caller MUST supply a non-empty `signaling` array. Removing the upstream default was the most important security uplift in the migration: hard-coding a public signalling server leaks discovery metadata to a third party. Teardown swallows individual library exceptions because the upstream `yjs` and `y-webrtc` packages have historically thrown from `destroy()` when called on a partially-initialised instance, and we never want a teardown to crash the orchestrator. The factory wrapper translates any thrown value into `YJS_AWARENESS_FAILED` so consumers do not need to know about the upstream error types.
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeYjsBroker,
|
||||
fakeYjsDocFactory,
|
||||
flushMicrotasks,
|
||||
} from "../../../tests/setup/fake-yjs.js";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
CRDTProviderCallbacks,
|
||||
} from "../../types/types.js";
|
||||
import { YjsProviderAdapter } from "./yjs-provider.js";
|
||||
|
||||
function makeCallbacks() {
|
||||
const seen: CRDTPeerMetadata[] = [];
|
||||
const lost: string[] = [];
|
||||
let syncs = 0;
|
||||
const callbacks: CRDTProviderCallbacks = {
|
||||
onPeer: (m) => seen.push(m),
|
||||
onPeerLost: (id) => lost.push(id),
|
||||
onSynced: () => {
|
||||
syncs += 1;
|
||||
},
|
||||
};
|
||||
return { callbacks, seen, lost, syncCount: () => syncs };
|
||||
}
|
||||
|
||||
function makePeer(id: string): CRDTPeerMetadata {
|
||||
return {
|
||||
id,
|
||||
addresses: [`addr-${id}`],
|
||||
metadata: { tag: id },
|
||||
lastSeen: 1,
|
||||
provider: "yjs",
|
||||
};
|
||||
}
|
||||
|
||||
describe("YjsProviderAdapter.initialize", () => {
|
||||
it("returns the local peer id and observes the peers map", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const { callbacks, syncCount } = makeCallbacks();
|
||||
const id = await adapter.initialize(callbacks);
|
||||
expect(id).toMatch(/^fake-doc-/);
|
||||
await flushMicrotasks();
|
||||
expect(syncCount()).toBeGreaterThanOrEqual(1);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("rejects with AUTOMERGE-like code when factory throws (YJS path)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
() => {
|
||||
throw new Error("nope");
|
||||
}
|
||||
);
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(adapter.initialize(callbacks)).rejects.toMatchObject({
|
||||
code: NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with DISPOSED after destroy", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
adapter.destroy();
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(adapter.initialize(callbacks)).rejects.toBeInstanceOf(
|
||||
NetDiscCRDTError
|
||||
);
|
||||
});
|
||||
|
||||
it("honours pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(
|
||||
adapter.initialize(callbacks, ctrl.signal)
|
||||
).rejects.toBeDefined();
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("forges AbortError when signal.reason is non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
Object.defineProperty(ctrl.signal, "aborted", { value: true });
|
||||
Object.defineProperty(ctrl.signal, "reason", {
|
||||
value: "string-reason",
|
||||
});
|
||||
const { callbacks } = makeCallbacks();
|
||||
await expect(
|
||||
adapter.initialize(callbacks, ctrl.signal)
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
adapter.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("YjsProviderAdapter cross-peer awareness", () => {
|
||||
it("propagates announces between adapters in the same room", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const factory = fakeYjsDocFactory(broker);
|
||||
const a = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const b = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
a.announce(makePeer("a-self"));
|
||||
await flushMicrotasks();
|
||||
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("flushes existing peers on synced", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const factory = fakeYjsDocFactory(broker);
|
||||
const a = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const cbA = makeCallbacks();
|
||||
await a.initialize(cbA.callbacks);
|
||||
a.announce(makePeer("a-self"));
|
||||
await flushMicrotasks();
|
||||
// Now b joins — it should see a-self via the synced flush.
|
||||
const b = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const cbB = makeCallbacks();
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("propagates delete as onPeerLost", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const factory = fakeYjsDocFactory(broker);
|
||||
const a = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const b = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
const idA = await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
// `a` announces under its own (local) id — this is what `leave()` deletes.
|
||||
a.announce({
|
||||
...makePeer(idA),
|
||||
});
|
||||
await flushMicrotasks();
|
||||
a.leave();
|
||||
await flushMicrotasks();
|
||||
expect(cbB.lost).toContain(idA);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("listKnownPeers excludes the local peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const factory = fakeYjsDocFactory(broker);
|
||||
const a = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const b = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
factory
|
||||
);
|
||||
const cbA = makeCallbacks();
|
||||
const cbB = makeCallbacks();
|
||||
const idA = await a.initialize(cbA.callbacks);
|
||||
await b.initialize(cbB.callbacks);
|
||||
await flushMicrotasks();
|
||||
a.announce(makePeer("a-self"));
|
||||
b.announce(makePeer("b-self"));
|
||||
await flushMicrotasks();
|
||||
const seenByA = a.listKnownPeers();
|
||||
expect(seenByA.every((p) => p.id !== idA)).toBe(true);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
it("isConnected reflects provider state", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
adapter.destroy();
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YjsProviderAdapter edge cases", () => {
|
||||
it("announce / leave / listKnownPeers no-op before initialise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
adapter.announce(makePeer("x"));
|
||||
adapter.leave();
|
||||
expect(adapter.listKnownPeers()).toEqual([]);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("destroy is idempotent", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
adapter.destroy();
|
||||
adapter.destroy();
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("handleMapChange guards against null map after destroy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
const internals = adapter as unknown as {
|
||||
handleMapChange: (
|
||||
event: { changes: { keys: Map<string, { action: string }> } },
|
||||
cb: typeof cb.callbacks
|
||||
) => void;
|
||||
peersMap: unknown;
|
||||
myPeerId: unknown;
|
||||
};
|
||||
internals.peersMap = null;
|
||||
internals.handleMapChange(
|
||||
{ changes: { keys: new Map([["x", { action: "add" }]]) } },
|
||||
cb.callbacks
|
||||
);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("handleMapChange skips missing metadata on add", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
const seenBefore = cb.seen.length;
|
||||
const internals = adapter as unknown as {
|
||||
handleMapChange: (
|
||||
event: { changes: { keys: Map<string, { action: string }> } },
|
||||
cb: typeof cb.callbacks
|
||||
) => void;
|
||||
};
|
||||
// Event references a key the map doesn't contain — onPeer must NOT fire.
|
||||
internals.handleMapChange(
|
||||
{
|
||||
changes: {
|
||||
keys: new Map([["missing-key", { action: "add" }]]),
|
||||
},
|
||||
},
|
||||
cb.callbacks
|
||||
);
|
||||
expect(cb.seen.length).toBe(seenBefore);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("flushExisting guards against null peersMap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
await adapter.initialize(cb.callbacks);
|
||||
const internals = adapter as unknown as {
|
||||
flushExisting: (cb: typeof cb.callbacks) => void;
|
||||
peersMap: unknown;
|
||||
};
|
||||
internals.peersMap = null;
|
||||
internals.flushExisting(cb.callbacks);
|
||||
adapter.destroy();
|
||||
});
|
||||
|
||||
it("flushExisting skips the local peer id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeYjsBroker();
|
||||
const adapter = new YjsProviderAdapter(
|
||||
"demo",
|
||||
{ signaling: ["wss://x.invalid"] },
|
||||
fakeYjsDocFactory(broker)
|
||||
);
|
||||
const cb = makeCallbacks();
|
||||
const idA = await adapter.initialize(cb.callbacks);
|
||||
// Announce under the local id so flushExisting must skip it.
|
||||
adapter.announce({
|
||||
id: idA,
|
||||
addresses: [],
|
||||
metadata: {},
|
||||
lastSeen: 1,
|
||||
provider: "yjs",
|
||||
});
|
||||
const before = cb.seen.length;
|
||||
const internals = adapter as unknown as {
|
||||
flushExisting: (cb: typeof cb.callbacks) => void;
|
||||
};
|
||||
internals.flushExisting(cb.callbacks);
|
||||
// No new onPeer because the only entry is the local peer.
|
||||
expect(cb.seen.length).toBe(before);
|
||||
adapter.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
CRDTProviderAdapter,
|
||||
CRDTProviderCallbacks,
|
||||
WebrtcProviderLike,
|
||||
YDocLike,
|
||||
YjsDocFactory,
|
||||
YjsSignalingConfig,
|
||||
YMapEventLike,
|
||||
YMapLike,
|
||||
} from "../../types/types.js";
|
||||
|
||||
/**
|
||||
* Yjs-backed `CRDTProviderAdapter`. Wraps a `Y.Doc` + `y-webrtc` provider
|
||||
* pair built by the caller-supplied `YjsDocFactory`. Owns the peer-map
|
||||
* observe handler, the synced + peers events, and the destroy semantics.
|
||||
*/
|
||||
export class YjsProviderAdapter implements CRDTProviderAdapter {
|
||||
public readonly kind = "yjs" as const;
|
||||
|
||||
private readonly room: string;
|
||||
private readonly opts: YjsSignalingConfig;
|
||||
private readonly factory: YjsDocFactory;
|
||||
|
||||
private doc: YDocLike | null = null;
|
||||
private provider: WebrtcProviderLike | null = null;
|
||||
private peersMap: YMapLike | null = null;
|
||||
private myPeerId: string | null = null;
|
||||
private observeHandler: ((e: YMapEventLike) => void) | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
room: string,
|
||||
opts: YjsSignalingConfig,
|
||||
factory: YjsDocFactory
|
||||
) {
|
||||
this.room = room;
|
||||
this.opts = opts;
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
public async initialize(
|
||||
callbacks: CRDTProviderCallbacks,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
if (signal?.aborted) throw toAbortError(signal);
|
||||
if (this.destroyed) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed"
|
||||
);
|
||||
}
|
||||
let built: { doc: YDocLike; provider: WebrtcProviderLike };
|
||||
try {
|
||||
built = this.factory(this.room, this.opts);
|
||||
} catch (err) {
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED,
|
||||
"errors.crdt.yjs_init_failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
this.doc = built.doc;
|
||||
this.provider = built.provider;
|
||||
this.peersMap = built.doc.getMap("peers");
|
||||
this.myPeerId = built.doc.guid;
|
||||
|
||||
this.observeHandler = (event: YMapEventLike) => {
|
||||
this.handleMapChange(event, callbacks);
|
||||
};
|
||||
this.peersMap.observe(this.observeHandler);
|
||||
|
||||
this.provider.on("synced", () => {
|
||||
callbacks.onSynced();
|
||||
this.flushExisting(callbacks);
|
||||
});
|
||||
this.provider.on("peers", (e) => {
|
||||
for (const removed of e.removed) {
|
||||
callbacks.onPeerLost(removed);
|
||||
}
|
||||
});
|
||||
|
||||
return this.myPeerId;
|
||||
}
|
||||
|
||||
public announce(metadata: CRDTPeerMetadata): void {
|
||||
if (this.peersMap === null || this.myPeerId === null) return;
|
||||
this.peersMap.set(this.myPeerId, metadata);
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
if (this.peersMap === null || this.myPeerId === null) return;
|
||||
try {
|
||||
this.peersMap.delete(this.myPeerId);
|
||||
} catch {
|
||||
/* swallow — already detached */
|
||||
}
|
||||
}
|
||||
|
||||
public listKnownPeers(): readonly CRDTPeerMetadata[] {
|
||||
if (this.peersMap === null || this.myPeerId === null) return [];
|
||||
const out: CRDTPeerMetadata[] = [];
|
||||
const snap = this.peersMap.toJSON();
|
||||
for (const [id, metadata] of Object.entries(snap)) {
|
||||
if (id !== this.myPeerId) out.push(metadata);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (this.peersMap !== null && this.observeHandler !== null) {
|
||||
try {
|
||||
this.peersMap.unobserve(this.observeHandler);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
if (this.provider !== null) {
|
||||
try {
|
||||
this.provider.destroy();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
this.provider = null;
|
||||
}
|
||||
if (this.doc !== null) {
|
||||
try {
|
||||
this.doc.destroy();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
this.doc = null;
|
||||
}
|
||||
this.peersMap = null;
|
||||
this.myPeerId = null;
|
||||
this.observeHandler = null;
|
||||
}
|
||||
|
||||
public get isConnected(): boolean {
|
||||
return this.provider?.connected ?? false;
|
||||
}
|
||||
|
||||
private handleMapChange(
|
||||
event: YMapEventLike,
|
||||
callbacks: CRDTProviderCallbacks
|
||||
): void {
|
||||
const map = this.peersMap;
|
||||
if (map === null || this.myPeerId === null) return;
|
||||
for (const [peerId, change] of event.changes.keys.entries()) {
|
||||
if (peerId === this.myPeerId) continue;
|
||||
if (change.action === "add" || change.action === "update") {
|
||||
const metadata = map.get(peerId);
|
||||
if (metadata !== undefined) {
|
||||
callbacks.onPeer(metadata);
|
||||
}
|
||||
} else {
|
||||
callbacks.onPeerLost(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushExisting(callbacks: CRDTProviderCallbacks): void {
|
||||
if (this.peersMap === null || this.myPeerId === null) return;
|
||||
const snap = this.peersMap.toJSON();
|
||||
for (const [id, metadata] of Object.entries(snap)) {
|
||||
if (id !== this.myPeerId) callbacks.onPeer(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAbortError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# errors — `NetDiscCRDTError` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the frozen `NET_DISC_CRDT_CODES` registry and `NetDiscCRDTError` class every public boundary in `@suaveplan/network-discovery-crdt` throws on failure. Centralising the code list keeps consumers' branching exhaustive: a `switch (error.code)` either covers the whole set or fails type-checking.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes covering every failure path in the package: provider lifecycle (`PROVIDER_FAILED`), state-machine invariants (`STATE_DESYNC`, `DISPOSED`), peer-admission (`PEER_REJECTED`), input validation (`INVALID_OPTIONS`), and per-provider sync failures (`YJS_AWARENESS_FAILED`, `AUTOMERGE_SYNC_FAILED`).
|
||||
- Codes registered via `defineCodes("NET_DISC_CRDT", [...])` so they appear in the repo-wide error registry and never collide with other packages' codes.
|
||||
- Single `NetDiscCRDTError` class extending `SuaveplanError` — opaque enough to be caught with `instanceof`, structured enough to branch on `error.code`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NET_DISC_CRDT_CODES,
|
||||
NetDiscCRDTError,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
throw new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
|
||||
"errors.crdt.provider_failed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await discovery.start();
|
||||
} catch (err) {
|
||||
if (err instanceof NetDiscCRDTError) {
|
||||
switch (err.code) {
|
||||
case NET_DISC_CRDT_CODES.INVALID_OPTIONS:
|
||||
case NET_DISC_CRDT_CODES.DISPOSED:
|
||||
// fatal — re-throw
|
||||
throw err;
|
||||
case NET_DISC_CRDT_CODES.PROVIDER_FAILED:
|
||||
case NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED:
|
||||
case NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED:
|
||||
// transient — retry with backoff
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`NET_DISC_CRDT_CODES`** — frozen string-literal registry; keys equal the value strings.
|
||||
- **`NetDiscCRDTErrorCode`** — TypeScript union of every value in the registry.
|
||||
- **`NetDiscCRDTError`** — `class NetDiscCRDTError extends SuaveplanError<NetDiscCRDTErrorCode>`. Constructor: `(code, messageKey, options?: SuaveplanErrorOptions)`. `options.cause` accepts any thrown value and is preserved on the resulting `Error` chain.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven-code surface is deliberately small. Provider-level distinctions (`YJS_AWARENESS_FAILED` vs `AUTOMERGE_SYNC_FAILED`) exist because the two underlying CRDT libraries have very different failure modes and a single "sync failed" code would conflate them in consumer dashboards. `STATE_DESYNC` is reserved for cases the CRDT's own conflict-resolution cannot fix — e.g. an awareness payload that fails its Zod schema even after merging. The orchestrator never throws on simple peer churn (joins / leaves through normal CRDT operations); those flow through the `EventBusLike` instead.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "./errors.js";
|
||||
|
||||
describe("NET_DISC_CRDT_CODES", () => {
|
||||
it("registers the full code set", { timeout: 5000 }, () => {
|
||||
expect(NET_DISC_CRDT_CODES.PROVIDER_FAILED).toBe(
|
||||
"NET_DISC_CRDT.PROVIDER_FAILED"
|
||||
);
|
||||
expect(NET_DISC_CRDT_CODES.STATE_DESYNC).toBe(
|
||||
"NET_DISC_CRDT.STATE_DESYNC"
|
||||
);
|
||||
expect(NET_DISC_CRDT_CODES.PEER_REJECTED).toBe(
|
||||
"NET_DISC_CRDT.PEER_REJECTED"
|
||||
);
|
||||
expect(NET_DISC_CRDT_CODES.INVALID_OPTIONS).toBe(
|
||||
"NET_DISC_CRDT.INVALID_OPTIONS"
|
||||
);
|
||||
expect(NET_DISC_CRDT_CODES.DISPOSED).toBe("NET_DISC_CRDT.DISPOSED");
|
||||
expect(NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED).toBe(
|
||||
"NET_DISC_CRDT.YJS_AWARENESS_FAILED"
|
||||
);
|
||||
expect(NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED).toBe(
|
||||
"NET_DISC_CRDT.AUTOMERGE_SYNC_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetDiscCRDTError", () => {
|
||||
it("constructs with code and messageKey", { timeout: 5000 }, () => {
|
||||
const err = new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
|
||||
"errors.crdt.provider_failed"
|
||||
);
|
||||
expect(err).toBeInstanceOf(NetDiscCRDTError);
|
||||
expect(err.code).toBe(NET_DISC_CRDT_CODES.PROVIDER_FAILED);
|
||||
expect(err.name).toBe("NetDiscCRDTError");
|
||||
});
|
||||
|
||||
it("preserves cause on the error chain", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new NetDiscCRDTError(
|
||||
NET_DISC_CRDT_CODES.DISPOSED,
|
||||
"errors.crdt.disposed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error codes emitted by `@suaveplan/network-discovery-crdt`.
|
||||
*
|
||||
* - `PROVIDER_FAILED` — the underlying Yjs / Automerge provider could not be
|
||||
* initialised or surfaced an unrecoverable error during sync.
|
||||
* - `STATE_DESYNC` — the awareness document is in a state the orchestrator
|
||||
* cannot reconcile (e.g. duplicate-id collision the CRDT cannot resolve).
|
||||
* - `PEER_REJECTED` — a peer was rejected during admission (room full,
|
||||
* schema violation, etc).
|
||||
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, or a
|
||||
* required provider-specific config / factory was omitted.
|
||||
* - `DISPOSED` — operation attempted after `dispose()` / `stop()`.
|
||||
* - `YJS_AWARENESS_FAILED` — Yjs-specific awareness or sync path failed.
|
||||
* - `AUTOMERGE_SYNC_FAILED` — Automerge-specific sync path failed.
|
||||
*/
|
||||
export const NET_DISC_CRDT_CODES = defineCodes("NET_DISC_CRDT", [
|
||||
"PROVIDER_FAILED",
|
||||
"STATE_DESYNC",
|
||||
"PEER_REJECTED",
|
||||
"INVALID_OPTIONS",
|
||||
"DISPOSED",
|
||||
"YJS_AWARENESS_FAILED",
|
||||
"AUTOMERGE_SYNC_FAILED",
|
||||
] as const);
|
||||
|
||||
export type NetDiscCRDTErrorCode =
|
||||
(typeof NET_DISC_CRDT_CODES)[keyof typeof NET_DISC_CRDT_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class for the CRDT discovery strategy. Every public
|
||||
* boundary throws this on failure; consumers branch on `error.code`.
|
||||
*/
|
||||
export class NetDiscCRDTError extends SuaveplanError<NetDiscCRDTErrorCode> {
|
||||
public constructor(
|
||||
code: NetDiscCRDTErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetDiscCRDTError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Core
|
||||
export { AutomergeProviderAdapter } from "./core/automerge-provider/automerge-provider.js";
|
||||
export { CRDTDiscovery } from "./core/crdt-discovery/crdt-discovery.js";
|
||||
export {
|
||||
type CRDTIntervalDeps,
|
||||
CRDTIntervals,
|
||||
} from "./core/crdt-intervals/crdt-intervals.js";
|
||||
export { CRDTPeerManager } from "./core/crdt-peer-manager/crdt-peer-manager.js";
|
||||
export { YjsProviderAdapter } from "./core/yjs-provider/yjs-provider.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
NET_DISC_CRDT_CODES,
|
||||
NetDiscCRDTError,
|
||||
type NetDiscCRDTErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
type PeerDiscoveryOutcome,
|
||||
recordPeersDiscovered,
|
||||
recordReconcileDuration,
|
||||
withProviderSyncSpan,
|
||||
withStartSpan,
|
||||
} from "./otel/otel.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AutomergeConfig,
|
||||
AutomergeRepoFactory,
|
||||
AutomergeRepoLike,
|
||||
CRDTConnectionStatus,
|
||||
CRDTDiscoveredEvent,
|
||||
CRDTDiscoveryConfig,
|
||||
CRDTEventMap,
|
||||
CRDTLostEvent,
|
||||
CRDTMessageTypeValue,
|
||||
CRDTPeerMetadata,
|
||||
CRDTProvider,
|
||||
CRDTProviderAdapter,
|
||||
CRDTProviderCallbacks,
|
||||
CRDTStartedEvent,
|
||||
CRDTSyncFailedEvent,
|
||||
CRDTSyncStatus,
|
||||
ResolvedCRDTDiscoveryConfig,
|
||||
WebrtcProviderLike,
|
||||
YDocLike,
|
||||
YjsDocFactory,
|
||||
YjsSignalingConfig,
|
||||
YMapEventLike,
|
||||
YMapLike,
|
||||
} from "./types/types.js";
|
||||
export { CRDTMessageType } from "./types/types.js";
|
||||
|
||||
// Schemas
|
||||
export {
|
||||
AutomergeConfigSchema,
|
||||
CRDTDiscoveryConfigSchema,
|
||||
CRDTPeerMetadataSchema,
|
||||
CRDTProviderSchema,
|
||||
YjsSignalingConfigSchema,
|
||||
} from "./types/types.schemas.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
# otel — OpenTelemetry instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises every OTel instrument the package emits. Concentrating instrument construction in a single module keeps tracer / meter names consistent and stops the rest of the code from importing `@opentelemetry/api` directly. The instruments cover the discovery-strategy lifecycle (start span, per-provider sync span) and the two dimensions the operations team needs to chart: peer-discovery throughput per outcome (found / duplicate / rejected) per provider, and reconciliation-loop duration per provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Single tracer / meter under `@suaveplan/network-discovery-crdt`.
|
||||
- `withStartSpan` and `withProviderSyncSpan` wrappers managing span lifecycle, so the orchestrator never has to call `span.end()` manually.
|
||||
- `disc_crdt.peers_discovered_total{provider,outcome}` counter — three-label-cardinality outcome enum (`found` | `duplicate` | `rejected`) so dashboards stay bounded.
|
||||
- `disc_crdt.reconcile_duration_ms{provider}` histogram tracking awareness-reconciliation wall-clock.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordPeersDiscovered,
|
||||
withStartSpan,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
await withStartSpan(async (span) => {
|
||||
span.setAttribute("room", "demo");
|
||||
recordPeersDiscovered("yjs", "found", 1);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordReconcileDuration,
|
||||
withProviderSyncSpan,
|
||||
} from "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const t0 = Date.now();
|
||||
await withProviderSyncSpan("automerge", async () => {
|
||||
// adapter-level sync
|
||||
});
|
||||
recordReconcileDuration("automerge", Date.now() - t0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`withStartSpan<T>(fn)`** — runs `fn(span)` inside a `disc_crdt.start` span, always closes the span.
|
||||
- **`withProviderSyncSpan<T>(provider, fn)`** — runs `fn(span)` inside a `disc_crdt.provider.<provider>.sync` span; auto-attaches the `provider` attribute.
|
||||
- **`recordPeersDiscovered(provider, outcome, count?)`** — increments `disc_crdt.peers_discovered_total` (no-op when `count <= 0`).
|
||||
- **`recordReconcileDuration(provider, ms)`** — records on `disc_crdt.reconcile_duration_ms`; clamps `ms` at `0`.
|
||||
- **`PeerDiscoveryOutcome`** — `"found" | "duplicate" | "rejected"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The outcome label-set is intentionally three-valued. Adding finer-grained outcomes (e.g. "stale" / "expired") would explode counter cardinality without giving the dashboards information they cannot already derive from the room-state snapshot. Both span helpers always call `span.end()` in a `finally` block, so a thrown error inside the wrapped function still closes the span cleanly. Negative durations are clamped at zero because OTel's histogram contract rejects negative samples and a clock-skew event from an injected `Clock` should not crash discovery.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordPeersDiscovered,
|
||||
recordReconcileDuration,
|
||||
withProviderSyncSpan,
|
||||
withStartSpan,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("withStartSpan", () => {
|
||||
it("runs the body and returns its value", { timeout: 5000 }, async () => {
|
||||
const out = await withStartSpan(async (span) => {
|
||||
span.setAttribute("k", "v");
|
||||
return 42;
|
||||
});
|
||||
expect(out).toBe(42);
|
||||
});
|
||||
|
||||
it("closes the span even when body throws", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withStartSpan(async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withProviderSyncSpan", () => {
|
||||
it("attaches the provider attribute and returns body value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = await withProviderSyncSpan("yjs", async () => "ok");
|
||||
expect(out).toBe("ok");
|
||||
});
|
||||
|
||||
it("closes the span even when body throws", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withProviderSyncSpan("automerge", async () => {
|
||||
throw new Error("nope");
|
||||
})
|
||||
).rejects.toThrow("nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordPeersDiscovered", () => {
|
||||
it("accepts every outcome", { timeout: 5000 }, () => {
|
||||
recordPeersDiscovered("yjs", "found");
|
||||
recordPeersDiscovered("yjs", "duplicate", 2);
|
||||
recordPeersDiscovered("automerge", "rejected");
|
||||
});
|
||||
it("ignores non-positive counts", { timeout: 5000 }, () => {
|
||||
recordPeersDiscovered("yjs", "found", 0);
|
||||
recordPeersDiscovered("yjs", "found", -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordReconcileDuration", () => {
|
||||
it("clamps negatives at zero", { timeout: 5000 }, () => {
|
||||
recordReconcileDuration("yjs", -100);
|
||||
recordReconcileDuration("yjs", 1);
|
||||
recordReconcileDuration("automerge", 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
|
||||
import type { CRDTProvider } from "../types/types.js";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/network-discovery-crdt";
|
||||
const METER_NAME = "@suaveplan/network-discovery-crdt";
|
||||
|
||||
const tracer: Tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/**
|
||||
* Counter incremented for every peer the strategy emits, labelled by the
|
||||
* provider and the outcome of the discovery attempt.
|
||||
*/
|
||||
const peersDiscoveredCounter = meter.createCounter(
|
||||
"disc_crdt.peers_discovered_total",
|
||||
{
|
||||
description:
|
||||
"Total peers seen by the CRDT discovery strategy, by provider and outcome.",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram of reconciliation-loop wall-clock duration (ms), labelled by
|
||||
* provider.
|
||||
*/
|
||||
const reconcileDurationHistogram = meter.createHistogram(
|
||||
"disc_crdt.reconcile_duration_ms",
|
||||
{
|
||||
description: "CRDT reconciliation duration in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Wrap the `start()` body in an OTel span. The caller's function receives the
|
||||
* live span so it can attach attributes (e.g. provider, resolved peer id).
|
||||
*/
|
||||
export async function withStartSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("disc_crdt.start", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a provider-specific sync block in an OTel span.
|
||||
*/
|
||||
export async function withProviderSyncSpan<T>(
|
||||
provider: CRDTProvider,
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan(
|
||||
`disc_crdt.provider.${provider}.sync`,
|
||||
async (span) => {
|
||||
try {
|
||||
span.setAttribute("provider", provider);
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome label for {@link recordPeersDiscovered}. */
|
||||
export type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected";
|
||||
|
||||
/**
|
||||
* Increment the peers-discovered counter.
|
||||
*/
|
||||
export function recordPeersDiscovered(
|
||||
provider: CRDTProvider,
|
||||
outcome: PeerDiscoveryOutcome,
|
||||
count = 1
|
||||
): void {
|
||||
if (count > 0) {
|
||||
peersDiscoveredCounter.add(count, { provider, outcome });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the wall-clock duration of a reconciliation loop.
|
||||
*/
|
||||
export function recordReconcileDuration(
|
||||
provider: CRDTProvider,
|
||||
ms: number
|
||||
): void {
|
||||
reconcileDurationHistogram.record(Math.max(0, ms), { provider });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# types — public surface of `@suaveplan/network-discovery-crdt`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module hosts every type the public API of `@suaveplan/network-discovery-crdt` exposes: the `CRDTProvider` discriminator, the `CRDTDiscoveryConfig` / `ResolvedCRDTDiscoveryConfig` pair, the shared `CRDTProviderAdapter` contract both Yjs and Automerge backends conform to, the wire-format message envelope, the structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views the test suite implements without touching real CRDT libraries, and the typed `CRDTEventMap` emitted on an optional `EventBusLike`. Keeping every type in one module means a downstream consumer can `import type { CRDTDiscoveryConfig } from "@suaveplan/network-discovery-crdt/types"` without pulling in the orchestrator class.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-source `CRDTProvider` discriminator (`"yjs" | "automerge"`) referenced everywhere.
|
||||
- Strict `CRDTDiscoveryConfig` requiring per-provider config + factory at construction time.
|
||||
- `CRDTProviderAdapter` interface — both providers conform; orchestrator never branches on `provider` strings.
|
||||
- Structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views allowing real-implementation test fakes (no `vi.mock`).
|
||||
- Typed `CRDTEventMap` for the optional `@suaveplan/events` bus integration.
|
||||
- Frozen `CRDTMessageType` registry for the wire format (kept for parity with the rest of the discovery family).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CRDTDiscoveryConfig,
|
||||
CRDTProvider,
|
||||
} from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
const cfg: CRDTDiscoveryConfig = {
|
||||
provider: "yjs",
|
||||
room: "demo",
|
||||
yjs: { signaling: ["wss://signal.local"] },
|
||||
yjsDocFactory: (room, opts) => ({ doc: makeDoc(), provider: makeProvider() }),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CRDTProviderAdapter,
|
||||
CRDTProviderCallbacks,
|
||||
} from "@suaveplan/network-discovery-crdt/types";
|
||||
|
||||
class MyProvider implements CRDTProviderAdapter {
|
||||
public readonly kind = "yjs" as const;
|
||||
public get isConnected() { return true; }
|
||||
public async initialize(cb: CRDTProviderCallbacks): Promise<string> { return "id"; }
|
||||
public announce(): void {}
|
||||
public leave(): void {}
|
||||
public listKnownPeers() { return []; }
|
||||
public destroy(): void {}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`CRDTProvider`** — `"yjs" | "automerge"` discriminator.
|
||||
- **`CRDTMessageType`** / **`CRDTMessageTypeValue`** — frozen wire-format message-type registry.
|
||||
- **`CRDTPeerMetadata`** — awareness-document peer record.
|
||||
- **`YjsSignalingConfig`** / **`AutomergeConfig`** — per-provider option blocks.
|
||||
- **`YDocLike`** / **`YMapLike`** / **`YMapEventLike`** / **`WebrtcProviderLike`** — structural Yjs surfaces.
|
||||
- **`YjsDocFactory`** — `(room, opts) => { doc, provider }` injection point.
|
||||
- **`AutomergeRepoLike`** / **`AutomergeRepoFactory`** — structural Automerge surfaces + injection.
|
||||
- **`CRDTProviderAdapter`** / **`CRDTProviderCallbacks`** — shared provider contract.
|
||||
- **`CRDTDiscoveryConfig`** / **`ResolvedCRDTDiscoveryConfig`** — public + post-resolve config.
|
||||
- **`CRDTSyncStatus`** / **`CRDTConnectionStatus`** — status snapshots.
|
||||
- **`CRDTEventMap`** + the four event types — typed `EventBusLike` payloads.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module is type-only — every export is a `type` / `interface`, except `CRDTMessageType` which is a frozen string-literal record kept compatible with the upstream wire format. Treating the wire-format as a record (not a TS `enum`) lets it survive `isolatedModules` and avoids the implicit `const enum` foot-gun. The `CRDTProviderAdapter` interface is the package's most important abstraction: it lets the orchestrator call `adapter.initialize()`, `adapter.announce()`, `adapter.leave()`, `adapter.destroy()` without ever knowing whether it's driving Yjs awareness or an Automerge repo. The structural `*Like` views deliberately keep only the surface the discovery layer needs — they are smaller than the real Yjs / Automerge type surfaces, which keeps the test fakes implementable in ~100 lines each.
|
||||
@@ -0,0 +1,61 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas for every public option struct and wire message in
|
||||
* `@suaveplan/network-discovery-crdt`. Callable contracts (`yjsDocFactory`,
|
||||
* `automergeRepoFactory`, `EventBusLike`, `ServiceRegistry`) are type-only
|
||||
* and tolerated as `z.unknown()` — runtime checks live in the constructor
|
||||
* that consumes them.
|
||||
*/
|
||||
|
||||
export const CRDTProviderSchema = z.enum(["yjs", "automerge"]);
|
||||
|
||||
export const CRDTPeerMetadataSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
addresses: z.array(z.string()).readonly(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
lastSeen: z.number().int().nonnegative(),
|
||||
provider: CRDTProviderSchema,
|
||||
version: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const YjsSignalingConfigSchema = z
|
||||
.object({
|
||||
signaling: z.array(z.string().url()).min(1),
|
||||
password: z.string().min(1).optional(),
|
||||
maxConns: z.number().int().positive().default(20),
|
||||
filterBcConns: z.boolean().optional(),
|
||||
peerExchange: z.boolean().default(true),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AutomergeConfigSchema = z
|
||||
.object({
|
||||
websocketUrl: z.string().url().optional(),
|
||||
enableBroadcastChannel: z.boolean().default(true),
|
||||
enableIndexedDB: z.boolean().default(true),
|
||||
storageKey: z.string().min(1).default("automerge-discovery"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const CRDTDiscoveryConfigSchema = z
|
||||
.object({
|
||||
provider: CRDTProviderSchema,
|
||||
room: z.string().min(1),
|
||||
yjs: YjsSignalingConfigSchema.optional(),
|
||||
automerge: AutomergeConfigSchema.optional(),
|
||||
interval: z.number().int().positive().default(30_000),
|
||||
timeout: z.number().int().positive().default(60_000),
|
||||
heartbeatInterval: z.number().int().positive().default(15_000),
|
||||
cleanupInterval: z.number().int().positive().default(30_000),
|
||||
maxPeers: z.number().int().positive().default(100),
|
||||
iceServers: z.array(z.unknown()).optional(),
|
||||
yjsDocFactory: z.unknown().optional(),
|
||||
automergeRepoFactory: z.unknown().optional(),
|
||||
bus: z.unknown().optional(),
|
||||
registry: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Identifier for which CRDT backend `CRDTDiscovery` is wired against. Fixed
|
||||
* at construction time; cannot be swapped at runtime.
|
||||
*/
|
||||
export type CRDTProvider = "yjs" | "automerge";
|
||||
|
||||
/**
|
||||
* Discovery-message types exchanged via the CRDT awareness layer.
|
||||
*
|
||||
* Frozen string-literal union (kept as a record so the wire-format values
|
||||
* stay stable across the family of `network-discovery-*` packages).
|
||||
*/
|
||||
export const CRDTMessageType = Object.freeze({
|
||||
/** Announce presence + metadata in the shared awareness document. */
|
||||
ANNOUNCE: "announce",
|
||||
/** Request the full known peer-list. */
|
||||
DISCOVER: "discover",
|
||||
/** Reply to a `DISCOVER` with an array of peer ids. */
|
||||
PEER_LIST: "peer_list",
|
||||
/** Voluntary leave notification. */
|
||||
LEAVE: "leave",
|
||||
/** Liveness ping. */
|
||||
HEARTBEAT: "heartbeat",
|
||||
/** Document sync state update (Yjs/Automerge handled internally). */
|
||||
SYNC: "sync",
|
||||
} as const);
|
||||
|
||||
export type CRDTMessageTypeValue =
|
||||
(typeof CRDTMessageType)[keyof typeof CRDTMessageType];
|
||||
|
||||
/**
|
||||
* Peer metadata broadcast over the CRDT awareness map.
|
||||
*/
|
||||
export interface CRDTPeerMetadata {
|
||||
/** Unique peer id (assigned by the provider). */
|
||||
readonly id: string;
|
||||
/** Reachable addresses (libp2p-style multiaddrs are accepted; opaque). */
|
||||
readonly addresses: readonly string[];
|
||||
/** Free-form caller-supplied metadata. */
|
||||
readonly metadata: Readonly<Record<string, unknown>>;
|
||||
/** Optional capability strings the peer advertises. */
|
||||
readonly capabilities?: readonly string[];
|
||||
/** Unix-ms timestamp of the most recent observation. */
|
||||
lastSeen: number;
|
||||
/** Provider that produced this record. */
|
||||
readonly provider: CRDTProvider;
|
||||
/** Document/clock version for sync state. */
|
||||
readonly version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yjs-specific signalling configuration. Mirrors `y-webrtc` provider options
|
||||
* verbatim, modulo Zod-driven defaults.
|
||||
*/
|
||||
export interface YjsSignalingConfig {
|
||||
/** WebRTC signalling-server URLs. REQUIRED — no implicit public default. */
|
||||
readonly signaling: readonly string[];
|
||||
/** Password for room encryption. */
|
||||
readonly password?: string | undefined;
|
||||
/** Maximum WebRTC connections. Default `20`. */
|
||||
readonly maxConns?: number | undefined;
|
||||
/** Filter for broadcast-channel connections. */
|
||||
readonly filterBcConns?: boolean | undefined;
|
||||
/** Whether to enable peer-exchange. Default `true`. */
|
||||
readonly peerExchange?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automerge-specific configuration.
|
||||
*/
|
||||
export interface AutomergeConfig {
|
||||
/** WebSocket sync-server URL. */
|
||||
readonly websocketUrl?: string | undefined;
|
||||
/** Enable broadcast-channel intra-tab transport. Default `true`. */
|
||||
readonly enableBroadcastChannel?: boolean | undefined;
|
||||
/** Enable IndexedDB storage. Default `true`. */
|
||||
readonly enableIndexedDB?: boolean | undefined;
|
||||
/** IndexedDB storage key. Default `automerge-discovery`. */
|
||||
readonly storageKey?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural surface of a Yjs `Y.Doc` used by this package. Tests bind a
|
||||
* deterministic in-memory implementation; production wires to `new Doc()`
|
||||
* from `yjs`.
|
||||
*/
|
||||
export interface YDocLike {
|
||||
readonly guid: string;
|
||||
getMap(name: string): YMapLike;
|
||||
destroy(): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural surface of a Yjs `Y.Map` used by this package.
|
||||
*/
|
||||
export interface YMapLike {
|
||||
set(key: string, value: CRDTPeerMetadata): void;
|
||||
get(key: string): CRDTPeerMetadata | undefined;
|
||||
delete(key: string): void;
|
||||
has(key: string): boolean;
|
||||
observe(handler: (event: YMapEventLike) => void): void;
|
||||
unobserve(handler: (event: YMapEventLike) => void): void;
|
||||
toJSON(): Record<string, CRDTPeerMetadata>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural surface of the change event emitted on `YMapLike.observe`.
|
||||
*/
|
||||
export interface YMapEventLike {
|
||||
readonly changes: {
|
||||
readonly keys: ReadonlyMap<
|
||||
string,
|
||||
{ readonly action: "add" | "update" | "delete" }
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural surface of a `y-webrtc` `WebrtcProvider` used by this package.
|
||||
*/
|
||||
export interface WebrtcProviderLike {
|
||||
readonly connected: boolean;
|
||||
readonly synced: boolean;
|
||||
on(event: "synced", handler: () => void): void;
|
||||
on(
|
||||
event: "peers",
|
||||
handler: (e: {
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
}) => void
|
||||
): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory wiring `(roomName, doc, options) => WebrtcProviderLike`. Production
|
||||
* binds this to `new (await import("y-webrtc")).WebrtcProvider`; tests pass a
|
||||
* deterministic stub.
|
||||
*/
|
||||
export type YjsDocFactory = (
|
||||
room: string,
|
||||
opts: YjsSignalingConfig
|
||||
) => {
|
||||
readonly doc: YDocLike;
|
||||
readonly provider: WebrtcProviderLike;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural surface of an Automerge `Repo` used by this package. Tests bind
|
||||
* a deterministic in-memory implementation; production wires to `new Repo()`
|
||||
* from `@automerge/automerge-repo`.
|
||||
*/
|
||||
export interface AutomergeRepoLike {
|
||||
/** Document id of the shared awareness doc. */
|
||||
readonly peerId: string;
|
||||
/** Subscribe to repo-level peer connection events. */
|
||||
on(event: "peer", handler: (e: { peerId: string }) => void): void;
|
||||
on(
|
||||
event: "peer-disconnected",
|
||||
handler: (e: { peerId: string }) => void
|
||||
): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
/** Announce local peer metadata into the shared map. */
|
||||
announce(metadata: CRDTPeerMetadata): void;
|
||||
/** Snapshot of all currently-known peers (sans the local peer). */
|
||||
listPeers(): readonly CRDTPeerMetadata[];
|
||||
/** Tear the repo down, closing every network adapter. */
|
||||
shutdown(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory wiring `(room, opts) => AutomergeRepoLike`. Production binds this
|
||||
* to a `new Repo({network: [...], storage})` instance wrapped to match
|
||||
* `AutomergeRepoLike`. Tests pass a deterministic stub.
|
||||
*/
|
||||
export type AutomergeRepoFactory = (
|
||||
room: string,
|
||||
opts: AutomergeConfig
|
||||
) => AutomergeRepoLike;
|
||||
|
||||
/**
|
||||
* Shared adapter contract both providers conform to. The orchestrator drives
|
||||
* provider lifecycle without branching on the provider string.
|
||||
*/
|
||||
export interface CRDTProviderAdapter {
|
||||
readonly kind: CRDTProvider;
|
||||
/** Initialise the provider, returning the assigned local peer id. */
|
||||
initialize(
|
||||
callbacks: CRDTProviderCallbacks,
|
||||
signal?: AbortSignal
|
||||
): Promise<string>;
|
||||
/** Announce local peer metadata. */
|
||||
announce(metadata: CRDTPeerMetadata): void;
|
||||
/** Mark the local peer as leaving (idempotent). */
|
||||
leave(): void;
|
||||
/** Snapshot of peers known to the underlying CRDT (sans local). */
|
||||
listKnownPeers(): readonly CRDTPeerMetadata[];
|
||||
/** Tear down. Idempotent. */
|
||||
destroy(): void;
|
||||
/** Whether the underlying transport is connected. */
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks fired by the adapter into the orchestrator.
|
||||
*/
|
||||
export interface CRDTProviderCallbacks {
|
||||
/** A peer (remote) was observed — added or updated. */
|
||||
onPeer(metadata: CRDTPeerMetadata): void;
|
||||
/** A peer is no longer in the awareness set. */
|
||||
onPeerLost(peerId: string): void;
|
||||
/** Provider has caught up with at least one remote. */
|
||||
onSynced(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict configuration for `CRDTDiscovery`. `provider` and `room` are
|
||||
* required. Per-provider blocks are required for the matching provider.
|
||||
*/
|
||||
export interface CRDTDiscoveryConfig {
|
||||
/** Provider selection. Fixed at construction time. */
|
||||
readonly provider: CRDTProvider;
|
||||
/** Logical room — only same-room peers discover each other. */
|
||||
readonly room: string;
|
||||
/** Yjs config (REQUIRED when `provider === "yjs"`). */
|
||||
readonly yjs?: YjsSignalingConfig;
|
||||
/** Automerge config (REQUIRED when `provider === "automerge"`). */
|
||||
readonly automerge?: AutomergeConfig;
|
||||
/** Announce interval (ms). Default `30_000`. */
|
||||
readonly interval?: number;
|
||||
/** Peer staleness timeout (ms). Default `60_000`. */
|
||||
readonly timeout?: number;
|
||||
/** Heartbeat interval (ms). Default `15_000`. */
|
||||
readonly heartbeatInterval?: number;
|
||||
/** Cleanup-loop interval (ms). Default `30_000`. */
|
||||
readonly cleanupInterval?: number;
|
||||
/** Hard cap on tracked peers. Default `100`. */
|
||||
readonly maxPeers?: number;
|
||||
/** WebRTC ICE servers (Yjs only). */
|
||||
readonly iceServers?: readonly RTCIceServer[];
|
||||
/** Injectable Yjs factory — tests pass a stub. REQUIRED for yjs provider. */
|
||||
readonly yjsDocFactory?: YjsDocFactory;
|
||||
/** Injectable Automerge factory — tests pass a stub. REQUIRED for automerge provider. */
|
||||
readonly automergeRepoFactory?: AutomergeRepoFactory;
|
||||
/** Optional event bus for typed emissions. */
|
||||
readonly bus?: EventBusLike<CRDTEventMap>;
|
||||
/** Optional service registry to mirror discovered peers into. */
|
||||
readonly registry?: ServiceRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully resolved config — every optional field defaulted, every required
|
||||
* provider factory present.
|
||||
*/
|
||||
export interface ResolvedCRDTDiscoveryConfig {
|
||||
readonly provider: CRDTProvider;
|
||||
readonly room: string;
|
||||
readonly yjs: YjsSignalingConfig | undefined;
|
||||
readonly automerge: AutomergeConfig | undefined;
|
||||
readonly interval: number;
|
||||
readonly timeout: number;
|
||||
readonly heartbeatInterval: number;
|
||||
readonly cleanupInterval: number;
|
||||
readonly maxPeers: number;
|
||||
readonly iceServers: readonly RTCIceServer[] | undefined;
|
||||
readonly yjsDocFactory: YjsDocFactory | undefined;
|
||||
readonly automergeRepoFactory: AutomergeRepoFactory | undefined;
|
||||
readonly bus: EventBusLike<CRDTEventMap> | undefined;
|
||||
readonly registry: ServiceRegistry | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status the orchestrator can surface to callers.
|
||||
*/
|
||||
export interface CRDTSyncStatus {
|
||||
readonly synced: boolean;
|
||||
readonly syncedPeers: number;
|
||||
readonly pendingPeers: number;
|
||||
readonly version: number;
|
||||
readonly lastSync: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall connection status.
|
||||
*/
|
||||
export interface CRDTConnectionStatus {
|
||||
readonly provider: CRDTProvider;
|
||||
readonly connected: boolean;
|
||||
readonly peerCount: number;
|
||||
readonly syncStatus: CRDTSyncStatus;
|
||||
}
|
||||
|
||||
/** Typed events emitted on the optional `EventBusLike`. */
|
||||
export interface CRDTStartedEvent {
|
||||
readonly peerId: string;
|
||||
readonly provider: CRDTProvider;
|
||||
readonly room: string;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface CRDTDiscoveredEvent {
|
||||
readonly peer: CRDTPeerMetadata;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface CRDTLostEvent {
|
||||
readonly peerId: string;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface CRDTSyncFailedEvent {
|
||||
readonly reason: string;
|
||||
readonly provider: CRDTProvider;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export type CRDTEventMap = {
|
||||
"crdt:started": CRDTStartedEvent;
|
||||
"crdt:peer-discovered": CRDTDiscoveredEvent;
|
||||
"crdt:peer-lost": CRDTLostEvent;
|
||||
"crdt:sync-failed": CRDTSyncFailedEvent;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-discovery-crdt dep-graph", () => {
|
||||
it("must not have circular dependencies", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.CRDTDiscovery).toBeDefined();
|
||||
expect(mod.CRDTPeerManager).toBeDefined();
|
||||
expect(mod.CRDTIntervals).toBeDefined();
|
||||
expect(mod.YjsProviderAdapter).toBeDefined();
|
||||
expect(mod.AutomergeProviderAdapter).toBeDefined();
|
||||
expect(mod.NET_DISC_CRDT_CODES).toBeDefined();
|
||||
expect(mod.NetDiscCRDTError).toBeDefined();
|
||||
expect(mod.CRDTDiscoveryConfigSchema).toBeDefined();
|
||||
expect(mod.CRDTMessageType).toBeDefined();
|
||||
expect(mod.withStartSpan).toBeDefined();
|
||||
expect(mod.recordPeersDiscovered).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Deterministic in-memory Automerge substitute used by the test suites.
|
||||
* Models a single shared broker — every `FakeAutomergeRepo` constructed
|
||||
* against the same `FakeAutomergeBroker` shares awareness updates with every
|
||||
* other repo in the same room. No mocks: real implementation of the
|
||||
* `AutomergeRepoLike` structural contract.
|
||||
*/
|
||||
import type {
|
||||
AutomergeRepoFactory,
|
||||
AutomergeRepoLike,
|
||||
CRDTPeerMetadata,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export class FakeAutomergeBroker {
|
||||
public readonly rooms = new Map<string, Set<FakeAutomergeRepo>>();
|
||||
public broken = false;
|
||||
|
||||
public join(room: string, repo: FakeAutomergeRepo): void {
|
||||
let set = this.rooms.get(room);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.rooms.set(room, set);
|
||||
}
|
||||
// Replay every existing peer's snapshot into the joiner before fire.
|
||||
for (const other of set) {
|
||||
for (const m of other.listPeers()) repo.receiveAnnounce(m);
|
||||
}
|
||||
set.add(repo);
|
||||
queueMicrotask(() => {
|
||||
for (const other of set as Set<FakeAutomergeRepo>) {
|
||||
if (other === repo) continue;
|
||||
other.notePeer(repo.peerId);
|
||||
repo.notePeer(other.peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public leave(room: string, repo: FakeAutomergeRepo): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
set.delete(repo);
|
||||
for (const other of set) other.notePeerLost(repo.peerId);
|
||||
}
|
||||
|
||||
public broadcast(
|
||||
room: string,
|
||||
sender: FakeAutomergeRepo,
|
||||
metadata: CRDTPeerMetadata
|
||||
): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
for (const other of set) {
|
||||
if (other === sender) continue;
|
||||
other.receiveAnnounce(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeAutomergeRepo implements AutomergeRepoLike {
|
||||
public readonly peerId: string;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private readonly known = new Map<string, CRDTPeerMetadata>();
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
public readonly room: string,
|
||||
private readonly broker: FakeAutomergeBroker
|
||||
) {
|
||||
nextId += 1;
|
||||
this.peerId = `fake-am-${nextId}`;
|
||||
broker.join(room, this);
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
public announce(metadata: CRDTPeerMetadata): void {
|
||||
if (this.destroyed) return;
|
||||
// Mirror the announce locally so late joiners can replay it.
|
||||
this.known.set(metadata.id, metadata);
|
||||
this.broker.broadcast(this.room, this, metadata);
|
||||
}
|
||||
|
||||
public listPeers(): readonly CRDTPeerMetadata[] {
|
||||
return Array.from(this.known.values());
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.broker.leave(this.room, this);
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public notePeer(peerId: string): void {
|
||||
this.fire("peer", { peerId });
|
||||
}
|
||||
|
||||
public notePeerLost(peerId: string): void {
|
||||
this.known.delete(peerId);
|
||||
this.fire("peer-disconnected", { peerId });
|
||||
}
|
||||
|
||||
public receiveAnnounce(metadata: CRDTPeerMetadata): void {
|
||||
this.known.set(metadata.id, metadata);
|
||||
this.fire("peer", { peerId: metadata.id });
|
||||
}
|
||||
|
||||
private fire(event: string, ...args: unknown[]): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an `AutomergeRepoFactory` bound to a shared broker. */
|
||||
export function fakeAutomergeRepoFactory(
|
||||
broker: FakeAutomergeBroker
|
||||
): AutomergeRepoFactory {
|
||||
return (room) => {
|
||||
if (broker.broken) {
|
||||
throw new Error("broker down");
|
||||
}
|
||||
return new FakeAutomergeRepo(room, broker);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Deterministic in-memory Yjs substitute used by the test suites. Models a
|
||||
* single shared "room" broker — every `FakeYDoc` constructed against the same
|
||||
* `FakeYjsBroker` shares its `peers` map updates with every other doc. No
|
||||
* mocks: this is a real implementation of the `YDocLike` / `YMapLike` /
|
||||
* `WebrtcProviderLike` structural contracts.
|
||||
*/
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
WebrtcProviderLike,
|
||||
YDocLike,
|
||||
YjsDocFactory,
|
||||
YMapEventLike,
|
||||
YMapLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
let nextGuid = 0;
|
||||
|
||||
export class FakeYjsBroker {
|
||||
public readonly rooms = new Map<string, Set<FakeWebrtcProvider>>();
|
||||
public broken = false;
|
||||
|
||||
public join(room: string, provider: FakeWebrtcProvider): void {
|
||||
let set = this.rooms.get(room);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.rooms.set(room, set);
|
||||
}
|
||||
// Replay every existing peer's current map state to the new joiner
|
||||
// before announcing presence — mirrors `y-webrtc`'s "sync existing
|
||||
// state on join" semantics.
|
||||
for (const other of set) {
|
||||
const snap = other.snapshotPeers();
|
||||
for (const [key, value] of snap) {
|
||||
provider.applyRemote(key, value, "add");
|
||||
}
|
||||
}
|
||||
set.add(provider);
|
||||
// Sync immediately — everyone gets everyone else's current peers.
|
||||
queueMicrotask(() => {
|
||||
provider.fireSynced();
|
||||
for (const other of set as Set<FakeWebrtcProvider>) {
|
||||
if (other !== provider) {
|
||||
other.notePeerAdded(provider.doc.guid);
|
||||
provider.notePeerAdded(other.doc.guid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public leave(room: string, provider: FakeWebrtcProvider): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
set.delete(provider);
|
||||
for (const other of set) {
|
||||
other.notePeerRemoved(provider.doc.guid);
|
||||
}
|
||||
}
|
||||
|
||||
public broadcast(
|
||||
room: string,
|
||||
sender: FakeWebrtcProvider,
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
for (const other of set) {
|
||||
if (other === sender) continue;
|
||||
other.applyRemote(key, value, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeYMap implements YMapLike {
|
||||
private readonly data = new Map<string, CRDTPeerMetadata>();
|
||||
private readonly observers = new Set<(e: YMapEventLike) => void>();
|
||||
|
||||
public constructor(
|
||||
private readonly emitRemote?: (
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void
|
||||
) {}
|
||||
|
||||
public set(key: string, value: CRDTPeerMetadata): void {
|
||||
const action: "add" | "update" = this.data.has(key) ? "update" : "add";
|
||||
this.data.set(key, value);
|
||||
this.fire(key, action);
|
||||
this.emitRemote?.(key, value, action);
|
||||
}
|
||||
|
||||
public get(key: string): CRDTPeerMetadata | undefined {
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
public delete(key: string): void {
|
||||
if (!this.data.delete(key)) return;
|
||||
this.fire(key, "delete");
|
||||
this.emitRemote?.(key, undefined, "delete");
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
public observe(handler: (e: YMapEventLike) => void): void {
|
||||
this.observers.add(handler);
|
||||
}
|
||||
|
||||
public unobserve(handler: (e: YMapEventLike) => void): void {
|
||||
this.observers.delete(handler);
|
||||
}
|
||||
|
||||
public toJSON(): Record<string, CRDTPeerMetadata> {
|
||||
return Object.fromEntries(this.data.entries());
|
||||
}
|
||||
|
||||
/** Apply a remote change without echoing it back to the network. */
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
if (action === "delete") {
|
||||
if (!this.data.delete(key)) return;
|
||||
} else {
|
||||
if (value === undefined) return;
|
||||
this.data.set(key, value);
|
||||
}
|
||||
this.fire(key, action);
|
||||
}
|
||||
|
||||
private fire(key: string, action: "add" | "update" | "delete"): void {
|
||||
const event: YMapEventLike = {
|
||||
changes: { keys: new Map([[key, { action }]]) },
|
||||
};
|
||||
for (const observer of this.observers) observer(event);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeYDoc implements YDocLike {
|
||||
public readonly guid: string;
|
||||
private map: FakeYMap | null = null;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private remoteEmitter:
|
||||
| ((
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
public constructor(guid?: string) {
|
||||
nextGuid += 1;
|
||||
this.guid = guid ?? `fake-doc-${nextGuid}`;
|
||||
}
|
||||
|
||||
public attachEmitter(
|
||||
emitter: (
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void
|
||||
): void {
|
||||
this.remoteEmitter = emitter;
|
||||
}
|
||||
|
||||
public getMap(_name: string): YMapLike {
|
||||
if (this.map === null) {
|
||||
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
|
||||
}
|
||||
return this.map;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.fire("destroy");
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
/** Apply a remote map mutation (received via the broker). */
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
if (this.map === null) {
|
||||
// Lazily create the map so applyRemote works before observers attach.
|
||||
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
|
||||
}
|
||||
this.map.applyRemote(key, value, action);
|
||||
}
|
||||
|
||||
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
|
||||
if (this.map === null) return [];
|
||||
return Object.entries(this.map.toJSON());
|
||||
}
|
||||
|
||||
private fire(event: string): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h();
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeWebrtcProvider implements WebrtcProviderLike {
|
||||
public readonly doc: FakeYDoc;
|
||||
public readonly room: string;
|
||||
public connected = true;
|
||||
public synced = false;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
room: string,
|
||||
doc: FakeYDoc,
|
||||
private readonly broker: FakeYjsBroker
|
||||
) {
|
||||
this.doc = doc;
|
||||
this.room = room;
|
||||
doc.attachEmitter((k, v, a) => broker.broadcast(room, this, k, v, a));
|
||||
if (broker.broken) {
|
||||
// Schedule an immediate error after construction so callers can wire
|
||||
// handlers before the failure fires.
|
||||
queueMicrotask(() => {
|
||||
this.connected = false;
|
||||
this.fire("connection-error", new Error("broker down"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
broker.join(room, this);
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.connected = false;
|
||||
this.broker.leave(this.room, this);
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public fireSynced(): void {
|
||||
this.synced = true;
|
||||
this.fire("synced");
|
||||
}
|
||||
|
||||
public notePeerAdded(peerId: string): void {
|
||||
this.fire("peers", { added: [peerId], removed: [] });
|
||||
}
|
||||
|
||||
public notePeerRemoved(peerId: string): void {
|
||||
this.fire("peers", { added: [], removed: [peerId] });
|
||||
}
|
||||
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
this.doc.applyRemote(key, value, action);
|
||||
}
|
||||
|
||||
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
|
||||
return this.doc.snapshotPeers();
|
||||
}
|
||||
|
||||
private fire(event: string, ...args: unknown[]): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `YjsDocFactory` bound to a shared broker. */
|
||||
export function fakeYjsDocFactory(broker: FakeYjsBroker): YjsDocFactory {
|
||||
return (room) => {
|
||||
const doc = new FakeYDoc();
|
||||
const provider = new FakeWebrtcProvider(room, doc, broker);
|
||||
return { doc, provider };
|
||||
};
|
||||
}
|
||||
|
||||
/** Yield control until pending microtasks have drained. */
|
||||
export async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-discovery-crdt",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/network-bootstrap",
|
||||
"@suaveplan/service-discovery",
|
||||
"@suaveplan/timing",
|
||||
"@suaveplan/types",
|
||||
"yjs",
|
||||
"y-webrtc",
|
||||
"@automerge/automerge-repo",
|
||||
"@automerge/automerge-repo-network-broadcastchannel",
|
||||
"@automerge/automerge-repo-network-websocket",
|
||||
"@automerge/automerge-repo-storage-indexeddb",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user