import {request as httpRequest} from "node:http" import WebSocket from "ws" import {parseJson} from "@welshman/lib" import type {ClientMessage, RelayMessage} from "@welshman/net" import type {RelayConnection} from "./types" /** * How the harness reaches the zooid container, and the only place that knows the container has a * loopback address at all. * * The client is given `wss://.test/` and never learns anything else. It has to be that * rather than the container's own `ws://localhost:3334/`, because a relay selection drops a url * that is local or insecure unless the caller opts in — see isLocalUrl and RelaySelection.getUrls * in @welshman/util — which Flotilla never does, so the Router would resolve every outbox load, * profile and relay hint to nothing. * * The two headers below are what makes the container answer to that name. zooid's dispatcher binds * a config to a Host (cmd/relay/main.go), and khatru derives the url it checks nip-42 and nip-86 * signatures against from that Host plus the forwarded proto (getBaseURL in khatru/relay.go), so * both the relay the config describes and the url it expects to have been signed are the tenant's. * They are exactly what a tls terminator adds in front of a real deployment. */ const port = 3334 const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) export type ZooidConnection = RelayConnection & { // Resolves with the first message the relay sends that matches. Seeding waits for its auth // challenge and for the OK answering each write this way. wait(match: (message: RelayMessage) => boolean): Promise } // One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry // a Host header — fetch forbids it — so this is the one thing in the harness that `ws` is here for. export const connectToZooid = (host: string): ZooidConnection => { const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) const listeners = new Set<(message: RelayMessage) => void>() const pending: ClientMessage[] = [] const write = (message: ClientMessage) => socket.send(JSON.stringify(message)) socket.on("open", () => pending.splice(0).forEach(write)) socket.on("message", data => { const message = parseJson(data.toString()) if (message) { for (const listener of listeners) { listener(message) } } }) // The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts // and the `host` in the tenant's toml have drifted apart. socket.on("error", error => console.error(`The zooid container refused a socket for ${host}: ${error.message}`), ) return { onMessage: listener => { listeners.add(listener) }, // A client may speak before the container has finished its handshake — the page's socket is // open as soon as playwright has a route for it, which is well before this one is. send: message => { if (socket.readyState === WebSocket.OPEN) { write(message) } else { pending.push(message) } }, close: () => socket.close(), wait: match => new Promise(resolve => { const listener = (message: RelayMessage) => { if (match(message)) { listeners.delete(listener) resolve(message) } } listeners.add(listener) }), } } export type ZooidResponse = { status: number headers: Record body: Buffer } // One http request to the container, carrying the same two headers, so that the nip-11 document // and the nip-86 management api the browser reads are the real relay's rather than a mock's. export const requestZooid = ( host: string, method: string, path: string, requestHeaders: Record, body?: Buffer, ): Promise => new Promise((resolve, reject) => { const request = httpRequest( { method, path, host: "127.0.0.1", port, headers: { // A pseudo-header describes the request line rather than the request, and node:http // rejects a header name with a colon in it. ...Object.fromEntries( Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), ), ...headersFor(host), }, }, response => { const chunks: Buffer[] = [] const responseHeaders: Record = {} for (const [name, value] of Object.entries(response.headers)) { // Hop-by-hop headers describe the connection this answer arrived on rather than the one // the browser is waiting on, which playwright frames itself. if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value } } response.on("data", chunk => chunks.push(chunk)) response.on("end", () => resolve({ status: response.statusCode ?? 500, headers: responseHeaders, body: Buffer.concat(chunks), }), ) }, ) request.on("error", reject) request.end(body) })