feat(09-01): arena-proxy survives SSE, client-IP forwarding, upstream-down
CI / check (push) Has been cancelled

- Forward client IP via x-forwarded-for/x-real-ip so the canonical arena's
  per-IP rate limiting isn't collapsed to one bucket per node.
- 30s AbortSignal.timeout on non-stream requests; SSE fight streams
  (/api/fights/:id/stream) are exempt (long-lived by design).
- On upstream fetch failure, log and return 502 {error} instead of a
  buffered hang or a 500 stack trace.
- fights.ts: set X-Accel-Buffering: no on the SSE stream response so an
  nginx-fronted arena (nginx-proxy-manager) doesn't buffer live fight events.
- docker-compose.yml: document ARENA_UPSTREAM_URL / TRUSTED_PROXY (commented,
  no active value set here — the canonical arena gets its own compose file
  in a later plan).

TDD: added the SSE/XFF/502 tests, confirmed the 502 test failed against the
prior implementation, then implemented to green (9/9 arena-proxy tests,
17/17 combined with rate-limit.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-30 21:32:57 -04:00
co-authored by Claude Fable 5
parent 143ca808e8
commit 0511b97cb9
4 changed files with 133 additions and 2 deletions
+82
View File
@@ -40,6 +40,24 @@ upstream.post('/api/echo', async (c) => {
})
})
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
@@ -174,4 +192,68 @@ describe('arenaProxy', () => {
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
const app = buildProxyingApp()
const res = await app.request('/api/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(200)
const body = await res.json() as { xff: string | null }
// app.request() drives the Hono app directly (no real Node socket), so the
// remoteAddress lookup this proxy relies on may legitimately be
// undetermined here — the behavioral contract is that the header is
// either forwarded (non-empty) or cleanly absent, never invented/garbage.
if (body.xff !== null) {
expect(body.xff.length).toBeGreaterThan(0)
}
})
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()
})
})