Initial Commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-discovery-crdt dep-graph", () => {
|
||||
it("must not have circular dependencies", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.CRDTDiscovery).toBeDefined();
|
||||
expect(mod.CRDTPeerManager).toBeDefined();
|
||||
expect(mod.CRDTIntervals).toBeDefined();
|
||||
expect(mod.YjsProviderAdapter).toBeDefined();
|
||||
expect(mod.AutomergeProviderAdapter).toBeDefined();
|
||||
expect(mod.NET_DISC_CRDT_CODES).toBeDefined();
|
||||
expect(mod.NetDiscCRDTError).toBeDefined();
|
||||
expect(mod.CRDTDiscoveryConfigSchema).toBeDefined();
|
||||
expect(mod.CRDTMessageType).toBeDefined();
|
||||
expect(mod.withStartSpan).toBeDefined();
|
||||
expect(mod.recordPeersDiscovered).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Deterministic in-memory Automerge substitute used by the test suites.
|
||||
* Models a single shared broker — every `FakeAutomergeRepo` constructed
|
||||
* against the same `FakeAutomergeBroker` shares awareness updates with every
|
||||
* other repo in the same room. No mocks: real implementation of the
|
||||
* `AutomergeRepoLike` structural contract.
|
||||
*/
|
||||
import type {
|
||||
AutomergeRepoFactory,
|
||||
AutomergeRepoLike,
|
||||
CRDTPeerMetadata,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export class FakeAutomergeBroker {
|
||||
public readonly rooms = new Map<string, Set<FakeAutomergeRepo>>();
|
||||
public broken = false;
|
||||
|
||||
public join(room: string, repo: FakeAutomergeRepo): void {
|
||||
let set = this.rooms.get(room);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.rooms.set(room, set);
|
||||
}
|
||||
// Replay every existing peer's snapshot into the joiner before fire.
|
||||
for (const other of set) {
|
||||
for (const m of other.listPeers()) repo.receiveAnnounce(m);
|
||||
}
|
||||
set.add(repo);
|
||||
queueMicrotask(() => {
|
||||
for (const other of set as Set<FakeAutomergeRepo>) {
|
||||
if (other === repo) continue;
|
||||
other.notePeer(repo.peerId);
|
||||
repo.notePeer(other.peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public leave(room: string, repo: FakeAutomergeRepo): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
set.delete(repo);
|
||||
for (const other of set) other.notePeerLost(repo.peerId);
|
||||
}
|
||||
|
||||
public broadcast(
|
||||
room: string,
|
||||
sender: FakeAutomergeRepo,
|
||||
metadata: CRDTPeerMetadata
|
||||
): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
for (const other of set) {
|
||||
if (other === sender) continue;
|
||||
other.receiveAnnounce(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeAutomergeRepo implements AutomergeRepoLike {
|
||||
public readonly peerId: string;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private readonly known = new Map<string, CRDTPeerMetadata>();
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
public readonly room: string,
|
||||
private readonly broker: FakeAutomergeBroker
|
||||
) {
|
||||
nextId += 1;
|
||||
this.peerId = `fake-am-${nextId}`;
|
||||
broker.join(room, this);
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
public announce(metadata: CRDTPeerMetadata): void {
|
||||
if (this.destroyed) return;
|
||||
// Mirror the announce locally so late joiners can replay it.
|
||||
this.known.set(metadata.id, metadata);
|
||||
this.broker.broadcast(this.room, this, metadata);
|
||||
}
|
||||
|
||||
public listPeers(): readonly CRDTPeerMetadata[] {
|
||||
return Array.from(this.known.values());
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.broker.leave(this.room, this);
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public notePeer(peerId: string): void {
|
||||
this.fire("peer", { peerId });
|
||||
}
|
||||
|
||||
public notePeerLost(peerId: string): void {
|
||||
this.known.delete(peerId);
|
||||
this.fire("peer-disconnected", { peerId });
|
||||
}
|
||||
|
||||
public receiveAnnounce(metadata: CRDTPeerMetadata): void {
|
||||
this.known.set(metadata.id, metadata);
|
||||
this.fire("peer", { peerId: metadata.id });
|
||||
}
|
||||
|
||||
private fire(event: string, ...args: unknown[]): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an `AutomergeRepoFactory` bound to a shared broker. */
|
||||
export function fakeAutomergeRepoFactory(
|
||||
broker: FakeAutomergeBroker
|
||||
): AutomergeRepoFactory {
|
||||
return (room) => {
|
||||
if (broker.broken) {
|
||||
throw new Error("broker down");
|
||||
}
|
||||
return new FakeAutomergeRepo(room, broker);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Deterministic in-memory Yjs substitute used by the test suites. Models a
|
||||
* single shared "room" broker — every `FakeYDoc` constructed against the same
|
||||
* `FakeYjsBroker` shares its `peers` map updates with every other doc. No
|
||||
* mocks: this is a real implementation of the `YDocLike` / `YMapLike` /
|
||||
* `WebrtcProviderLike` structural contracts.
|
||||
*/
|
||||
import type {
|
||||
CRDTPeerMetadata,
|
||||
WebrtcProviderLike,
|
||||
YDocLike,
|
||||
YjsDocFactory,
|
||||
YMapEventLike,
|
||||
YMapLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
let nextGuid = 0;
|
||||
|
||||
export class FakeYjsBroker {
|
||||
public readonly rooms = new Map<string, Set<FakeWebrtcProvider>>();
|
||||
public broken = false;
|
||||
|
||||
public join(room: string, provider: FakeWebrtcProvider): void {
|
||||
let set = this.rooms.get(room);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.rooms.set(room, set);
|
||||
}
|
||||
// Replay every existing peer's current map state to the new joiner
|
||||
// before announcing presence — mirrors `y-webrtc`'s "sync existing
|
||||
// state on join" semantics.
|
||||
for (const other of set) {
|
||||
const snap = other.snapshotPeers();
|
||||
for (const [key, value] of snap) {
|
||||
provider.applyRemote(key, value, "add");
|
||||
}
|
||||
}
|
||||
set.add(provider);
|
||||
// Sync immediately — everyone gets everyone else's current peers.
|
||||
queueMicrotask(() => {
|
||||
provider.fireSynced();
|
||||
for (const other of set as Set<FakeWebrtcProvider>) {
|
||||
if (other !== provider) {
|
||||
other.notePeerAdded(provider.doc.guid);
|
||||
provider.notePeerAdded(other.doc.guid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public leave(room: string, provider: FakeWebrtcProvider): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
set.delete(provider);
|
||||
for (const other of set) {
|
||||
other.notePeerRemoved(provider.doc.guid);
|
||||
}
|
||||
}
|
||||
|
||||
public broadcast(
|
||||
room: string,
|
||||
sender: FakeWebrtcProvider,
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
const set = this.rooms.get(room);
|
||||
if (set === undefined) return;
|
||||
for (const other of set) {
|
||||
if (other === sender) continue;
|
||||
other.applyRemote(key, value, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeYMap implements YMapLike {
|
||||
private readonly data = new Map<string, CRDTPeerMetadata>();
|
||||
private readonly observers = new Set<(e: YMapEventLike) => void>();
|
||||
|
||||
public constructor(
|
||||
private readonly emitRemote?: (
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void
|
||||
) {}
|
||||
|
||||
public set(key: string, value: CRDTPeerMetadata): void {
|
||||
const action: "add" | "update" = this.data.has(key) ? "update" : "add";
|
||||
this.data.set(key, value);
|
||||
this.fire(key, action);
|
||||
this.emitRemote?.(key, value, action);
|
||||
}
|
||||
|
||||
public get(key: string): CRDTPeerMetadata | undefined {
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
public delete(key: string): void {
|
||||
if (!this.data.delete(key)) return;
|
||||
this.fire(key, "delete");
|
||||
this.emitRemote?.(key, undefined, "delete");
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
public observe(handler: (e: YMapEventLike) => void): void {
|
||||
this.observers.add(handler);
|
||||
}
|
||||
|
||||
public unobserve(handler: (e: YMapEventLike) => void): void {
|
||||
this.observers.delete(handler);
|
||||
}
|
||||
|
||||
public toJSON(): Record<string, CRDTPeerMetadata> {
|
||||
return Object.fromEntries(this.data.entries());
|
||||
}
|
||||
|
||||
/** Apply a remote change without echoing it back to the network. */
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
if (action === "delete") {
|
||||
if (!this.data.delete(key)) return;
|
||||
} else {
|
||||
if (value === undefined) return;
|
||||
this.data.set(key, value);
|
||||
}
|
||||
this.fire(key, action);
|
||||
}
|
||||
|
||||
private fire(key: string, action: "add" | "update" | "delete"): void {
|
||||
const event: YMapEventLike = {
|
||||
changes: { keys: new Map([[key, { action }]]) },
|
||||
};
|
||||
for (const observer of this.observers) observer(event);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeYDoc implements YDocLike {
|
||||
public readonly guid: string;
|
||||
private map: FakeYMap | null = null;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private remoteEmitter:
|
||||
| ((
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
public constructor(guid?: string) {
|
||||
nextGuid += 1;
|
||||
this.guid = guid ?? `fake-doc-${nextGuid}`;
|
||||
}
|
||||
|
||||
public attachEmitter(
|
||||
emitter: (
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
) => void
|
||||
): void {
|
||||
this.remoteEmitter = emitter;
|
||||
}
|
||||
|
||||
public getMap(_name: string): YMapLike {
|
||||
if (this.map === null) {
|
||||
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
|
||||
}
|
||||
return this.map;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.fire("destroy");
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
/** Apply a remote map mutation (received via the broker). */
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
if (this.map === null) {
|
||||
// Lazily create the map so applyRemote works before observers attach.
|
||||
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
|
||||
}
|
||||
this.map.applyRemote(key, value, action);
|
||||
}
|
||||
|
||||
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
|
||||
if (this.map === null) return [];
|
||||
return Object.entries(this.map.toJSON());
|
||||
}
|
||||
|
||||
private fire(event: string): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h();
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeWebrtcProvider implements WebrtcProviderLike {
|
||||
public readonly doc: FakeYDoc;
|
||||
public readonly room: string;
|
||||
public connected = true;
|
||||
public synced = false;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
private destroyed = false;
|
||||
|
||||
public constructor(
|
||||
room: string,
|
||||
doc: FakeYDoc,
|
||||
private readonly broker: FakeYjsBroker
|
||||
) {
|
||||
this.doc = doc;
|
||||
this.room = room;
|
||||
doc.attachEmitter((k, v, a) => broker.broadcast(room, this, k, v, a));
|
||||
if (broker.broken) {
|
||||
// Schedule an immediate error after construction so callers can wire
|
||||
// handlers before the failure fires.
|
||||
queueMicrotask(() => {
|
||||
this.connected = false;
|
||||
this.fire("connection-error", new Error("broker down"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
broker.join(room, this);
|
||||
}
|
||||
|
||||
public on(event: string, handler: Handler): void {
|
||||
let set = this.handlers.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
public off(event: string, handler: Handler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.connected = false;
|
||||
this.broker.leave(this.room, this);
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
public fireSynced(): void {
|
||||
this.synced = true;
|
||||
this.fire("synced");
|
||||
}
|
||||
|
||||
public notePeerAdded(peerId: string): void {
|
||||
this.fire("peers", { added: [peerId], removed: [] });
|
||||
}
|
||||
|
||||
public notePeerRemoved(peerId: string): void {
|
||||
this.fire("peers", { added: [], removed: [peerId] });
|
||||
}
|
||||
|
||||
public applyRemote(
|
||||
key: string,
|
||||
value: CRDTPeerMetadata | undefined,
|
||||
action: "add" | "update" | "delete"
|
||||
): void {
|
||||
this.doc.applyRemote(key, value, action);
|
||||
}
|
||||
|
||||
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
|
||||
return this.doc.snapshotPeers();
|
||||
}
|
||||
|
||||
private fire(event: string, ...args: unknown[]): void {
|
||||
for (const h of this.handlers.get(event) ?? []) h(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `YjsDocFactory` bound to a shared broker. */
|
||||
export function fakeYjsDocFactory(broker: FakeYjsBroker): YjsDocFactory {
|
||||
return (room) => {
|
||||
const doc = new FakeYDoc();
|
||||
const provider = new FakeWebrtcProvider(room, doc, broker);
|
||||
return { doc, provider };
|
||||
};
|
||||
}
|
||||
|
||||
/** Yield control until pending microtasks have drained. */
|
||||
export async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user