Initial Commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-transport-libp2p dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.Libp2pPlugin).toBeDefined();
|
||||
expect(mod.Libp2pTransport).toBeDefined();
|
||||
expect(mod.NET_LIBP2P_CODES).toBeDefined();
|
||||
expect(mod.FrameDecoder).toBeDefined();
|
||||
expect(mod.encodeFrame).toBeDefined();
|
||||
expect(mod.encodePeerMessage).toBeDefined();
|
||||
expect(mod.decodePeerMessage).toBeDefined();
|
||||
expect(mod.sendEnvelope).toBeDefined();
|
||||
expect(mod.registerStreamHandler).toBeDefined();
|
||||
expect(mod.createAndStartNode).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Libp2pTransport } from "../src/transport/transport.js";
|
||||
import { FakeLibp2pHub, makeFakeFactory } from "./setup/fake-libp2p.js";
|
||||
|
||||
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 = 4): Promise<void> {
|
||||
for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe("2-peer integration over fake websockets sub-transport", () => {
|
||||
it("dial → envelope round-trip", { timeout: 8000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory: fA } = makeFakeFactory(hub, { idPrefix: "ws-a" });
|
||||
const { factory: fB } = makeFakeFactory(hub, { idPrefix: "ws-b" });
|
||||
const a = new Libp2pTransport({
|
||||
libp2pFactory: fA,
|
||||
bootstrap: ["/memory/seed"],
|
||||
subTransport: "websockets",
|
||||
});
|
||||
const b = new Libp2pTransport({
|
||||
libp2pFactory: fB,
|
||||
bootstrap: ["/memory/seed"],
|
||||
subTransport: "websockets",
|
||||
});
|
||||
|
||||
await a.start();
|
||||
await b.start();
|
||||
|
||||
const inboxB: Array<[PeerId, string]> = [];
|
||||
b.onMessage((peer, env) => inboxB.push([peer, String(env.id)]));
|
||||
|
||||
// a dials b
|
||||
await a.connect("ws-b-1", "/memory/ws-b-1");
|
||||
await tick(2);
|
||||
expect(a.getConnectedPeers()).toContain("ws-b-1");
|
||||
|
||||
// a sends to b
|
||||
await a.send("ws-b-1", envelope("from-a", "ws-a-1"));
|
||||
await tick(2);
|
||||
|
||||
expect(inboxB).toHaveLength(1);
|
||||
expect(inboxB[0]?.[1]).toBe("from-a");
|
||||
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("2-peer integration over fake webrtc sub-transport", () => {
|
||||
it("dial → envelope round-trip", { timeout: 8000 }, async () => {
|
||||
const hub = new FakeLibp2pHub();
|
||||
const { factory: fA } = makeFakeFactory(hub, { idPrefix: "wr-a" });
|
||||
const { factory: fB } = makeFakeFactory(hub, { idPrefix: "wr-b" });
|
||||
const a = new Libp2pTransport({
|
||||
libp2pFactory: fA,
|
||||
bootstrap: ["/memory/seed"],
|
||||
subTransport: "webrtc",
|
||||
});
|
||||
const b = new Libp2pTransport({
|
||||
libp2pFactory: fB,
|
||||
bootstrap: ["/memory/seed"],
|
||||
subTransport: "webrtc",
|
||||
});
|
||||
|
||||
await a.start();
|
||||
await b.start();
|
||||
|
||||
const inboxB: Array<[PeerId, string]> = [];
|
||||
b.onMessage((peer, env) => inboxB.push([peer, String(env.id)]));
|
||||
|
||||
await a.connect("wr-b-1", "/memory/wr-b-1");
|
||||
await tick(2);
|
||||
await a.send("wr-b-1", envelope("rtc-msg", "wr-a-1"));
|
||||
await tick(2);
|
||||
|
||||
expect(inboxB).toHaveLength(1);
|
||||
expect(inboxB[0]?.[1]).toBe("rtc-msg");
|
||||
|
||||
await a.close();
|
||||
await b.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import type {
|
||||
Libp2pFactory,
|
||||
Libp2pFactoryConfig,
|
||||
Libp2pNodeLike,
|
||||
Libp2pPeerIdLike,
|
||||
Libp2pStreamHandler,
|
||||
Libp2pStreamLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
let nextNodeNum = 1;
|
||||
|
||||
/** Deterministic peer-id object. */
|
||||
export class FakePeerId implements Libp2pPeerIdLike {
|
||||
public constructor(private readonly id: string) {}
|
||||
public toString(): string {
|
||||
return this.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real in-memory `Libp2pStreamLike` pair. Two `FakeStream`s share an
|
||||
* outbound queue + inbound queue so `sink` on one side becomes async-
|
||||
* iterable `source` chunks on the other.
|
||||
*/
|
||||
export class FakeStream implements Libp2pStreamLike {
|
||||
#inbound: Uint8Array[] = [];
|
||||
#waiters: Array<(v: IteratorResult<{ subarray(): Uint8Array }>) => void> =
|
||||
[];
|
||||
#closed = false;
|
||||
#sourceEnded = false;
|
||||
#peer: FakeStream | undefined;
|
||||
|
||||
public pair(other: FakeStream): void {
|
||||
this.#peer = other;
|
||||
}
|
||||
|
||||
public get source(): AsyncIterable<{ subarray(): Uint8Array }> {
|
||||
const self = this;
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next(): Promise<
|
||||
IteratorResult<{ subarray(): Uint8Array }>
|
||||
> {
|
||||
if (self.#inbound.length > 0) {
|
||||
const bytes = self.#inbound.shift();
|
||||
if (!bytes) {
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
return {
|
||||
value: { subarray: () => bytes },
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
if (self.#sourceEnded || self.#closed) {
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
self.#waiters.push(resolve);
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async sink(
|
||||
data: Iterable<Uint8Array> | AsyncIterable<Uint8Array>
|
||||
): Promise<void> {
|
||||
const peer = this.#peer;
|
||||
if (!peer) {
|
||||
throw new Error("fake-stream.unpaired");
|
||||
}
|
||||
for await (const chunk of data) {
|
||||
peer.#deliver(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#sourceEnded = true;
|
||||
for (const w of this.#waiters) {
|
||||
w({ value: undefined, done: true });
|
||||
}
|
||||
this.#waiters = [];
|
||||
// Signal end-of-stream to the peer's source iterator as well so
|
||||
// `consumeInboundStream` exits its `for await` when the writer
|
||||
// half closes its end of the duplex pair.
|
||||
this.#peer?.signalSourceEnd();
|
||||
}
|
||||
|
||||
/** Internal: peer-half close signals end-of-stream on our source. */
|
||||
public signalSourceEnd(): void {
|
||||
if (this.#sourceEnded) return;
|
||||
this.#sourceEnded = true;
|
||||
for (const w of this.#waiters) {
|
||||
w({ value: undefined, done: true });
|
||||
}
|
||||
this.#waiters = [];
|
||||
}
|
||||
|
||||
#deliver(bytes: Uint8Array): void {
|
||||
if (this.#waiters.length > 0) {
|
||||
const w = this.#waiters.shift();
|
||||
w?.({ value: { subarray: () => bytes }, done: false });
|
||||
return;
|
||||
}
|
||||
this.#inbound.push(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeListeners {
|
||||
handlers: Map<string, Libp2pStreamHandler>;
|
||||
connectListeners: Set<(event: { detail: unknown }) => void>;
|
||||
disconnectListeners: Set<(event: { detail: unknown }) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub coordinating multiple `FakeLibp2pNode` instances. Nodes register
|
||||
* by id; dialing a peer wires their `peer:connect` event listeners and
|
||||
* unlocks `dialProtocol` between them.
|
||||
*/
|
||||
export class FakeLibp2pHub {
|
||||
readonly #nodes = new Map<string, FakeLibp2pNode>();
|
||||
|
||||
public register(node: FakeLibp2pNode): void {
|
||||
this.#nodes.set(node.id, node);
|
||||
}
|
||||
|
||||
public unregister(node: FakeLibp2pNode): void {
|
||||
this.#nodes.delete(node.id);
|
||||
for (const other of this.#nodes.values()) {
|
||||
other.dropPeer(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
public get(id: string): FakeLibp2pNode | undefined {
|
||||
return this.#nodes.get(id);
|
||||
}
|
||||
|
||||
public connectPair(a: FakeLibp2pNode, b: FakeLibp2pNode): void {
|
||||
a.markConnected(b.id);
|
||||
b.markConnected(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real in-memory implementation of `Libp2pNodeLike`. Not a mock — every
|
||||
* method does real work over `FakeStream` pairs and `FakeLibp2pHub`.
|
||||
*/
|
||||
export class FakeLibp2pNode implements Libp2pNodeLike {
|
||||
public readonly id: string;
|
||||
readonly #hub: FakeLibp2pHub;
|
||||
readonly #peers = new Set<string>();
|
||||
readonly #listeners: NodeListeners = {
|
||||
handlers: new Map(),
|
||||
connectListeners: new Set(),
|
||||
disconnectListeners: new Set(),
|
||||
};
|
||||
#started = false;
|
||||
#stopped = false;
|
||||
public startThrows = false;
|
||||
public dialThrows = false;
|
||||
public dialProtocolThrows = false;
|
||||
|
||||
public constructor(hub: FakeLibp2pHub, id?: string) {
|
||||
this.id = id ?? `peer-${nextNodeNum++}`;
|
||||
this.#hub = hub;
|
||||
hub.register(this);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.startThrows) {
|
||||
throw new Error("fake-libp2p.start-failed");
|
||||
}
|
||||
this.#started = true;
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.#stopped) return;
|
||||
this.#stopped = true;
|
||||
this.#started = false;
|
||||
for (const peerId of Array.from(this.#peers)) {
|
||||
this.dropPeer(peerId);
|
||||
}
|
||||
this.#hub.unregister(this);
|
||||
}
|
||||
|
||||
public async dial(addr: string): Promise<unknown> {
|
||||
if (this.dialThrows) {
|
||||
throw new Error("fake-libp2p.dial-failed");
|
||||
}
|
||||
// addr format: "/memory/<peer-id>"
|
||||
const targetId = addr.replace(/^.*\//, "");
|
||||
const target = this.#hub.get(targetId);
|
||||
if (!target) {
|
||||
throw new Error(`fake-libp2p.unknown-peer:${targetId}`);
|
||||
}
|
||||
this.#hub.connectPair(this, target);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async dialProtocol(
|
||||
peerId: Libp2pPeerIdLike,
|
||||
protocolId: string
|
||||
): Promise<Libp2pStreamLike> {
|
||||
if (this.dialProtocolThrows) {
|
||||
throw new Error("fake-libp2p.dial-protocol-failed");
|
||||
}
|
||||
const target = this.#hub.get(peerId.toString());
|
||||
if (!target) {
|
||||
throw new Error(`fake-libp2p.unknown-peer:${peerId.toString()}`);
|
||||
}
|
||||
const handler = target.#listeners.handlers.get(protocolId);
|
||||
if (!handler) {
|
||||
throw new Error(`fake-libp2p.protocol-not-supported:${protocolId}`);
|
||||
}
|
||||
const a = new FakeStream();
|
||||
const b = new FakeStream();
|
||||
a.pair(b);
|
||||
b.pair(a);
|
||||
handler({
|
||||
stream: b,
|
||||
connection: { remotePeer: new FakePeerId(this.id) },
|
||||
});
|
||||
return a;
|
||||
}
|
||||
|
||||
public async handle(
|
||||
protocolId: string,
|
||||
handler: Libp2pStreamHandler
|
||||
): Promise<void> {
|
||||
this.#listeners.handlers.set(protocolId, handler);
|
||||
}
|
||||
|
||||
public async unhandle(protocolId: string): Promise<void> {
|
||||
this.#listeners.handlers.delete(protocolId);
|
||||
}
|
||||
|
||||
public getPeers(): Libp2pPeerIdLike[] {
|
||||
return Array.from(this.#peers).map((id) => new FakePeerId(id));
|
||||
}
|
||||
|
||||
public addEventListener(
|
||||
type: string,
|
||||
listener: (event: { detail: unknown }) => void
|
||||
): void {
|
||||
if (type === "peer:connect") {
|
||||
this.#listeners.connectListeners.add(listener);
|
||||
} else if (type === "peer:disconnect") {
|
||||
this.#listeners.disconnectListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public removeEventListener(
|
||||
type: string,
|
||||
listener: (event: { detail: unknown }) => void
|
||||
): void {
|
||||
if (type === "peer:connect") {
|
||||
this.#listeners.connectListeners.delete(listener);
|
||||
} else if (type === "peer:disconnect") {
|
||||
this.#listeners.disconnectListeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: hub uses this to fire peer:connect for both ends. */
|
||||
public markConnected(peerId: string): void {
|
||||
if (this.#peers.has(peerId)) return;
|
||||
this.#peers.add(peerId);
|
||||
for (const listener of this.#listeners.connectListeners) {
|
||||
listener({ detail: new FakePeerId(peerId) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: hub uses this to fire peer:disconnect from either end. */
|
||||
public dropPeer(peerId: string): void {
|
||||
if (!this.#peers.has(peerId)) return;
|
||||
this.#peers.delete(peerId);
|
||||
for (const listener of this.#listeners.disconnectListeners) {
|
||||
listener({ detail: new FakePeerId(peerId) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: force-fire a peer:connect event bypassing dedup. */
|
||||
public forceEmitConnect(peerId: string): void {
|
||||
for (const listener of this.#listeners.connectListeners) {
|
||||
listener({ detail: new FakePeerId(peerId) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: force-fire a peer:disconnect event bypassing dedup. */
|
||||
public forceEmitDisconnect(peerId: string): void {
|
||||
for (const listener of this.#listeners.disconnectListeners) {
|
||||
listener({ detail: new FakePeerId(peerId) });
|
||||
}
|
||||
}
|
||||
|
||||
public get started(): boolean {
|
||||
return this.#started;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `Libp2pFactory` bound to a hub. */
|
||||
export function makeFakeFactory(
|
||||
hub: FakeLibp2pHub,
|
||||
opts?: { idPrefix?: string; nodeOptions?: Partial<FakeLibp2pNode> }
|
||||
): { factory: Libp2pFactory; instances: FakeLibp2pNode[] } {
|
||||
const instances: FakeLibp2pNode[] = [];
|
||||
let counter = 0;
|
||||
const factory: Libp2pFactory = async (_config: Libp2pFactoryConfig) => {
|
||||
const id = opts?.idPrefix ? `${opts.idPrefix}-${++counter}` : undefined;
|
||||
const node = new FakeLibp2pNode(hub, id);
|
||||
if (opts?.nodeOptions?.startThrows) {
|
||||
node.startThrows = opts.nodeOptions.startThrows;
|
||||
}
|
||||
instances.push(node);
|
||||
return node;
|
||||
};
|
||||
return { factory, instances };
|
||||
}
|
||||
@@ -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