164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { WebSocket, type RawData, type WebSocketServer } from "ws";
|
|
import { parseHttpRequestFrame, parseJsonFrame } from "../src/protocol.js";
|
|
import { startOpencodeProxy } from "../src/opencode-proxy.js";
|
|
|
|
test("parseHttpRequestFrame accepts a valid frame", () => {
|
|
const frame = parseJsonFrame(
|
|
JSON.stringify({ type: "http", id: "req_1", method: "POST", path: "/session/x/message", body: "{}" }),
|
|
);
|
|
assert.deepEqual(parseHttpRequestFrame(frame), {
|
|
type: "http",
|
|
id: "req_1",
|
|
method: "POST",
|
|
path: "/session/x/message",
|
|
body: "{}",
|
|
});
|
|
});
|
|
|
|
test("parseHttpRequestFrame omits absent body", () => {
|
|
const frame = parseHttpRequestFrame({ type: "http", id: "req_1", method: "GET", path: "/session" });
|
|
assert.deepEqual(frame, { type: "http", id: "req_1", method: "GET", path: "/session", body: undefined });
|
|
});
|
|
|
|
test("parseHttpRequestFrame rejects invalid frames", () => {
|
|
assert.equal(parseHttpRequestFrame({ type: "http", id: "", method: "GET", path: "/x" }), null);
|
|
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "DELETE", path: "/x" }), null);
|
|
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "no-slash" }), null);
|
|
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "/x", body: 5 }), null);
|
|
assert.equal(parseHttpRequestFrame({ type: "other", id: "a", method: "GET", path: "/x" }), null);
|
|
});
|
|
|
|
test("proxy forwards a request to opencode and returns the raw response", async () => {
|
|
const seen: { method?: string; url?: string; auth?: string; body?: string } = {};
|
|
const opencode = await startFakeOpencode((req, res, body) => {
|
|
seen.method = req.method;
|
|
seen.url = req.url;
|
|
seen.auth = req.headers.authorization;
|
|
seen.body = body;
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
|
|
});
|
|
const proxy = startOpencodeProxy({
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
opencodeUrl: opencode.url,
|
|
username: "opencode",
|
|
password: "secret",
|
|
});
|
|
await waitForListening(proxy);
|
|
|
|
try {
|
|
const response = await roundtrip(proxyUrl(proxy), {
|
|
type: "http",
|
|
id: "req_42",
|
|
method: "POST",
|
|
path: "/session/ses_1/message",
|
|
body: JSON.stringify({ parts: [{ type: "text", text: "ping" }] }),
|
|
});
|
|
|
|
assert.equal(response.type, "http-response");
|
|
assert.equal(response.id, "req_42");
|
|
assert.equal(response.status, 200);
|
|
const parsed = JSON.parse(response.body as string) as { parts: { text: string }[] };
|
|
assert.equal(parsed.parts[0].text, "pong");
|
|
|
|
assert.equal(seen.method, "POST");
|
|
assert.equal(seen.url, "/session/ses_1/message");
|
|
assert.equal(seen.auth, "Basic " + Buffer.from("opencode:secret").toString("base64"));
|
|
assert.equal(seen.body, JSON.stringify({ parts: [{ type: "text", text: "ping" }] }));
|
|
} finally {
|
|
await closeWss(proxy);
|
|
await opencode.close();
|
|
}
|
|
});
|
|
|
|
test("proxy maps a fetch failure to status 0", async () => {
|
|
// Point at a port with nothing listening so fetch rejects.
|
|
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl: "http://127.0.0.1:1" });
|
|
await waitForListening(proxy);
|
|
|
|
try {
|
|
const response = await roundtrip(proxyUrl(proxy), {
|
|
type: "http",
|
|
id: "req_err",
|
|
method: "GET",
|
|
path: "/session",
|
|
});
|
|
assert.equal(response.id, "req_err");
|
|
assert.equal(response.status, 0);
|
|
assert.equal(typeof response.error, "string");
|
|
} finally {
|
|
await closeWss(proxy);
|
|
}
|
|
});
|
|
|
|
type HttpResponse = { type: string; id: string; status: number; body?: string; error?: string };
|
|
|
|
function rawToString(data: RawData): string {
|
|
if (Array.isArray(data)) {
|
|
return Buffer.concat(data).toString("utf8");
|
|
}
|
|
if (Buffer.isBuffer(data)) {
|
|
return data.toString("utf8");
|
|
}
|
|
return Buffer.from(data).toString("utf8");
|
|
}
|
|
|
|
function roundtrip(url: string, frame: unknown): Promise<HttpResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(url);
|
|
ws.on("open", () => ws.send(JSON.stringify(frame)));
|
|
ws.on("message", (data: RawData) => {
|
|
ws.close();
|
|
resolve(JSON.parse(rawToString(data)) as HttpResponse);
|
|
});
|
|
ws.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function startFakeOpencode(
|
|
handler: (req: IncomingMessage, res: ServerResponse, body: string) => void,
|
|
): Promise<{ url: string; close: () => Promise<void> }> {
|
|
const server = createServer((req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (c: Buffer) => chunks.push(c));
|
|
req.on("end", () => handler(req, res, Buffer.concat(chunks).toString("utf8")));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = (server.address() as AddressInfo).port;
|
|
return {
|
|
url: `http://127.0.0.1:${port}`,
|
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
};
|
|
}
|
|
|
|
function proxyUrl(proxy: WebSocketServer): string {
|
|
const address = proxy.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("proxy has no TCP address");
|
|
}
|
|
return `ws://127.0.0.1:${address.port}`;
|
|
}
|
|
|
|
function waitForListening(server: WebSocketServer | Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (server.address()) {
|
|
resolve();
|
|
return;
|
|
}
|
|
server.once("listening", () => resolve());
|
|
server.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function closeWss(proxy: WebSocketServer): Promise<void> {
|
|
for (const client of proxy.clients) {
|
|
client.terminate();
|
|
}
|
|
return new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
}
|