@thoughtpivot/flight 2.0.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,465 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import Router from '@koa/router'
7
+
8
+ import { openFlight, type FlightServer } from './app'
9
+ import { loadConfig, type Config } from './config'
10
+ import { createKoaDispatcher } from './koa-compat'
11
+ import { safeTarget } from './static'
12
+
13
+ const servers: FlightServer[] = []
14
+ const dirs: string[] = []
15
+
16
+ /**
17
+ * Remove temp apps and stop servers started by this file.
18
+ */
19
+ afterEach(async () => {
20
+ while (servers.length) servers.pop()?.stop(true)
21
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
22
+ })
23
+
24
+ /**
25
+ * Create an app root with a Koa backend, a Bun route fragment, a compiled `.backend.js`, and a tiny SPA.
26
+ */
27
+ async function makeApp(): Promise<string> {
28
+ const dir = await mkdtemp(join(tmpdir(), 'flight-bun-'))
29
+ dirs.push(dir)
30
+ await symlink(join(import.meta.dir, '../../node_modules'), join(dir, 'node_modules'), 'dir')
31
+ await mkdir(join(dir, 'dist/assets'), { recursive: true })
32
+ await writeFile(join(dir, 'dist/index.html'), '<!doctype html><title>flight-spa</title>', 'utf8')
33
+ await writeFile(join(dir, 'dist/assets/app.js'), 'export const x = "asset"', 'utf8')
34
+ await writeFile(
35
+ join(dir, 'hello.backend.ts'),
36
+ `import Router from '@koa/router'
37
+ const router = new Router()
38
+ router.get('/hello', async (ctx) => {
39
+ ctx.body = { message: 'Hello from Flight!' }
40
+ })
41
+ router.post('/api/koa-echo', async (ctx) => {
42
+ ctx.body = { received: ctx.request.body }
43
+ })
44
+ export default router.routes()
45
+ `
46
+ )
47
+ await writeFile(
48
+ join(dir, 'ping.backend.ts'),
49
+ `export default {
50
+ '/api/ping': { GET: () => Response.json({ ok: true }) },
51
+ '/api/login': {
52
+ POST: (req) => {
53
+ req.session.user = 'ada'
54
+ return Response.json({ ok: true })
55
+ }
56
+ },
57
+ '/api/me': { GET: (req) => Response.json({ user: req.session?.user ?? null }) },
58
+ '/api/cached': {
59
+ GET: () =>
60
+ new Response(JSON.stringify({ n: 1 }), {
61
+ headers: { 'content-type': 'application/json', 'cache-control': 'max-age=30' }
62
+ })
63
+ }
64
+ }
65
+ `
66
+ )
67
+ await writeFile(
68
+ join(dir, 'compiled.backend.js'),
69
+ `export default { '/api/from-js': { GET: () => Response.json({ from: 'js' }) } }
70
+ `
71
+ )
72
+ return dir
73
+ }
74
+
75
+ /**
76
+ * Boot the Bun runtime against a throwaway app. Middleware that tests do not care about stays off.
77
+ */
78
+ async function boot(env: Record<string, string>): Promise<{ server: FlightServer; url: string }> {
79
+ const appHome = env.FLIGHT_APP_HOME || (await makeApp())
80
+ const cfg: Config = loadConfig({
81
+ FLIGHT_MODE: 'production',
82
+ FLIGHT_LOGGING: 'false',
83
+ FLIGHT_CORS_ENABLED: 'false',
84
+ FLIGHT_SECURITY_ENABLED: 'false',
85
+ FLIGHT_COMPRESS_ENABLED: 'false',
86
+ FLIGHT_APP_HOME: appHome,
87
+ FLIGHT_DIST_PATH: join(appHome, 'dist'),
88
+ ...env,
89
+ FLIGHT_APP_HOME: env.FLIGHT_APP_HOME || appHome
90
+ })
91
+ const server = await openFlight(cfg, { port: 0, hostname: '127.0.0.1' })
92
+ servers.push(server)
93
+ return { server, url: `http://127.0.0.1:${server.port}` }
94
+ }
95
+
96
+ test('healthz, Bun routes, compiled backends, and Koa routes answer on one server', async () => {
97
+ const { url } = await boot({})
98
+
99
+ const health = await fetch(`${url}/healthz`)
100
+ expect(await health.text()).toBe('ok')
101
+
102
+ const ping = await fetch(`${url}/api/ping`, { headers: { accept: 'application/json' } })
103
+ expect(ping.status).toBe(200)
104
+ expect(await ping.json()).toEqual({ ok: true })
105
+
106
+ const fromJs = await fetch(`${url}/api/from-js`)
107
+ expect(await fromJs.json()).toEqual({ from: 'js' })
108
+
109
+ const hello = await fetch(`${url}/hello`)
110
+ expect(hello.status).toBe(200)
111
+ expect(await hello.json()).toEqual({ message: 'Hello from Flight!' })
112
+
113
+ const echo = await fetch(`${url}/api/koa-echo`, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ name: 'ada' })
117
+ })
118
+ expect(echo.status).toBe(200)
119
+ expect(await echo.json()).toEqual({ received: { name: 'ada' } })
120
+ })
121
+
122
+ test('production SPA serves hashed assets and falls back to index.html', async () => {
123
+ const { url } = await boot({})
124
+
125
+ const asset = await fetch(`${url}/assets/app.js`)
126
+ expect(asset.status).toBe(200)
127
+ expect(await asset.text()).toContain('export const x')
128
+
129
+ const deep = await fetch(`${url}/dashboard/deep`, { headers: { accept: 'text/html' } })
130
+ expect(deep.status).toBe(200)
131
+ expect(deep.headers.get('content-type')).toContain('text/html')
132
+ expect(await deep.text()).toContain('flight-spa')
133
+
134
+ const apiMiss = await fetch(`${url}/api/missing`, { headers: { accept: 'text/html' } })
135
+ expect(apiMiss.status).toBe(404)
136
+ expect(await apiMiss.text()).not.toContain('flight-spa')
137
+
138
+ const escaped = await fetch(`${url}/%2e%2e/secret.txt`)
139
+ expect(escaped.status).toBe(404)
140
+ expect(await escaped.text()).not.toContain('flight-spa')
141
+ expect(safeTarget('/tmp/dist', '/../secret.txt')).toBeNull()
142
+ expect(safeTarget('/tmp/dist', '/assets/app.js')).toBe('/tmp/dist/assets/app.js')
143
+ })
144
+
145
+ test('rate limit uses the peer address unless the proxy is trusted', async () => {
146
+ const shared = {
147
+ FLIGHT_RATE_LIMIT_MAX: '1',
148
+ FLIGHT_RATE_LIMIT_DURATION_MS: '60000'
149
+ }
150
+ const locked = await boot(shared)
151
+ const first = await fetch(`${locked.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.5' } })
152
+ const spoofed = await fetch(`${locked.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.9' } })
153
+ expect(first.status).toBe(200)
154
+ expect(spoofed.status).toBe(429)
155
+
156
+ const trusted = await boot({ ...shared, FLIGHT_TRUST_PROXY: '1' })
157
+ const fromA = await fetch(`${trusted.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.5' } })
158
+ const fromB = await fetch(`${trusted.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.9' } })
159
+ expect(fromA.status).toBe(200)
160
+ expect(fromB.status).toBe(200)
161
+ })
162
+
163
+ test('static assets do not consume the API rate limit', async () => {
164
+ const { url } = await boot({ FLIGHT_RATE_LIMIT_MAX: '1' })
165
+ expect((await fetch(`${url}/assets/app.js`)).status).toBe(200)
166
+ expect((await fetch(`${url}/assets/app.js`)).status).toBe(200)
167
+ expect((await fetch(`${url}/api/ping`)).status).toBe(200)
168
+ expect((await fetch(`${url}/api/ping`)).status).toBe(429)
169
+ })
170
+
171
+ test('sessions round-trip through memory and through Redis', async () => {
172
+ const cases = [{}, { FLIGHT_REDIS_URL: 'redis://127.0.0.1:6379' }]
173
+ for (const extra of cases) {
174
+ const { url } = await boot({ FLIGHT_SESSION_SECRET: 'test-secret', ...extra })
175
+ const login = await fetch(`${url}/api/login`, { method: 'POST' })
176
+ expect(login.status).toBe(200)
177
+ const setCookie = login.headers.get('set-cookie')
178
+ expect(setCookie).toBeTruthy()
179
+ const me = await fetch(`${url}/api/me`, { headers: { cookie: setCookie!.split(';')[0] } })
180
+ expect(await me.json()).toEqual({ user: 'ada' })
181
+ const anon = await fetch(`${url}/api/me`)
182
+ expect(await anon.json()).toEqual({ user: null })
183
+ }
184
+ })
185
+
186
+ test('opt-in response cache serves the second GET from the store', async () => {
187
+ const { url } = await boot({ FLIGHT_CACHE_ENABLED: 'true' })
188
+ const miss = await fetch(`${url}/api/cached`, { headers: { accept: 'application/json' } })
189
+ expect(miss.headers.get('x-flight-cache')).toBe('MISS')
190
+ expect(await miss.json()).toEqual({ n: 1 })
191
+ const hit = await fetch(`${url}/api/cached`, { headers: { accept: 'application/json' } })
192
+ expect(hit.headers.get('x-flight-cache')).toBe('HIT')
193
+ expect(await hit.json()).toEqual({ n: 1 })
194
+ })
195
+
196
+ /**
197
+ * Add a Koa backend that streams server-sent events and ends one route early.
198
+ * A router that does not handle these paths is mounted first, in this file, so the
199
+ * order does not depend on how the directory is listed.
200
+ */
201
+ async function writeStreamBackend(dir: string, flagPath: string, gapMs: number): Promise<void> {
202
+ await writeFile(
203
+ join(dir, 'stream.backend.ts'),
204
+ `import Router from '@koa/router'
205
+ import { writeFileSync } from 'node:fs'
206
+ const miss = new Router()
207
+ miss.get('/api/not-events', (ctx) => {
208
+ ctx.body = { ok: true }
209
+ })
210
+ const router = new Router()
211
+ const flag = ${JSON.stringify(flagPath)}
212
+ const gapMs = ${gapMs}
213
+
214
+ router.get('/api/events', async (ctx) => {
215
+ ctx.respond = false
216
+ ctx.res.writeHead(200, {
217
+ 'content-type': 'text/event-stream',
218
+ 'cache-control': 'no-cache',
219
+ connection: 'keep-alive'
220
+ })
221
+ ctx.res.write('data: one\\n\\n')
222
+ await new Promise((resolve) => setTimeout(resolve, gapMs))
223
+ ctx.res.write('data: two\\n\\n')
224
+ ctx.res.end()
225
+ })
226
+
227
+ router.get('/api/events-open', (ctx) => {
228
+ ctx.respond = false
229
+ ctx.res.writeHead(200, {
230
+ 'content-type': 'text/event-stream',
231
+ 'cache-control': 'no-cache'
232
+ })
233
+ ctx.res.write('data: hello\\n\\n')
234
+ const timer = setInterval(() => {
235
+ ctx.res.write('data: beat\\n\\n')
236
+ }, 400)
237
+ timer.unref()
238
+ ctx.res.on('close', () => {
239
+ clearInterval(timer)
240
+ writeFileSync(flag, 'closed')
241
+ })
242
+ })
243
+
244
+ router.get('/api/early-end', (ctx) => {
245
+ ctx.respond = false
246
+ ctx.res.writeHead(201, { 'content-type': 'text/plain; charset=utf-8' })
247
+ try {
248
+ ctx.res.write('from-route')
249
+ } finally {
250
+ ctx.res.end()
251
+ }
252
+ })
253
+
254
+ router.get('/api/big-text', (ctx) => {
255
+ ctx.body = 'z'.repeat(2048)
256
+ })
257
+
258
+ export default (ctx, next) => miss.routes()(ctx, () => router.routes()(ctx, next))
259
+ `
260
+ )
261
+ }
262
+
263
+ /**
264
+ * Read from an open body until `needle` shows up, and record how long that chunk took.
265
+ */
266
+ async function readUntil(
267
+ reader: ReadableStreamDefaultReader<Uint8Array>,
268
+ needle: string
269
+ ): Promise<{ text: string; firstByteMs: number }> {
270
+ const decoder = new TextDecoder()
271
+ const started = performance.now()
272
+ let text = ''
273
+ let firstByteMs = 0
274
+ while (!text.includes(needle)) {
275
+ const { value, done } = await reader.read()
276
+ if (done) break
277
+ if (!text) firstByteMs = performance.now() - started
278
+ text += decoder.decode(value, { stream: true })
279
+ }
280
+ return { text, firstByteMs }
281
+ }
282
+
283
+ test('FLIGHT_IDLE_TIMEOUT defaults to 10 seconds and clamps to Bun maximum', () => {
284
+ expect(loadConfig({}).idleTimeout).toBe(10)
285
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '0' }).idleTimeout).toBe(0)
286
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '0.0' }).idleTimeout).toBe(0)
287
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '0.5' }).idleTimeout).toBe(1)
288
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '1.2' }).idleTimeout).toBe(2)
289
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '30' }).idleTimeout).toBe(30)
290
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '999' }).idleTimeout).toBe(255)
291
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '-5' }).idleTimeout).toBe(10)
292
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: 'nope' }).idleTimeout).toBe(10)
293
+ expect(loadConfig({ FLIGHT_IDLE_TIMEOUT: '' }).idleTimeout).toBe(10)
294
+ })
295
+
296
+ /**
297
+ * Two routers in a fixed order. The first does not handle the path under test.
298
+ * `@koa/router` assigns `ctx.matched` for that first router and only `push`es after that.
299
+ */
300
+ function orderedRouters(setup: (router: Router) => void) {
301
+ const miss = new Router()
302
+ miss.get('/api/not-events', (ctx) => {
303
+ ctx.body = { ok: true }
304
+ })
305
+ const hit = new Router()
306
+ setup(hit)
307
+ return [miss.routes(), hit.routes()]
308
+ }
309
+
310
+ test('a non-matching router mounted first still streams the later router', async () => {
311
+ const dispatch = await createKoaDispatcher(
312
+ orderedRouters((router) => {
313
+ router.get('/api/events', (ctx) => {
314
+ ctx.respond = false
315
+ ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
316
+ ctx.res.write('data: one\n\n')
317
+ })
318
+ }),
319
+ '1mb'
320
+ )
321
+
322
+ const response = await dispatch(new Request('http://127.0.0.1/api/events'))
323
+ expect(response).not.toBeNull()
324
+ expect(response!.status).toBe(200)
325
+ expect(response!.headers.get('content-type')).toContain('text/event-stream')
326
+ const reader = response!.body?.getReader()
327
+ if (!reader) throw new Error('expected a response body')
328
+ const first = await readUntil(reader, 'one')
329
+ expect(first.text).toContain('data: one')
330
+ await reader.cancel()
331
+
332
+ const missed = await dispatch(new Request('http://127.0.0.1/nowhere'))
333
+ expect(missed).toBeNull()
334
+ })
335
+
336
+ test('a non-matching router mounted first still returns an early end', async () => {
337
+ const dispatch = await createKoaDispatcher(
338
+ orderedRouters((router) => {
339
+ router.get('/api/early-end', (ctx) => {
340
+ ctx.respond = false
341
+ ctx.res.writeHead(201, { 'content-type': 'text/plain; charset=utf-8' })
342
+ try {
343
+ ctx.res.write('from-route')
344
+ } finally {
345
+ ctx.res.end()
346
+ }
347
+ })
348
+ }),
349
+ '1mb'
350
+ )
351
+
352
+ const response = await dispatch(new Request('http://127.0.0.1/api/early-end'))
353
+ expect(response).not.toBeNull()
354
+ expect(response!.status).toBe(201)
355
+ expect(await response!.text()).toBe('from-route')
356
+ })
357
+
358
+ test('Koa SSE chunks arrive before the stream ends', async () => {
359
+ const dir = await makeApp()
360
+ await writeStreamBackend(dir, join(dir, 'closed.flag'), 300)
361
+ const { url } = await boot({
362
+ FLIGHT_APP_HOME: dir,
363
+ FLIGHT_SECURITY_ENABLED: 'true',
364
+ FLIGHT_CORS_ENABLED: 'true'
365
+ })
366
+
367
+ const response = await fetch(`${url}/api/events`, { headers: { accept: 'text/event-stream' } })
368
+ expect(response.status).toBe(200)
369
+ expect(response.headers.get('content-type')).toContain('text/event-stream')
370
+ expect(response.headers.get('x-content-type-options')).toBe('nosniff')
371
+ const reader = response.body?.getReader()
372
+ if (!reader) throw new Error('expected a response body')
373
+
374
+ const first = await readUntil(reader, 'one')
375
+ expect(first.text).toContain('data: one')
376
+ expect(first.text).not.toContain('two')
377
+ expect(first.firstByteMs).toBeLessThan(250)
378
+
379
+ const rest = await readUntil(reader, 'two')
380
+ expect(rest.text).toContain('data: two')
381
+ })
382
+
383
+ test('closing the SSE client emits close on the Koa response', async () => {
384
+ const dir = await makeApp()
385
+ const flag = join(dir, 'closed.flag')
386
+ await writeStreamBackend(dir, flag, 300)
387
+ const { url } = await boot({ FLIGHT_APP_HOME: dir })
388
+
389
+ const controller = new AbortController()
390
+ const response = await fetch(`${url}/api/events-open`, { signal: controller.signal })
391
+ expect(response.status).toBe(200)
392
+ const reader = response.body?.getReader()
393
+ const first = await reader?.read()
394
+ expect(new TextDecoder().decode(first?.value)).toContain('hello')
395
+
396
+ controller.abort()
397
+ const started = Date.now()
398
+ let body = ''
399
+ while (Date.now() - started < 2000) {
400
+ try {
401
+ body = await readFile(flag, 'utf8')
402
+ break
403
+ } catch {
404
+ await new Promise((resolve) => setTimeout(resolve, 20))
405
+ }
406
+ }
407
+ expect(body).toBe('closed')
408
+ })
409
+
410
+ test('a Koa route that ends inside the handler is not replaced by the SPA 404', async () => {
411
+ const dir = await makeApp()
412
+ await writeStreamBackend(dir, join(dir, 'closed.flag'), 300)
413
+ const { url } = await boot({ FLIGHT_APP_HOME: dir })
414
+
415
+ const response = await fetch(`${url}/api/early-end`, { headers: { accept: 'text/html' } })
416
+ expect(response.status).toBe(201)
417
+ expect(await response.text()).toBe('from-route')
418
+ })
419
+
420
+ test('gzip does not buffer a Koa event stream', async () => {
421
+ const dir = await makeApp()
422
+ await writeStreamBackend(dir, join(dir, 'closed.flag'), 300)
423
+ const { url } = await boot({
424
+ FLIGHT_APP_HOME: dir,
425
+ FLIGHT_COMPRESS_ENABLED: 'true',
426
+ FLIGHT_COMPRESS_THRESHOLD: '1024'
427
+ })
428
+
429
+ const plain = await fetch(`${url}/api/big-text`, { headers: { 'accept-encoding': 'gzip' } })
430
+ expect(plain.status).toBe(200)
431
+ expect(plain.headers.get('content-encoding')).toBe('gzip')
432
+ expect((await plain.arrayBuffer()).byteLength).toBe(2048)
433
+
434
+ const events = await fetch(`${url}/api/events`, {
435
+ headers: { accept: 'text/event-stream', 'accept-encoding': 'gzip' }
436
+ })
437
+ expect(events.status).toBe(200)
438
+ expect(events.headers.get('content-encoding')).toBeNull()
439
+ const reader = events.body?.getReader()
440
+ if (!reader) throw new Error('expected a response body')
441
+ const first = await readUntil(reader, 'one')
442
+ expect(first.text).toContain('data: one')
443
+ expect(first.text).not.toContain('two')
444
+ expect(first.firstByteMs).toBeLessThan(250)
445
+ })
446
+
447
+ test(
448
+ 'SSE stays open past FLIGHT_IDLE_TIMEOUT',
449
+ async () => {
450
+ const dir = await makeApp()
451
+ await writeStreamBackend(dir, join(dir, 'closed.flag'), 5000)
452
+ const { url } = await boot({ FLIGHT_APP_HOME: dir, FLIGHT_IDLE_TIMEOUT: '1' })
453
+
454
+ const response = await fetch(`${url}/api/events`)
455
+ expect(response.headers.get('content-type')).toContain('text/event-stream')
456
+ const reader = response.body?.getReader()
457
+ if (!reader) throw new Error('expected a response body')
458
+ const first = await readUntil(reader, 'one')
459
+ expect(first.text).toContain('data: one')
460
+ expect(first.text).not.toContain('two')
461
+ const rest = await readUntil(reader, 'two')
462
+ expect(rest.text).toContain('data: two')
463
+ },
464
+ { timeout: 12000 }
465
+ )
package/src/bun/app.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { discoverBackends, type MethodMap } from './backends'
2
+ import { cache } from './cache'
3
+ import type { Config } from './config'
4
+ import { createKoaDispatcher } from './koa-compat'
5
+ import {
6
+ clientKey,
7
+ compose,
8
+ compress,
9
+ cors,
10
+ isEventStream,
11
+ logger,
12
+ rateLimit,
13
+ security,
14
+ type AddressServer,
15
+ type Handler,
16
+ type Middleware
17
+ } from './middleware'
18
+ import { session } from './session'
19
+ import { makeStaticHandler } from './static'
20
+ import { createStore } from './store'
21
+
22
+ /** Bun server handle returned by `openFlight`. */
23
+ export interface FlightServer extends AddressServer {
24
+ port: number
25
+ stop(closeActiveConnections?: boolean): void
26
+ }
27
+
28
+ /** Slice of `Bun.serve`'s server used to adjust the idle timeout for one request. */
29
+ interface BunHttpServer {
30
+ timeout(request: Request, seconds: number): void
31
+ }
32
+
33
+ /**
34
+ * Turn the idle timeout off for server-sent events so a quiet heartbeat is not cut off.
35
+ */
36
+ function relaxIdleTimeout(req: Request, response: Response, bunServer?: BunHttpServer): Response {
37
+ if (bunServer && isEventStream(response)) bunServer.timeout(req, 0)
38
+ return response
39
+ }
40
+
41
+ /**
42
+ * Wrap discovered routes so every method runs through the middleware onion.
43
+ * OPTIONS is registered when CORS is on, otherwise Bun answers 405 before our middleware runs.
44
+ */
45
+ function wrapRoutes(
46
+ routes: Record<string, Handler | MethodMap>,
47
+ run: (req: Request, core: Handler) => Promise<Response>,
48
+ corsEnabled: boolean
49
+ ): Record<string, Handler | MethodMap> {
50
+ const wrapped: Record<string, Handler | MethodMap> = {}
51
+ for (const [path, value] of Object.entries(routes)) {
52
+ if (typeof value === 'function') {
53
+ const handler = value
54
+ wrapped[path] = (req: Request, bunServer?: BunHttpServer) =>
55
+ run(req, () => handler(req)).then((response) => relaxIdleTimeout(req, response, bunServer))
56
+ } else {
57
+ const methods: MethodMap = {}
58
+ for (const [method, handler] of Object.entries(value)) {
59
+ methods[method] = (req: Request, bunServer?: BunHttpServer) =>
60
+ run(req, () => handler(req)).then((response) => relaxIdleTimeout(req, response, bunServer))
61
+ }
62
+ if (corsEnabled && !methods.OPTIONS) {
63
+ methods.OPTIONS = (req: Request) => run(req, () => new Response(null, { status: 204 }))
64
+ }
65
+ wrapped[path] = methods
66
+ }
67
+ }
68
+ return wrapped
69
+ }
70
+
71
+ /**
72
+ * Build the Fetch application: Bun routes, Koa compatibility, and the SPA fallback.
73
+ */
74
+ export async function createApp(cfg: Config, server?: () => AddressServer | undefined) {
75
+ const discovered = await discoverBackends(cfg.appHome, cfg.excludePaths)
76
+ const middlewares: Middleware[] = []
77
+ const enabled: string[] = []
78
+
79
+ if (cfg.logging) {
80
+ middlewares.push(logger())
81
+ enabled.push('logging')
82
+ }
83
+ if (cfg.security.enabled) {
84
+ middlewares.push(security(cfg.security))
85
+ enabled.push('security')
86
+ }
87
+ if (cfg.cors.enabled) {
88
+ middlewares.push(cors(cfg.cors))
89
+ enabled.push('cors')
90
+ }
91
+ if (cfg.rateLimit.enabled) {
92
+ middlewares.push(rateLimit(cfg.rateLimit, (req) => clientKey(req, cfg.rateLimit.trustProxy, server?.())))
93
+ enabled.push(`rateLimit(${cfg.rateLimit.max}/${cfg.rateLimit.durationMs}ms)`)
94
+ }
95
+ if (cfg.compression.enabled) {
96
+ middlewares.push(compress(cfg.compression))
97
+ enabled.push('compression')
98
+ }
99
+ if (cfg.session.enabled) {
100
+ middlewares.push(session(cfg.session, createStore(cfg.redisUrl, 'session')))
101
+ enabled.push('session')
102
+ }
103
+ if (cfg.cache.enabled) {
104
+ middlewares.push(cache(cfg.cache, createStore(cfg.redisUrl, 'cache')))
105
+ enabled.push('cache')
106
+ }
107
+
108
+ const run = compose(middlewares)
109
+ const routes = wrapRoutes(discovered.routes, run, cfg.cors.enabled)
110
+ routes['/healthz'] = () => new Response('ok')
111
+
112
+ const koa = discovered.koa.length > 0 ? await createKoaDispatcher(discovered.koa, cfg.payloadLimit) : null
113
+ const serveStatic = makeStaticHandler(cfg.distPath, cfg.mode, cfg.spaDenyPrefixes)
114
+
115
+ return {
116
+ enabled,
117
+ routes,
118
+ fetch: async (req: Request, bunServer?: BunHttpServer) => {
119
+ const response = await run(req, async () => {
120
+ const fromKoa = koa ? await koa(req) : null
121
+ if (fromKoa) return fromKoa
122
+ return (await serveStatic(req)) ?? new Response('Not Found', { status: 404 })
123
+ })
124
+ return relaxIdleTimeout(req, response, bunServer)
125
+ },
126
+ error(err: unknown) {
127
+ console.error('flight-bun error:', err)
128
+ return new Response('Internal Server Error', { status: 500 })
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Listen with Bun. The peer address is visible to the rate limiter after the server exists.
135
+ */
136
+ export async function openFlight(
137
+ cfg: Config,
138
+ listen: { port?: number; hostname?: string } = {}
139
+ ): Promise<FlightServer> {
140
+ let server: FlightServer | undefined
141
+ const app = await createApp(cfg, () => server)
142
+ server = Bun.serve({
143
+ port: listen.port ?? cfg.port,
144
+ hostname: listen.hostname ?? cfg.hostname,
145
+ idleTimeout: cfg.idleTimeout,
146
+ routes: app.routes as Record<string, Handler | MethodMap>,
147
+ fetch: (req: Request, bunServer: BunHttpServer) => app.fetch(req, bunServer),
148
+ error: app.error
149
+ }) as FlightServer
150
+
151
+ const dist = cfg.mode === 'production' ? `, dist=${cfg.distPath}` : ''
152
+ console.log(
153
+ `flight-bun (${cfg.mode}) listening on :${server.port} — app_home=${cfg.appHome}${dist}\n` +
154
+ `flight-bun: middleware [${app.enabled.join(', ') || 'none'}]` +
155
+ `, idleTimeout=${cfg.idleTimeout}s` +
156
+ (cfg.redisUrl ? `, redis=${cfg.redisUrl}` : ', redis=(in-memory)')
157
+ )
158
+ return server
159
+ }