import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { Hono } from 'hono' import { serve, type ServerType } from '@hono/node-server' import type { AddressInfo } from 'node:net' import { gzipSync } from 'node:zlib' import { arenaProxy } from './arena-proxy.js' // --- Real upstream "arena" server: a second, independent Hono app -------- const registeredBots: { id: string; secret: string; name: string }[] = [] const upstream = new Hono() upstream.post('/api/bots', async (c) => { const body = await c.req.json().catch(() => ({})) const bot = { id: `bot-${registeredBots.length + 1}`, secret: 'shh', name: body.name ?? 'unnamed' } registeredBots.push(bot) return c.json(bot) }) upstream.get('/api/bots', (c) => c.json(registeredBots)) upstream.get('/api/echo', (c) => { return c.json({ method: c.req.method, path: new URL(c.req.url).pathname, query: new URL(c.req.url).search, host: c.req.header('host') ?? null, }) }) upstream.post('/api/echo', async (c) => { const body = await c.req.json().catch(() => null) return c.json({ method: c.req.method, path: new URL(c.req.url).pathname, query: new URL(c.req.url).search, body, host: c.req.header('host') ?? null, xff: c.req.header('x-forwarded-for') ?? null, }) }) upstream.get('/api/sse', (c) => { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode('event: frame\ndata: {"n":1}\n\n')) await new Promise((r) => setTimeout(r, 60)) controller.enqueue(encoder.encode('event: frame\ndata: {"n":2}\n\n')) await new Promise((r) => setTimeout(r, 60)) controller.enqueue(encoder.encode('event: frame\ndata: {"n":3}\n\n')) controller.close() }, }) return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' }, }) }) upstream.get('/api/gzip-lie', (c) => { // Upstream actually gzip-compresses the body and declares content-encoding // for the COMPRESSED bytes. undici transparently decompresses on the // proxy's fetch() before this code ever sees the response, so by the time // the proxy builds its own Response, the stale content-encoding/ // content-length (describing the compressed representation) would corrupt // what the caller receives if copied through verbatim — the proxy must // strip them, not forward them. const compressed = gzipSync(Buffer.from(JSON.stringify({ ok: true }))) return new Response(compressed, { status: 200, // content-length deliberately omitted — the underlying Node HTTP server // computes and sends the real one automatically. headers: { 'content-type': 'application/json', 'content-encoding': 'gzip', }, }) }) let upstreamServer: ServerType let upstreamUrl: string beforeAll(async () => { await new Promise((resolve) => { upstreamServer = serve({ fetch: upstream.fetch, port: 0 }, (info) => { upstreamUrl = `http://127.0.0.1:${(info as AddressInfo).port}` resolve() }) }) }) afterAll(async () => { await new Promise((resolve) => upstreamServer.close(() => resolve())) }) // --- Proxying app under test ---------------------------------------------- function buildProxyingApp() { const app = new Hono() app.use('/api/*', arenaProxy) app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' })) app.get('/api/sentinel', (c) => c.json({ sentinel: true })) return app } // A real Node HTTP server for the proxying app itself, needed to exercise // the actual socket.remoteAddress lookup arenaProxy uses for x-forwarded-for // — Hono's in-process app.request() harness has no real socket to read. async function withRealProxyingServer(fn: (baseUrl: string) => Promise): Promise { const app = buildProxyingApp() let server: ServerType const baseUrl = await new Promise((resolve) => { server = serve({ fetch: app.fetch, port: 0 }, (info) => { resolve(`http://127.0.0.1:${(info as AddressInfo).port}`) }) }) try { return await fn(baseUrl) } finally { await new Promise((resolve) => server.close(() => resolve())) } } describe('arenaProxy', () => { const originalEnv = process.env.ARENA_UPSTREAM_URL afterEach(() => { if (originalEnv === undefined) delete process.env.ARENA_UPSTREAM_URL else process.env.ARENA_UPSTREAM_URL = originalEnv }) it('registers a bot upstream and reads it back through the proxy', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl const app = buildProxyingApp() const postRes = await app.request('/api/bots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'cross-node-bot' }), }) expect(postRes.status).toBe(200) const posted = await postRes.json() as { name: string } expect(posted.name).toBe('cross-node-bot') const getRes = await app.request('/api/bots') expect(getRes.status).toBe(200) const list = await getRes.json() as { name: string }[] expect(list.some((b) => b.name === 'cross-node-bot')).toBe(true) }) it('falls through to local routers when ARENA_UPSTREAM_URL is unset', async () => { delete process.env.ARENA_UPSTREAM_URL const app = buildProxyingApp() const res = await app.request('/api/sentinel') expect(res.status).toBe(200) const body = await res.json() as { sentinel: boolean } expect(body.sentinel).toBe(true) }) it('answers /api/health locally even in proxy mode', async () => { // Point at a port with nothing listening. process.env.ARENA_UPSTREAM_URL = 'http://127.0.0.1:1' const app = buildProxyingApp() const res = await app.request('/api/health') expect(res.status).toBe(200) const body = await res.json() as { status: string } expect(body.status).toBe('ok') }) it('forwards method, query string and JSON body unchanged', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl const app = buildProxyingApp() const res = await app.request('/api/echo?a=1&b=2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hello: 'world' }), }) expect(res.status).toBe(200) const body = await res.json() as { method: string; path: string; query: string; body: unknown } expect(body.method).toBe('POST') expect(body.path).toBe('/api/echo') expect(body.query).toBe('?a=1&b=2') expect(body.body).toEqual({ hello: 'world' }) }) it('does not forward the inbound Host header', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl const app = buildProxyingApp() const res = await app.request('/api/echo', { headers: { Host: 'caller-node.example.com' }, }) expect(res.status).toBe(200) const body = await res.json() as { host: string | null } expect(body.host).not.toBe('caller-node.example.com') expect(body.host).toBe(new URL(upstreamUrl).host) }) it('strips response content-encoding and content-length', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl const app = buildProxyingApp() const res = await app.request('/api/gzip-lie') expect(res.status).toBe(200) expect(res.headers.get('content-encoding')).toBeNull() expect(res.headers.get('content-length')).toBeNull() const body = await res.json() as { ok: boolean } expect(body.ok).toBe(true) }) it('streams SSE incrementally through the proxy', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl const app = buildProxyingApp() const start = Date.now() const res = await app.request('/api/sse') expect(res.status).toBe(200) expect(res.body).not.toBeNull() const reader = res.body!.getReader() const decoder = new TextDecoder() let firstFrameAt: number | null = null let buffer = '' let frameCount = 0 while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const frames = buffer.split('\n\n').filter((f) => f.includes('event: frame')) if (frames.length > 0 && firstFrameAt === null) { firstFrameAt = Date.now() } frameCount = frames.length } expect(frameCount).toBe(3) // The first frame must have arrived well before the full ~120ms stream // finished — proves the proxy piped the stream through instead of // buffering the whole thing before responding. expect(firstFrameAt).not.toBeNull() expect(firstFrameAt! - start).toBeLessThan(100) }) it('forwards the client address in x-forwarded-for', async () => { process.env.ARENA_UPSTREAM_URL = upstreamUrl // Drive the proxying app over a REAL socket (loopback) so // c.env.incoming.socket.remoteAddress is actually populated, exercising // the real code path instead of Hono's in-process app.request() harness. const body = await withRealProxyingServer(async (baseUrl) => { const res = await fetch(`${baseUrl}/api/echo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(200) return await res.json() as { xff: string | null } }) expect(body.xff).toBeTruthy() // Loopback connection — either IPv4 or IPv6-mapped loopback form. expect(body.xff).toMatch(/127\.0\.0\.1|::1|::ffff:127\.0\.0\.1/) }) it('answers 502 when the arena is unreachable', async () => { process.env.ARENA_UPSTREAM_URL = 'http://127.0.0.1:1' const app = buildProxyingApp() const res = await app.request('/api/bots') expect(res.status).toBe(502) const body = await res.json() as { error: string } expect(body.error).toBeTruthy() }) })