Initial Commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-trystero-server dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.TrysteroServer).toBeDefined();
|
||||
expect(mod.NET_TRYSTERO_SERVER_CODES).toBeDefined();
|
||||
expect(mod.NetTrysteroServerError).toBeDefined();
|
||||
expect(mod.TrysteroServerConfigSchema).toBeDefined();
|
||||
expect(mod.TrysteroServerMqttOptionsSchema).toBeDefined();
|
||||
expect(mod.TrysteroServerNostrOptionsSchema).toBeDefined();
|
||||
expect(mod.selectBrokerFactory).toBeDefined();
|
||||
expect(mod.buildPeerCallbacks).toBeDefined();
|
||||
expect(mod.handleHealth).toBeDefined();
|
||||
expect(mod.classifyHealthRequest).toBeDefined();
|
||||
expect(mod.buildHealthResponse).toBeDefined();
|
||||
expect(mod.HEALTH_PATHS).toBeDefined();
|
||||
expect(mod.recordConnection).toBeDefined();
|
||||
expect(mod.recordMessageRelayed).toBeDefined();
|
||||
expect(mod.adjustActiveConnections).toBeDefined();
|
||||
expect(mod.recordStartDuration).toBeDefined();
|
||||
expect(mod.withStartSpan).toBeDefined();
|
||||
expect(mod.withStopSpan).toBeDefined();
|
||||
expect(mod.listenHttp).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TrysteroServer } from "../src/server/server.js";
|
||||
import { makeFakeBrokerFactory } from "./setup/fake-trystero-server.js";
|
||||
|
||||
async function fetchJson(
|
||||
url: string
|
||||
): Promise<{ readonly status: number; readonly body: unknown }> {
|
||||
const res = await fetch(url);
|
||||
const body = await res.json();
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
function baseUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
describe("integration: real HTTP boot + health probes (mqtt)", () => {
|
||||
it("health/ready/live respond with 200 while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
const { address, port, mode, relayUrl } = await server.start();
|
||||
expect(mode).toBe("mqtt");
|
||||
expect(relayUrl.endsWith("/mqtt")).toBe(true);
|
||||
const base = baseUrl(address, port);
|
||||
try {
|
||||
const health = await fetchJson(`${base}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
expect((health.body as { status: string }).status).toBe("ok");
|
||||
expect((health.body as { mode: string }).mode).toBe("mqtt");
|
||||
|
||||
const ready = await fetchJson(`${base}/ready`);
|
||||
expect(ready.status).toBe(200);
|
||||
|
||||
const live = await fetchJson(`${base}/live`);
|
||||
expect(live.status).toBe(200);
|
||||
|
||||
// Non-health route falls through to the relay marker.
|
||||
const fall = await fetch(`${base}/mqtt`);
|
||||
expect(fall.status).toBe(200);
|
||||
const txt = await fall.text();
|
||||
expect(txt).toContain("mqtt");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("two clients connect via fake broker; daemon tracks both", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.connect("client-2");
|
||||
broker.frame("client-1");
|
||||
broker.frame("client-2");
|
||||
expect(server.activeConnections).toBe(2);
|
||||
const ids = server
|
||||
.peers()
|
||||
.map((p) => p.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(["client-1", "client-2"]);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed frame from a misbehaving client is dropped", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.malformed("client-1");
|
||||
// Still tracked, no extra peers.
|
||||
expect(server.activeConnections).toBe(1);
|
||||
expect(server.peers()).toHaveLength(1);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("graceful stop closes the HTTP listener", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("mqtt");
|
||||
const server = new TrysteroServer({
|
||||
mode: "mqtt",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
mqttBrokerFactory: factory,
|
||||
});
|
||||
const { address, port } = await server.start();
|
||||
const base = baseUrl(address, port);
|
||||
await server.stop();
|
||||
await expect(fetch(`${base}/health`)).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: real HTTP boot + health probes (nostr)", () => {
|
||||
it("health/ready/live respond with 200 while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeBrokerFactory("nostr");
|
||||
const server = new TrysteroServer({
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
nostrRelayFactory: factory,
|
||||
nostr: { relayName: "edge-it" },
|
||||
});
|
||||
const { address, port, mode, relayUrl } = await server.start();
|
||||
expect(mode).toBe("nostr");
|
||||
expect(relayUrl.endsWith("/nostr")).toBe(true);
|
||||
const base = baseUrl(address, port);
|
||||
try {
|
||||
const health = await fetchJson(`${base}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
expect((health.body as { mode: string }).mode).toBe("nostr");
|
||||
|
||||
const fall = await fetch(`${base}/nostr`);
|
||||
expect(fall.status).toBe(200);
|
||||
const txt = await fall.text();
|
||||
expect(txt).toContain("nostr");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("two clients relay a frame between modes (nostr)", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeBrokerFactory("nostr");
|
||||
const server = new TrysteroServer({
|
||||
mode: "nostr",
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
nostrRelayFactory: factory,
|
||||
});
|
||||
await server.start();
|
||||
try {
|
||||
const broker = instances[0];
|
||||
if (!broker) throw new Error("expected broker");
|
||||
broker.connect("client-1");
|
||||
broker.connect("client-2");
|
||||
broker.frame("client-1");
|
||||
expect(server.activeConnections).toBe(2);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
BrokerCallbacks,
|
||||
BrokerFactory,
|
||||
BrokerLike,
|
||||
BrokerSocketLike,
|
||||
BrokerStartOptions,
|
||||
TrysteroServerMode,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* In-memory deterministic implementation of the `BrokerLike` surface
|
||||
* used by the daemon. The real broker shims (which would `upgrade`
|
||||
* the daemon's `http.Server` and pump WebSocket frames between peers)
|
||||
* are intentionally not pulled into the test environment — they
|
||||
* require browser-WS clients and binding to an OS socket. Every test
|
||||
* wires this fake via the injected factory and drives lifecycle
|
||||
* events directly through the captured callbacks.
|
||||
*/
|
||||
export class FakeBroker implements BrokerLike {
|
||||
public readonly mode: TrysteroServerMode;
|
||||
public activeConnections = 0;
|
||||
public startCalls = 0;
|
||||
public stopCalls = 0;
|
||||
public startError: Error | undefined;
|
||||
public stopError: Error | undefined;
|
||||
public lastStartOptions: BrokerStartOptions | undefined;
|
||||
public callbacks: BrokerCallbacks | undefined;
|
||||
private readonly sockets = new Map<string, FakeSocket>();
|
||||
|
||||
public constructor(mode: TrysteroServerMode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public async start(options: BrokerStartOptions): Promise<void> {
|
||||
this.startCalls += 1;
|
||||
this.lastStartOptions = options;
|
||||
this.callbacks = options.callbacks;
|
||||
if (this.startError !== undefined) {
|
||||
throw this.startError;
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.stopCalls += 1;
|
||||
for (const id of [...this.sockets.keys()]) {
|
||||
this.disconnect(id);
|
||||
}
|
||||
if (this.stopError !== undefined) {
|
||||
throw this.stopError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a new client connecting. */
|
||||
public connect(id: string): FakeSocket {
|
||||
const socket = new FakeSocket(id, () => this.disconnect(id));
|
||||
this.sockets.set(id, socket);
|
||||
this.activeConnections += 1;
|
||||
this.callbacks?.onConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a client disconnecting cleanly. */
|
||||
public disconnect(id: string): void {
|
||||
const socket = this.sockets.get(id);
|
||||
if (socket === undefined) return;
|
||||
this.sockets.delete(id);
|
||||
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
||||
this.callbacks?.onDisconnect(id);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a valid frame from a client. */
|
||||
public frame(id: string): void {
|
||||
this.callbacks?.onFrame(id);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a malformed frame from a client. */
|
||||
public malformed(id: string): void {
|
||||
this.callbacks?.onMalformedFrame(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trivial `BrokerSocketLike` implementation backed by a string array
|
||||
* tests can introspect.
|
||||
*/
|
||||
export class FakeSocket implements BrokerSocketLike {
|
||||
public readonly id: string;
|
||||
public readonly sent: string[] = [];
|
||||
public closed = false;
|
||||
private readonly onClose: () => void;
|
||||
|
||||
public constructor(id: string, onClose: () => void) {
|
||||
this.id = id;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
public send(payload: string): void {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory helper producing `FakeBroker` instances and recording
|
||||
* every call so tests can introspect or drive the captured
|
||||
* reference.
|
||||
*/
|
||||
export function makeFakeBrokerFactory(mode: TrysteroServerMode): {
|
||||
readonly instances: FakeBroker[];
|
||||
readonly factory: BrokerFactory;
|
||||
} {
|
||||
const instances: FakeBroker[] = [];
|
||||
return {
|
||||
instances,
|
||||
factory: (deps) => {
|
||||
const inst = new FakeBroker(deps.mode);
|
||||
instances.push(inst);
|
||||
void mode;
|
||||
return inst;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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