@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.
- package/CHANGELOG.md +58 -0
- package/LICENSE +202 -0
- package/NOTICE +1 -0
- package/README.md +226 -19
- package/dist/backend-discovery.js +61 -0
- package/dist/flight.js +8 -47
- package/dist/spa-pipeline.js +22 -4
- package/package.json +23 -11
- package/src/bun/app.test.ts +465 -0
- package/src/bun/app.ts +159 -0
- package/src/bun/backends.ts +93 -0
- package/src/bun/cache.ts +57 -0
- package/src/bun/config.ts +182 -0
- package/src/bun/koa-compat.ts +381 -0
- package/src/bun/middleware.ts +256 -0
- package/src/bun/server.ts +16 -0
- package/src/bun/session.ts +123 -0
- package/src/bun/static.ts +72 -0
- package/src/bun/store.ts +128 -0
- package/src/flight.ts +6 -47
- package/LICENSE.md +0 -19
|
@@ -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
|
+
}
|