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 { statsRouter } from './routes/stats.js'
|
||||||
import { arcadeRouter } from './routes/arcade.js'
|
import { arcadeRouter } from './routes/arcade.js'
|
||||||
import { rateLimit } from './middleware/rate-limit.js'
|
import { rateLimit } from './middleware/rate-limit.js'
|
||||||
|
import { arenaProxy } from './middleware/arena-proxy.js'
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
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')
|
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.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
|
||||||
|
|
||||||
app.route('/api/auth', authRouter)
|
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