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:
@@ -16,6 +16,7 @@ import { adminRouter } from './routes/admin.js'
|
||||
import { statsRouter } from './routes/stats.js'
|
||||
import { arcadeRouter } from './routes/arcade.js'
|
||||
import { rateLimit } from './middleware/rate-limit.js'
|
||||
import { arenaProxy } from './middleware/arena-proxy.js'
|
||||
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
@@ -92,6 +93,10 @@ app.use('/api/docs/*', async (c, next) => {
|
||||
if (c.req.method === 'GET') c.header('Cache-Control', 'public, max-age=3600')
|
||||
})
|
||||
|
||||
// When ARENA_UPSTREAM_URL is set, forward every /api/* request to the
|
||||
// canonical arena instead of the local routers (BOT-03). No-ops otherwise.
|
||||
app.use('/api/*', arenaProxy)
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
|
||||
|
||||
app.route('/api/auth', authRouter)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Context, Next } from 'hono'
|
||||
|
||||
// Requests answered locally even when ARENA_UPSTREAM_URL is set — the manifest
|
||||
// health check must never depend on the canonical arena being reachable, or a
|
||||
// perfectly healthy node container gets marked unhealthy and restart-looped.
|
||||
const LOCAL_BYPASS_PATHS = new Set(['/api/health'])
|
||||
|
||||
// Headers that must never be copied verbatim between hops (either because
|
||||
// they're connection-scoped, or because copying a stale value corrupts the
|
||||
// forwarded/returned message — e.g. content-length after undici recomputes
|
||||
// the body, or content-encoding after undici already decoded it).
|
||||
const HOP_BY_HOP = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'content-length',
|
||||
])
|
||||
|
||||
function buildTargetUrl(upstream: string, path: string, search: string): URL {
|
||||
// Build from the base + path + the ORIGINAL query string. Do not round-trip
|
||||
// through URLSearchParams — that reorders and re-escapes repeated keys.
|
||||
return new URL(path + search, upstream)
|
||||
}
|
||||
|
||||
function copyForwardHeaders(c: Context): Headers {
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of c.req.raw.headers) {
|
||||
if (HOP_BY_HOP.has(key.toLowerCase())) continue
|
||||
headers.append(key, value)
|
||||
}
|
||||
// Ask the upstream for an uncompressed body — Node's fetch already handles
|
||||
// decoding for us, and forwarding compression bookkeeping is unnecessary.
|
||||
headers.set('accept-encoding', 'identity')
|
||||
return headers
|
||||
}
|
||||
|
||||
function copyResponseHeaders(upstreamHeaders: Headers): Headers {
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of upstreamHeaders) {
|
||||
if (HOP_BY_HOP.has(key.toLowerCase())) continue
|
||||
if (key.toLowerCase() === 'content-encoding') continue
|
||||
headers.append(key, value)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function arenaProxy(c: Context, next: Next) {
|
||||
// Read the env var on every call — a module-level constant would be
|
||||
// captured at import time and could never be toggled by tests or by a
|
||||
// container restart-free config change.
|
||||
const upstream = process.env.ARENA_UPSTREAM_URL
|
||||
if (!upstream) return next() // standalone mode — today's code path, untouched
|
||||
|
||||
const path = c.req.path
|
||||
if (LOCAL_BYPASS_PATHS.has(path)) return next()
|
||||
|
||||
const search = new URL(c.req.url).search
|
||||
const target = buildTargetUrl(upstream, path, search)
|
||||
const headers = copyForwardHeaders(c)
|
||||
const method = c.req.method
|
||||
|
||||
const upstreamRes = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: method === 'GET' || method === 'HEAD' ? undefined : c.req.raw.body,
|
||||
redirect: 'manual',
|
||||
// Node's undici fetch requires `duplex` whenever a streamed body is sent.
|
||||
// `@types/node` 22.13.14 already includes `duplex` on RequestInit.
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
return new Response(upstreamRes.body, {
|
||||
status: upstreamRes.status,
|
||||
headers: copyResponseHeaders(upstreamRes.headers),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user