Initial Commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-transport-hyperswarm dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.HyperswarmPlugin).toBeDefined();
|
||||
expect(mod.HyperswarmTransport).toBeDefined();
|
||||
expect(mod.NET_HYPERSWARM_CODES).toBeDefined();
|
||||
expect(mod.FrameDecoder).toBeDefined();
|
||||
expect(mod.ReputationStore).toBeDefined();
|
||||
expect(mod.ConnectionRegistry).toBeDefined();
|
||||
expect(mod.validatePeer).toBeDefined();
|
||||
expect(mod.joinTopic).toBeDefined();
|
||||
expect(mod.prepareTopic).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HyperswarmTransport } from "../src/transport/transport.js";
|
||||
import { FakeSwarmHub, makeFakeFactory } from "./setup/fake-hyperswarm.js";
|
||||
|
||||
const TOPIC = new Uint8Array(32).fill(0x77);
|
||||
|
||||
function envelope(id: string, sender: string): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
type: "test:msg",
|
||||
payload: { i: id },
|
||||
senderId: sender,
|
||||
timestamp: 1,
|
||||
headers: { version: "1.0.0" },
|
||||
ttl: 8,
|
||||
visitedNodes: [],
|
||||
} as unknown as MessageEnvelope;
|
||||
}
|
||||
|
||||
async function tick(n = 2): Promise<void> {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("3-peer in-process integration", () => {
|
||||
it("all-to-all envelope exchange via shared DHT topic", {
|
||||
timeout: 8000,
|
||||
}, async () => {
|
||||
const hub = new FakeSwarmHub();
|
||||
const { factory: fA } = makeFakeFactory(hub);
|
||||
const { factory: fB } = makeFakeFactory(hub);
|
||||
const { factory: fC } = makeFakeFactory(hub);
|
||||
const a = new HyperswarmTransport({
|
||||
topic: TOPIC,
|
||||
hyperswarmFactory: fA,
|
||||
});
|
||||
const b = new HyperswarmTransport({
|
||||
topic: TOPIC,
|
||||
hyperswarmFactory: fB,
|
||||
});
|
||||
const c = new HyperswarmTransport({
|
||||
topic: TOPIC,
|
||||
hyperswarmFactory: fC,
|
||||
});
|
||||
await a.start();
|
||||
await b.start();
|
||||
await c.start();
|
||||
await tick(4);
|
||||
|
||||
// Each node sees the other two
|
||||
expect(a.getConnectedPeers().length).toBe(2);
|
||||
expect(b.getConnectedPeers().length).toBe(2);
|
||||
expect(c.getConnectedPeers().length).toBe(2);
|
||||
|
||||
const inboxA: Array<[PeerId, string]> = [];
|
||||
const inboxB: Array<[PeerId, string]> = [];
|
||||
const inboxC: Array<[PeerId, string]> = [];
|
||||
a.onMessage((pid, env) => inboxA.push([pid, String(env.id)]));
|
||||
b.onMessage((pid, env) => inboxB.push([pid, String(env.id)]));
|
||||
c.onMessage((pid, env) => inboxC.push([pid, String(env.id)]));
|
||||
|
||||
// a -> all peers
|
||||
for (const peerId of a.getConnectedPeers()) {
|
||||
await a.send(peerId, envelope("from-a", "a"));
|
||||
}
|
||||
// b -> all peers
|
||||
for (const peerId of b.getConnectedPeers()) {
|
||||
await b.send(peerId, envelope("from-b", "b"));
|
||||
}
|
||||
// c -> all peers
|
||||
for (const peerId of c.getConnectedPeers()) {
|
||||
await c.send(peerId, envelope("from-c", "c"));
|
||||
}
|
||||
await tick(4);
|
||||
|
||||
// Each inbox should contain two messages — one from each remote.
|
||||
expect(inboxA.length).toBe(2);
|
||||
expect(inboxB.length).toBe(2);
|
||||
expect(inboxC.length).toBe(2);
|
||||
|
||||
const messagesA = inboxA.map(([, id]) => id).sort();
|
||||
expect(messagesA).toEqual(["from-b", "from-c"]);
|
||||
const messagesB = inboxB.map(([, id]) => id).sort();
|
||||
expect(messagesB).toEqual(["from-a", "from-c"]);
|
||||
const messagesC = inboxC.map(([, id]) => id).sort();
|
||||
expect(messagesC).toEqual(["from-a", "from-b"]);
|
||||
|
||||
await a.close();
|
||||
await b.close();
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("peer disconnect propagates to remote node", {
|
||||
timeout: 8000,
|
||||
}, async () => {
|
||||
const hub = new FakeSwarmHub();
|
||||
const { factory: fA } = makeFakeFactory(hub);
|
||||
const { factory: fB } = makeFakeFactory(hub);
|
||||
const a = new HyperswarmTransport({
|
||||
topic: TOPIC,
|
||||
hyperswarmFactory: fA,
|
||||
});
|
||||
const b = new HyperswarmTransport({
|
||||
topic: TOPIC,
|
||||
hyperswarmFactory: fB,
|
||||
});
|
||||
await a.start();
|
||||
await b.start();
|
||||
await tick(2);
|
||||
|
||||
const events: string[] = [];
|
||||
b.onPeerEvent((_id, e) => events.push(e));
|
||||
const peerOnA = a.getConnectedPeers()[0];
|
||||
if (!peerOnA) throw new Error("no peer");
|
||||
await a.disconnect(peerOnA);
|
||||
await tick(2);
|
||||
|
||||
expect(events).toContain("disconnected");
|
||||
expect(b.getConnectedPeers()).toHaveLength(0);
|
||||
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type {
|
||||
HyperswarmDiscoveryLike,
|
||||
HyperswarmFactory,
|
||||
HyperswarmInstanceLike,
|
||||
HyperswarmPeerInfo,
|
||||
HyperswarmSocketLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* Deterministic real implementation of `HyperswarmSocketLike` for tests.
|
||||
* NOT a mock — every method does real work over an in-memory pair so
|
||||
* the test exercises the same code paths as production.
|
||||
*/
|
||||
export class FakeSocket implements HyperswarmSocketLike {
|
||||
public destroyed = false;
|
||||
#peer: FakeSocket | undefined;
|
||||
#dataHandlers = new Set<(data: Uint8Array) => void>();
|
||||
#errorHandlers = new Set<(err: Error) => void>();
|
||||
#closeHandlers = new Set<() => void>();
|
||||
#timeoutHandlers = new Set<() => void>();
|
||||
#timeoutMs = 0;
|
||||
|
||||
public pair(other: FakeSocket): void {
|
||||
this.#peer = other;
|
||||
}
|
||||
|
||||
public write(data: Uint8Array, callback?: (err?: Error) => void): boolean {
|
||||
if (this.destroyed) {
|
||||
callback?.(new Error("fake-socket.destroyed"));
|
||||
return false;
|
||||
}
|
||||
const peer = this.#peer;
|
||||
if (!peer) {
|
||||
callback?.(new Error("fake-socket.unpaired"));
|
||||
return false;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (peer.destroyed) {
|
||||
callback?.(new Error("fake-socket.peer-destroyed"));
|
||||
return;
|
||||
}
|
||||
for (const handler of peer.#dataHandlers) {
|
||||
handler(data);
|
||||
}
|
||||
callback?.();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public on(event: string, listener: (...args: never[]) => void): this {
|
||||
if (event === "data") {
|
||||
this.#dataHandlers.add(listener as (data: Uint8Array) => void);
|
||||
} else if (event === "error") {
|
||||
this.#errorHandlers.add(listener as (err: Error) => void);
|
||||
} else if (event === "close") {
|
||||
this.#closeHandlers.add(listener as () => void);
|
||||
} else if (event === "timeout") {
|
||||
this.#timeoutHandlers.add(listener as () => void);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public setTimeout(ms: number): void {
|
||||
this.#timeoutMs = ms;
|
||||
}
|
||||
|
||||
public get timeoutMs(): number {
|
||||
return this.#timeoutMs;
|
||||
}
|
||||
|
||||
public destroy(_error?: Error): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.destroyed = true;
|
||||
for (const handler of this.#closeHandlers) {
|
||||
handler();
|
||||
}
|
||||
if (this.#peer && !this.#peer.destroyed) {
|
||||
this.#peer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate a remote error event for tests. */
|
||||
public emitError(err: Error): void {
|
||||
for (const handler of this.#errorHandlers) {
|
||||
handler(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate an idle-timeout for tests. */
|
||||
public emitTimeout(): void {
|
||||
for (const handler of this.#timeoutHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate a remote close without destroying for tests. */
|
||||
public emitClose(): void {
|
||||
for (const handler of this.#closeHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process swarm registry shared by every `FakeHyperswarm` instance
|
||||
* created within a single test. Models the DHT as a multi-map from
|
||||
* topic-hex to swarm-set; whenever two swarms join the same topic, a
|
||||
* `FakeSocket` pair is materialised and delivered to both.
|
||||
*/
|
||||
export class FakeSwarmHub {
|
||||
readonly #byTopic = new Map<string, Set<FakeHyperswarm>>();
|
||||
|
||||
public join(swarm: FakeHyperswarm, topic: Uint8Array): void {
|
||||
const hex = bufHex(topic);
|
||||
let set = this.#byTopic.get(hex);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.#byTopic.set(hex, set);
|
||||
}
|
||||
for (const other of set) {
|
||||
if (other === swarm) {
|
||||
continue;
|
||||
}
|
||||
this.#wirePair(swarm, other);
|
||||
}
|
||||
set.add(swarm);
|
||||
}
|
||||
|
||||
public leave(swarm: FakeHyperswarm, topic: Uint8Array): void {
|
||||
const hex = bufHex(topic);
|
||||
const set = this.#byTopic.get(hex);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
set.delete(swarm);
|
||||
if (set.size === 0) {
|
||||
this.#byTopic.delete(hex);
|
||||
}
|
||||
}
|
||||
|
||||
#wirePair(a: FakeHyperswarm, b: FakeHyperswarm): void {
|
||||
const sockA = new FakeSocket();
|
||||
const sockB = new FakeSocket();
|
||||
sockA.pair(sockB);
|
||||
sockB.pair(sockA);
|
||||
// Emit on next microtask so listeners attached after join still see it.
|
||||
queueMicrotask(() => {
|
||||
a.deliverInbound(sockA, { publicKey: b.publicKey });
|
||||
b.deliverInbound(sockB, { publicKey: a.publicKey });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic real implementation of `HyperswarmInstanceLike`.
|
||||
* NOT a mock — backed by `FakeSwarmHub` so writes traverse a real
|
||||
* in-memory duplex pair.
|
||||
*/
|
||||
export class FakeHyperswarm implements HyperswarmInstanceLike {
|
||||
public readonly publicKey: Uint8Array;
|
||||
public connecting = 0;
|
||||
public readonly connections = new Set<HyperswarmSocketLike>();
|
||||
#connectionListeners = new Set<
|
||||
(socket: HyperswarmSocketLike, info: HyperswarmPeerInfo) => void
|
||||
>();
|
||||
#hub: FakeSwarmHub;
|
||||
#joinedTopics = new Set<string>();
|
||||
#destroyed = false;
|
||||
#flushSucceeds: boolean;
|
||||
#joinThrows: boolean;
|
||||
|
||||
public constructor(
|
||||
hub: FakeSwarmHub,
|
||||
opts: {
|
||||
publicKey?: Uint8Array;
|
||||
flushSucceeds?: boolean;
|
||||
joinThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
this.publicKey = opts.publicKey ?? randomBytes(32);
|
||||
this.#hub = hub;
|
||||
this.#flushSucceeds = opts.flushSucceeds ?? true;
|
||||
this.#joinThrows = opts.joinThrows ?? false;
|
||||
}
|
||||
|
||||
public join(
|
||||
topic: Uint8Array,
|
||||
_opts?: { server?: boolean; client?: boolean }
|
||||
): HyperswarmDiscoveryLike {
|
||||
if (this.#joinThrows) {
|
||||
throw new Error("fake-hyperswarm.join-threw");
|
||||
}
|
||||
const hex = bufHex(topic);
|
||||
this.#joinedTopics.add(hex);
|
||||
this.#hub.join(this, topic);
|
||||
const flushSucceeds = this.#flushSucceeds;
|
||||
return {
|
||||
flushed: async () => {
|
||||
if (!flushSucceeds) {
|
||||
throw new Error("fake-hyperswarm.flush-failed");
|
||||
}
|
||||
},
|
||||
destroy: async () => {
|
||||
this.#hub.leave(this, topic);
|
||||
this.#joinedTopics.delete(hex);
|
||||
},
|
||||
refresh: async () => {
|
||||
/* no-op in fake */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
if (this.#destroyed) {
|
||||
return;
|
||||
}
|
||||
this.#destroyed = true;
|
||||
for (const sock of Array.from(this.connections)) {
|
||||
sock.destroy();
|
||||
}
|
||||
this.connections.clear();
|
||||
this.#connectionListeners.clear();
|
||||
this.#joinedTopics.clear();
|
||||
}
|
||||
|
||||
public on(event: string, listener: (...args: never[]) => void): this {
|
||||
if (event === "connection") {
|
||||
this.#connectionListeners.add(
|
||||
listener as (
|
||||
socket: HyperswarmSocketLike,
|
||||
info: HyperswarmPeerInfo
|
||||
) => void
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public off(event: string, listener: (...args: never[]) => void): this {
|
||||
if (event === "connection") {
|
||||
this.#connectionListeners.delete(
|
||||
listener as (
|
||||
socket: HyperswarmSocketLike,
|
||||
info: HyperswarmPeerInfo
|
||||
) => void
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Internal hook used by the hub to fire `connection`. */
|
||||
public deliverInbound(
|
||||
socket: HyperswarmSocketLike,
|
||||
peerInfo: HyperswarmPeerInfo
|
||||
): void {
|
||||
if (this.#destroyed) {
|
||||
return;
|
||||
}
|
||||
this.connections.add(socket);
|
||||
for (const handler of this.#connectionListeners) {
|
||||
handler(socket, peerInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an `HyperswarmFactory` bound to a `FakeSwarmHub`. Successive
|
||||
* `factory()` calls return fresh swarms, all sharing the hub.
|
||||
*/
|
||||
export function makeFakeFactory(
|
||||
hub: FakeSwarmHub,
|
||||
opts?: {
|
||||
publicKey?: Uint8Array;
|
||||
flushSucceeds?: boolean;
|
||||
joinThrows?: boolean;
|
||||
}
|
||||
): {
|
||||
factory: HyperswarmFactory;
|
||||
instances: FakeHyperswarm[];
|
||||
} {
|
||||
const instances: FakeHyperswarm[] = [];
|
||||
const factory: HyperswarmFactory = () => {
|
||||
const inst = new FakeHyperswarm(hub, opts);
|
||||
instances.push(inst);
|
||||
return inst;
|
||||
};
|
||||
return { factory, instances };
|
||||
}
|
||||
|
||||
function bufHex(buf: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
out += (buf[i] ?? 0).toString(16).padStart(2, "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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