Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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"
}
}

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { AutomergeProviderAdapter } from "./automerge-provider.js";

View File

@@ -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.

View File

@@ -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 }
);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { CRDTDiscovery } from "./crdt-discovery.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,4 @@
export {
type CRDTIntervalDeps,
CRDTIntervals,
} from "./crdt-intervals.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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}`);
}
}
}

View File

@@ -0,0 +1 @@
export { CRDTPeerManager } from "./crdt-peer-manager.js";

View File

@@ -0,0 +1 @@
export { YjsProviderAdapter } from "./yjs-provider.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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";
}
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 });
}

View File

@@ -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.

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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();
});
});

View File

@@ -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);
};
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -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"]
}

View File

@@ -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:/,
],
},
},
});

View File

@@ -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,
},
});