flotilla/e2e/harness/zooid/transport.ts
2026-08-07 13:11:12 -07:00

144 lines
5.2 KiB
TypeScript

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://<tenant>.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<RelayMessage>
}
// 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<RelayMessage>(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<string, string>
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<string, string>,
body?: Buffer,
): Promise<ZooidResponse> =>
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<string, string> = {}
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)
})