feat(09-01): arena-proxy middleware — REST forwarding to canonical arena
CI / check (push) Has been cancelled
CI / check (push) Has been cancelled
Hono middleware that forwards /api/* to ARENA_UPSTREAM_URL when set, with standalone mode (env unset) and /api/health untouched. Verified end-to-end against a real second HTTP server: register a bot upstream, read it back through the proxy, method/query/JSON body forwarded unchanged, inbound Host header dropped, response content-encoding/content-length stripped. TDD: 6 tests written first and confirmed failing (module didn't exist), then implemented to green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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/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<void>((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<void>((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
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user