@thoughtpivot/flight 2.0.1 → 3.0.0
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 +41 -0
- package/README.md +182 -18
- package/dist/backend-discovery.js +61 -0
- package/dist/flight.js +8 -47
- package/dist/spa-pipeline.js +22 -4
- package/package.json +20 -10
- package/src/bun/app.test.ts +191 -0
- package/src/bun/app.ts +140 -0
- package/src/bun/backends.ts +93 -0
- package/src/bun/cache.ts +56 -0
- package/src/bun/config.ts +163 -0
- package/src/bun/koa-compat.ts +166 -0
- package/src/bun/middleware.ts +217 -0
- package/src/bun/server.ts +16 -0
- package/src/bun/session.ts +120 -0
- package/src/bun/static.ts +72 -0
- package/src/bun/store.ts +128 -0
- package/src/flight.ts +6 -47
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { openFlight, type FlightServer } from './app'
|
|
7
|
+
import { loadConfig, type Config } from './config'
|
|
8
|
+
import { safeTarget } from './static'
|
|
9
|
+
|
|
10
|
+
const servers: FlightServer[] = []
|
|
11
|
+
const dirs: string[] = []
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Remove temp apps and stop servers started by this file.
|
|
15
|
+
*/
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
while (servers.length) servers.pop()?.stop(true)
|
|
18
|
+
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an app root with a Koa backend, a Bun route fragment, a compiled `.backend.js`, and a tiny SPA.
|
|
23
|
+
*/
|
|
24
|
+
async function makeApp(): Promise<string> {
|
|
25
|
+
const dir = await mkdtemp(join(tmpdir(), 'flight-bun-'))
|
|
26
|
+
dirs.push(dir)
|
|
27
|
+
await symlink(join(import.meta.dir, '../../node_modules'), join(dir, 'node_modules'), 'dir')
|
|
28
|
+
await mkdir(join(dir, 'dist/assets'), { recursive: true })
|
|
29
|
+
await writeFile(join(dir, 'dist/index.html'), '<!doctype html><title>flight-spa</title>', 'utf8')
|
|
30
|
+
await writeFile(join(dir, 'dist/assets/app.js'), 'export const x = "asset"', 'utf8')
|
|
31
|
+
await writeFile(
|
|
32
|
+
join(dir, 'hello.backend.ts'),
|
|
33
|
+
`import Router from '@koa/router'
|
|
34
|
+
const router = new Router()
|
|
35
|
+
router.get('/hello', async (ctx) => {
|
|
36
|
+
ctx.body = { message: 'Hello from Flight!' }
|
|
37
|
+
})
|
|
38
|
+
router.post('/api/koa-echo', async (ctx) => {
|
|
39
|
+
ctx.body = { received: ctx.request.body }
|
|
40
|
+
})
|
|
41
|
+
export default router.routes()
|
|
42
|
+
`
|
|
43
|
+
)
|
|
44
|
+
await writeFile(
|
|
45
|
+
join(dir, 'ping.backend.ts'),
|
|
46
|
+
`export default {
|
|
47
|
+
'/api/ping': { GET: () => Response.json({ ok: true }) },
|
|
48
|
+
'/api/login': {
|
|
49
|
+
POST: (req) => {
|
|
50
|
+
req.session.user = 'ada'
|
|
51
|
+
return Response.json({ ok: true })
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
'/api/me': { GET: (req) => Response.json({ user: req.session?.user ?? null }) },
|
|
55
|
+
'/api/cached': {
|
|
56
|
+
GET: () =>
|
|
57
|
+
new Response(JSON.stringify({ n: 1 }), {
|
|
58
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'max-age=30' }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
)
|
|
64
|
+
await writeFile(
|
|
65
|
+
join(dir, 'compiled.backend.js'),
|
|
66
|
+
`export default { '/api/from-js': { GET: () => Response.json({ from: 'js' }) } }
|
|
67
|
+
`
|
|
68
|
+
)
|
|
69
|
+
return dir
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Boot the Bun runtime against a throwaway app. Middleware that tests do not care about stays off.
|
|
74
|
+
*/
|
|
75
|
+
async function boot(env: Record<string, string>): Promise<{ server: FlightServer; url: string }> {
|
|
76
|
+
const appHome = env.FLIGHT_APP_HOME || (await makeApp())
|
|
77
|
+
const cfg: Config = loadConfig({
|
|
78
|
+
FLIGHT_MODE: 'production',
|
|
79
|
+
FLIGHT_LOGGING: 'false',
|
|
80
|
+
FLIGHT_CORS_ENABLED: 'false',
|
|
81
|
+
FLIGHT_SECURITY_ENABLED: 'false',
|
|
82
|
+
FLIGHT_COMPRESS_ENABLED: 'false',
|
|
83
|
+
FLIGHT_APP_HOME: appHome,
|
|
84
|
+
FLIGHT_DIST_PATH: join(appHome, 'dist'),
|
|
85
|
+
...env,
|
|
86
|
+
FLIGHT_APP_HOME: env.FLIGHT_APP_HOME || appHome
|
|
87
|
+
})
|
|
88
|
+
const server = await openFlight(cfg, { port: 0, hostname: '127.0.0.1' })
|
|
89
|
+
servers.push(server)
|
|
90
|
+
return { server, url: `http://127.0.0.1:${server.port}` }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('healthz, Bun routes, compiled backends, and Koa routes answer on one server', async () => {
|
|
94
|
+
const { url } = await boot({})
|
|
95
|
+
|
|
96
|
+
const health = await fetch(`${url}/healthz`)
|
|
97
|
+
expect(await health.text()).toBe('ok')
|
|
98
|
+
|
|
99
|
+
const ping = await fetch(`${url}/api/ping`, { headers: { accept: 'application/json' } })
|
|
100
|
+
expect(ping.status).toBe(200)
|
|
101
|
+
expect(await ping.json()).toEqual({ ok: true })
|
|
102
|
+
|
|
103
|
+
const fromJs = await fetch(`${url}/api/from-js`)
|
|
104
|
+
expect(await fromJs.json()).toEqual({ from: 'js' })
|
|
105
|
+
|
|
106
|
+
const hello = await fetch(`${url}/hello`)
|
|
107
|
+
expect(hello.status).toBe(200)
|
|
108
|
+
expect(await hello.json()).toEqual({ message: 'Hello from Flight!' })
|
|
109
|
+
|
|
110
|
+
const echo = await fetch(`${url}/api/koa-echo`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ name: 'ada' })
|
|
114
|
+
})
|
|
115
|
+
expect(echo.status).toBe(200)
|
|
116
|
+
expect(await echo.json()).toEqual({ received: { name: 'ada' } })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('production SPA serves hashed assets and falls back to index.html', async () => {
|
|
120
|
+
const { url } = await boot({})
|
|
121
|
+
|
|
122
|
+
const asset = await fetch(`${url}/assets/app.js`)
|
|
123
|
+
expect(asset.status).toBe(200)
|
|
124
|
+
expect(await asset.text()).toContain('export const x')
|
|
125
|
+
|
|
126
|
+
const deep = await fetch(`${url}/dashboard/deep`, { headers: { accept: 'text/html' } })
|
|
127
|
+
expect(deep.status).toBe(200)
|
|
128
|
+
expect(deep.headers.get('content-type')).toContain('text/html')
|
|
129
|
+
expect(await deep.text()).toContain('flight-spa')
|
|
130
|
+
|
|
131
|
+
const apiMiss = await fetch(`${url}/api/missing`, { headers: { accept: 'text/html' } })
|
|
132
|
+
expect(apiMiss.status).toBe(404)
|
|
133
|
+
expect(await apiMiss.text()).not.toContain('flight-spa')
|
|
134
|
+
|
|
135
|
+
const escaped = await fetch(`${url}/%2e%2e/secret.txt`)
|
|
136
|
+
expect(escaped.status).toBe(404)
|
|
137
|
+
expect(await escaped.text()).not.toContain('flight-spa')
|
|
138
|
+
expect(safeTarget('/tmp/dist', '/../secret.txt')).toBeNull()
|
|
139
|
+
expect(safeTarget('/tmp/dist', '/assets/app.js')).toBe('/tmp/dist/assets/app.js')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('rate limit uses the peer address unless the proxy is trusted', async () => {
|
|
143
|
+
const shared = {
|
|
144
|
+
FLIGHT_RATE_LIMIT_MAX: '1',
|
|
145
|
+
FLIGHT_RATE_LIMIT_DURATION_MS: '60000'
|
|
146
|
+
}
|
|
147
|
+
const locked = await boot(shared)
|
|
148
|
+
const first = await fetch(`${locked.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.5' } })
|
|
149
|
+
const spoofed = await fetch(`${locked.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.9' } })
|
|
150
|
+
expect(first.status).toBe(200)
|
|
151
|
+
expect(spoofed.status).toBe(429)
|
|
152
|
+
|
|
153
|
+
const trusted = await boot({ ...shared, FLIGHT_TRUST_PROXY: '1' })
|
|
154
|
+
const fromA = await fetch(`${trusted.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.5' } })
|
|
155
|
+
const fromB = await fetch(`${trusted.url}/api/ping`, { headers: { 'x-forwarded-for': '203.0.113.9' } })
|
|
156
|
+
expect(fromA.status).toBe(200)
|
|
157
|
+
expect(fromB.status).toBe(200)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('static assets do not consume the API rate limit', async () => {
|
|
161
|
+
const { url } = await boot({ FLIGHT_RATE_LIMIT_MAX: '1' })
|
|
162
|
+
expect((await fetch(`${url}/assets/app.js`)).status).toBe(200)
|
|
163
|
+
expect((await fetch(`${url}/assets/app.js`)).status).toBe(200)
|
|
164
|
+
expect((await fetch(`${url}/api/ping`)).status).toBe(200)
|
|
165
|
+
expect((await fetch(`${url}/api/ping`)).status).toBe(429)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('sessions round-trip through memory and through Redis', async () => {
|
|
169
|
+
const cases = [{}, { FLIGHT_REDIS_URL: 'redis://127.0.0.1:6379' }]
|
|
170
|
+
for (const extra of cases) {
|
|
171
|
+
const { url } = await boot({ FLIGHT_SESSION_SECRET: 'test-secret', ...extra })
|
|
172
|
+
const login = await fetch(`${url}/api/login`, { method: 'POST' })
|
|
173
|
+
expect(login.status).toBe(200)
|
|
174
|
+
const setCookie = login.headers.get('set-cookie')
|
|
175
|
+
expect(setCookie).toBeTruthy()
|
|
176
|
+
const me = await fetch(`${url}/api/me`, { headers: { cookie: setCookie!.split(';')[0] } })
|
|
177
|
+
expect(await me.json()).toEqual({ user: 'ada' })
|
|
178
|
+
const anon = await fetch(`${url}/api/me`)
|
|
179
|
+
expect(await anon.json()).toEqual({ user: null })
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('opt-in response cache serves the second GET from the store', async () => {
|
|
184
|
+
const { url } = await boot({ FLIGHT_CACHE_ENABLED: 'true' })
|
|
185
|
+
const miss = await fetch(`${url}/api/cached`, { headers: { accept: 'application/json' } })
|
|
186
|
+
expect(miss.headers.get('x-flight-cache')).toBe('MISS')
|
|
187
|
+
expect(await miss.json()).toEqual({ n: 1 })
|
|
188
|
+
const hit = await fetch(`${url}/api/cached`, { headers: { accept: 'application/json' } })
|
|
189
|
+
expect(hit.headers.get('x-flight-cache')).toBe('HIT')
|
|
190
|
+
expect(await hit.json()).toEqual({ n: 1 })
|
|
191
|
+
})
|
package/src/bun/app.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
logger,
|
|
11
|
+
rateLimit,
|
|
12
|
+
security,
|
|
13
|
+
type AddressServer,
|
|
14
|
+
type Handler,
|
|
15
|
+
type Middleware
|
|
16
|
+
} from './middleware'
|
|
17
|
+
import { session } from './session'
|
|
18
|
+
import { makeStaticHandler } from './static'
|
|
19
|
+
import { createStore } from './store'
|
|
20
|
+
|
|
21
|
+
/** Bun server handle returned by `openFlight`. */
|
|
22
|
+
export interface FlightServer extends AddressServer {
|
|
23
|
+
port: number
|
|
24
|
+
stop(closeActiveConnections?: boolean): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wrap discovered routes so every method runs through the middleware onion.
|
|
29
|
+
* OPTIONS is registered when CORS is on, otherwise Bun answers 405 before our middleware runs.
|
|
30
|
+
*/
|
|
31
|
+
function wrapRoutes(
|
|
32
|
+
routes: Record<string, Handler | MethodMap>,
|
|
33
|
+
run: (req: Request, core: Handler) => Promise<Response>,
|
|
34
|
+
corsEnabled: boolean
|
|
35
|
+
): Record<string, Handler | MethodMap> {
|
|
36
|
+
const wrapped: Record<string, Handler | MethodMap> = {}
|
|
37
|
+
for (const [path, value] of Object.entries(routes)) {
|
|
38
|
+
if (typeof value === 'function') {
|
|
39
|
+
const handler = value
|
|
40
|
+
wrapped[path] = (req: Request) => run(req, () => handler(req))
|
|
41
|
+
} else {
|
|
42
|
+
const methods: MethodMap = {}
|
|
43
|
+
for (const [method, handler] of Object.entries(value)) {
|
|
44
|
+
methods[method] = (req: Request) => run(req, () => handler(req))
|
|
45
|
+
}
|
|
46
|
+
if (corsEnabled && !methods.OPTIONS) {
|
|
47
|
+
methods.OPTIONS = (req: Request) => run(req, () => new Response(null, { status: 204 }))
|
|
48
|
+
}
|
|
49
|
+
wrapped[path] = methods
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return wrapped
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the Fetch application: Bun routes, Koa compatibility, and the SPA fallback.
|
|
57
|
+
*/
|
|
58
|
+
export async function createApp(cfg: Config, server?: () => AddressServer | undefined) {
|
|
59
|
+
const discovered = await discoverBackends(cfg.appHome, cfg.excludePaths)
|
|
60
|
+
const middlewares: Middleware[] = []
|
|
61
|
+
const enabled: string[] = []
|
|
62
|
+
|
|
63
|
+
if (cfg.logging) {
|
|
64
|
+
middlewares.push(logger())
|
|
65
|
+
enabled.push('logging')
|
|
66
|
+
}
|
|
67
|
+
if (cfg.security.enabled) {
|
|
68
|
+
middlewares.push(security(cfg.security))
|
|
69
|
+
enabled.push('security')
|
|
70
|
+
}
|
|
71
|
+
if (cfg.cors.enabled) {
|
|
72
|
+
middlewares.push(cors(cfg.cors))
|
|
73
|
+
enabled.push('cors')
|
|
74
|
+
}
|
|
75
|
+
if (cfg.rateLimit.enabled) {
|
|
76
|
+
middlewares.push(rateLimit(cfg.rateLimit, (req) => clientKey(req, cfg.rateLimit.trustProxy, server?.())))
|
|
77
|
+
enabled.push(`rateLimit(${cfg.rateLimit.max}/${cfg.rateLimit.durationMs}ms)`)
|
|
78
|
+
}
|
|
79
|
+
if (cfg.compression.enabled) {
|
|
80
|
+
middlewares.push(compress(cfg.compression))
|
|
81
|
+
enabled.push('compression')
|
|
82
|
+
}
|
|
83
|
+
if (cfg.session.enabled) {
|
|
84
|
+
middlewares.push(session(cfg.session, createStore(cfg.redisUrl, 'session')))
|
|
85
|
+
enabled.push('session')
|
|
86
|
+
}
|
|
87
|
+
if (cfg.cache.enabled) {
|
|
88
|
+
middlewares.push(cache(cfg.cache, createStore(cfg.redisUrl, 'cache')))
|
|
89
|
+
enabled.push('cache')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const run = compose(middlewares)
|
|
93
|
+
const routes = wrapRoutes(discovered.routes, run, cfg.cors.enabled)
|
|
94
|
+
routes['/healthz'] = () => new Response('ok')
|
|
95
|
+
|
|
96
|
+
const koa = discovered.koa.length > 0 ? await createKoaDispatcher(discovered.koa, cfg.payloadLimit) : null
|
|
97
|
+
const serveStatic = makeStaticHandler(cfg.distPath, cfg.mode, cfg.spaDenyPrefixes)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
enabled,
|
|
101
|
+
routes,
|
|
102
|
+
fetch: async (req: Request) => {
|
|
103
|
+
return run(req, async () => {
|
|
104
|
+
const fromKoa = koa ? await koa(req) : null
|
|
105
|
+
if (fromKoa) return fromKoa
|
|
106
|
+
return (await serveStatic(req)) ?? new Response('Not Found', { status: 404 })
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
error(err: unknown) {
|
|
110
|
+
console.error('flight-bun error:', err)
|
|
111
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Listen with Bun. The peer address is visible to the rate limiter after the server exists.
|
|
118
|
+
*/
|
|
119
|
+
export async function openFlight(
|
|
120
|
+
cfg: Config,
|
|
121
|
+
listen: { port?: number; hostname?: string } = {}
|
|
122
|
+
): Promise<FlightServer> {
|
|
123
|
+
let server: FlightServer | undefined
|
|
124
|
+
const app = await createApp(cfg, () => server)
|
|
125
|
+
server = Bun.serve({
|
|
126
|
+
port: listen.port ?? cfg.port,
|
|
127
|
+
hostname: listen.hostname ?? cfg.hostname,
|
|
128
|
+
routes: app.routes as Record<string, Handler | MethodMap>,
|
|
129
|
+
fetch: app.fetch,
|
|
130
|
+
error: app.error
|
|
131
|
+
}) as FlightServer
|
|
132
|
+
|
|
133
|
+
const dist = cfg.mode === 'production' ? `, dist=${cfg.distPath}` : ''
|
|
134
|
+
console.log(
|
|
135
|
+
`flight-bun (${cfg.mode}) listening on :${server.port} — app_home=${cfg.appHome}${dist}\n` +
|
|
136
|
+
`flight-bun: middleware [${app.enabled.join(', ') || 'none'}]` +
|
|
137
|
+
(cfg.redisUrl ? `, redis=${cfg.redisUrl}` : ', redis=(in-memory)')
|
|
138
|
+
)
|
|
139
|
+
return server
|
|
140
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { Handler } from './middleware'
|
|
4
|
+
|
|
5
|
+
/** Method name to handler, as exported by a Bun-native `*.backend.ts` file. */
|
|
6
|
+
export type MethodMap = Record<string, Handler>
|
|
7
|
+
|
|
8
|
+
/** Path to a handler or a method map. This is the Bun-native backend contract. */
|
|
9
|
+
export type RouteFragment = Record<string, Handler | MethodMap>
|
|
10
|
+
|
|
11
|
+
/** Koa middleware: `(ctx, next) => Promise`. Existing Flight backends export this from `router.routes()`. */
|
|
12
|
+
export type KoaMiddleware = (ctx: unknown, next: () => Promise<unknown>) => unknown
|
|
13
|
+
|
|
14
|
+
/** Everything discovery found under an app root. */
|
|
15
|
+
export interface DiscoveredBackends {
|
|
16
|
+
routes: Record<string, Handler | MethodMap>
|
|
17
|
+
koa: KoaMiddleware[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when every key is a URL path. That is the Bun route-fragment shape.
|
|
22
|
+
*/
|
|
23
|
+
function isRouteFragment(value: object): boolean {
|
|
24
|
+
const keys = Object.keys(value)
|
|
25
|
+
return keys.length > 0 && keys.every((key) => key.startsWith('/'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Discover `*.backend.ts` and `*.backend.js` under `appHome`.
|
|
30
|
+
* An object whose keys are paths is the Bun contract. A function, or an object with `.routes()`,
|
|
31
|
+
* is the existing Koa contract and is kept so current apps can boot on Bun without a rewrite.
|
|
32
|
+
*/
|
|
33
|
+
export async function discoverBackends(appHome: string, excludePaths: string[]): Promise<DiscoveredBackends> {
|
|
34
|
+
const routes: Record<string, Handler | MethodMap> = {}
|
|
35
|
+
const koa: KoaMiddleware[] = []
|
|
36
|
+
const excluded = new Set(excludePaths)
|
|
37
|
+
|
|
38
|
+
for (const pattern of ['**/*.backend.ts', '**/*.backend.js']) {
|
|
39
|
+
const glob = new Bun.Glob(pattern)
|
|
40
|
+
for await (const abs of glob.scan({ cwd: appHome, absolute: true, onlyFiles: true })) {
|
|
41
|
+
const rel = relative(appHome, abs)
|
|
42
|
+
if (excluded.has(rel.split(sep)[0])) continue
|
|
43
|
+
|
|
44
|
+
let mod: { default?: unknown }
|
|
45
|
+
try {
|
|
46
|
+
mod = await import(abs)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`flight-bun: failed to load ${rel}:`, err)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const exported = mod.default
|
|
53
|
+
if (!exported) {
|
|
54
|
+
console.warn(`flight-bun: ${rel} has no default export — skipping`)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof exported === 'function') {
|
|
59
|
+
koa.push(exported as KoaMiddleware)
|
|
60
|
+
console.log(`flight-bun: mounted Koa backend ${rel}`)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof exported === 'object') {
|
|
65
|
+
const record = exported as Record<string, unknown>
|
|
66
|
+
if (typeof record.routes === 'function' && !isRouteFragment(record)) {
|
|
67
|
+
koa.push(record.routes() as KoaMiddleware)
|
|
68
|
+
console.log(`flight-bun: mounted Koa router ${rel}`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (isRouteFragment(record)) {
|
|
72
|
+
for (const [path, methods] of Object.entries(record)) {
|
|
73
|
+
if (typeof methods === 'function') {
|
|
74
|
+
routes[path] = methods as Handler
|
|
75
|
+
} else if (methods && typeof methods === 'object') {
|
|
76
|
+
const existing = routes[path]
|
|
77
|
+
routes[path] = {
|
|
78
|
+
...(typeof existing === 'object' ? existing : {}),
|
|
79
|
+
...(methods as MethodMap)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
console.log(`flight-bun: mounted backend ${rel}`)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.warn(`flight-bun: ${rel} default export is not a route fragment or Koa middleware — skipping`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { routes, koa }
|
|
93
|
+
}
|
package/src/bun/cache.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CacheConfig } from './config'
|
|
2
|
+
import { withHeaders, type Middleware } from './middleware'
|
|
3
|
+
import type { KVStore } from './store'
|
|
4
|
+
|
|
5
|
+
/** Stored GET response. Only text and JSON bodies are kept. */
|
|
6
|
+
interface CachedEntry {
|
|
7
|
+
status: number
|
|
8
|
+
headers: [string, string][]
|
|
9
|
+
body: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cache GET responses that opt in with `Cache-Control: max-age`.
|
|
14
|
+
* Hits and misses are marked with `X-Flight-Cache`.
|
|
15
|
+
*/
|
|
16
|
+
export function cache(cfg: CacheConfig, store: KVStore): Middleware {
|
|
17
|
+
return async (req, next) => {
|
|
18
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next()
|
|
19
|
+
|
|
20
|
+
const url = new URL(req.url)
|
|
21
|
+
const key = `cash:${url.pathname}${url.search}`
|
|
22
|
+
const hit = await store.get(key)
|
|
23
|
+
if (hit) {
|
|
24
|
+
try {
|
|
25
|
+
const entry = JSON.parse(hit) as CachedEntry
|
|
26
|
+
const headers = new Headers(entry.headers)
|
|
27
|
+
headers.set('x-flight-cache', 'HIT')
|
|
28
|
+
return new Response(req.method === 'HEAD' ? null : entry.body, {
|
|
29
|
+
status: entry.status,
|
|
30
|
+
headers
|
|
31
|
+
})
|
|
32
|
+
} catch {
|
|
33
|
+
// A corrupt entry is regenerated below.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const response = await next()
|
|
38
|
+
const cacheControl = response.headers.get('cache-control') || ''
|
|
39
|
+
const match = /max-age=(\d+)/.exec(cacheControl)
|
|
40
|
+
if (response.status === 200 && match && req.method === 'GET') {
|
|
41
|
+
const ttl = Math.min(Number(match[1]), cfg.maxTtl)
|
|
42
|
+
const contentType = response.headers.get('content-type') || ''
|
|
43
|
+
if (ttl > 0 && /^(text\/|application\/(json|javascript)|application\/.*\+json)/i.test(contentType)) {
|
|
44
|
+
const body = await response.clone().text()
|
|
45
|
+
const entry: CachedEntry = {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: [...response.headers.entries()],
|
|
48
|
+
body
|
|
49
|
+
}
|
|
50
|
+
await store.set(key, JSON.stringify(entry), ttl)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return withHeaders(response, { 'x-flight-cache': 'MISS' })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** CORS settings for the Bun runtime. */
|
|
4
|
+
export interface CorsConfig {
|
|
5
|
+
enabled: boolean
|
|
6
|
+
origin: string
|
|
7
|
+
methods: string
|
|
8
|
+
headers: string
|
|
9
|
+
credentials: boolean
|
|
10
|
+
maxAge: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Security-header settings. Content-Security-Policy stays app-specific. */
|
|
14
|
+
export interface SecurityConfig {
|
|
15
|
+
enabled: boolean
|
|
16
|
+
hsts: boolean
|
|
17
|
+
hstsMaxAge: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Gzip settings. Bodies smaller than `threshold` are left unchanged. */
|
|
21
|
+
export interface CompressionConfig {
|
|
22
|
+
enabled: boolean
|
|
23
|
+
threshold: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Fixed-window rate limit. Off unless `max` is greater than zero. */
|
|
27
|
+
export interface RateLimitConfig {
|
|
28
|
+
enabled: boolean
|
|
29
|
+
max: number
|
|
30
|
+
durationMs: number
|
|
31
|
+
trustProxy: boolean
|
|
32
|
+
skipPrefixes: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Signed-cookie sessions. Disabled until a secret is set. */
|
|
36
|
+
export interface SessionConfig {
|
|
37
|
+
enabled: boolean
|
|
38
|
+
secret: string
|
|
39
|
+
cookie: string
|
|
40
|
+
ttlSec: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Opt-in response cache. Handlers opt in with `Cache-Control: max-age`. */
|
|
44
|
+
export interface CacheConfig {
|
|
45
|
+
enabled: boolean
|
|
46
|
+
maxTtl: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolved Flight Bun configuration. */
|
|
50
|
+
export interface Config {
|
|
51
|
+
mode: string
|
|
52
|
+
port: number
|
|
53
|
+
hostname: string
|
|
54
|
+
appHome: string
|
|
55
|
+
distPath: string
|
|
56
|
+
excludePaths: string[]
|
|
57
|
+
spaDenyPrefixes: string[]
|
|
58
|
+
payloadLimit: string
|
|
59
|
+
logging: boolean
|
|
60
|
+
redisUrl?: string
|
|
61
|
+
cors: CorsConfig
|
|
62
|
+
security: SecurityConfig
|
|
63
|
+
compression: CompressionConfig
|
|
64
|
+
rateLimit: RateLimitConfig
|
|
65
|
+
session: SessionConfig
|
|
66
|
+
cache: CacheConfig
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Env = Record<string, string | undefined>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a boolean environment flag. Empty or missing values use the default.
|
|
73
|
+
*/
|
|
74
|
+
function bool(value: string | undefined, fallback: boolean): boolean {
|
|
75
|
+
if (value === undefined || value === '') return fallback
|
|
76
|
+
return value === '1' || value === 'true' || value === 'yes'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a finite number. Empty or invalid values use the default.
|
|
81
|
+
*/
|
|
82
|
+
function num(value: string | undefined, fallback: number): number {
|
|
83
|
+
if (value === undefined || value === '') return fallback
|
|
84
|
+
const parsed = Number(value)
|
|
85
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a comma-separated prefix list and ensure each entry starts with `/`.
|
|
90
|
+
*/
|
|
91
|
+
function prefixes(value: string | undefined, fallback: string): string[] {
|
|
92
|
+
const raw = (value ?? fallback).trim()
|
|
93
|
+
if (!raw) return []
|
|
94
|
+
return raw
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((part) => part.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.map((part) => (part.startsWith('/') ? part : `/${part}`))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a config object from an environment map.
|
|
103
|
+
* Defaults keep sessions, caching, and rate limiting off so a load test is not throttled by surprise.
|
|
104
|
+
*/
|
|
105
|
+
export function loadConfig(env: Env = process.env): Config {
|
|
106
|
+
const mode = env.FLIGHT_MODE || 'production'
|
|
107
|
+
const appHome = resolve(env.FLIGHT_APP_HOME || '.')
|
|
108
|
+
const sessionSecret = env.FLIGHT_SESSION_SECRET || ''
|
|
109
|
+
const rateMax = num(env.FLIGHT_RATE_LIMIT_MAX, 0)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
mode,
|
|
113
|
+
port: num(env.FLIGHT_PORT, 3000),
|
|
114
|
+
hostname: env.FLIGHT_HOSTNAME || '0.0.0.0',
|
|
115
|
+
appHome,
|
|
116
|
+
distPath: env.FLIGHT_DIST_PATH ? resolve(env.FLIGHT_DIST_PATH) : join(appHome, 'dist'),
|
|
117
|
+
excludePaths: (env.FLIGHT_EXCLUDE_PATHS || 'node_modules,dist')
|
|
118
|
+
.split(',')
|
|
119
|
+
.map((part) => part.trim())
|
|
120
|
+
.filter(Boolean),
|
|
121
|
+
spaDenyPrefixes: prefixes(env.FLIGHT_SPA_DENY_PREFIXES, '/api,/health,/healthz'),
|
|
122
|
+
payloadLimit: env.FLIGHT_PAYLOAD_LIMIT || '1mb',
|
|
123
|
+
logging: bool(env.FLIGHT_LOGGING, mode === 'development'),
|
|
124
|
+
redisUrl: env.FLIGHT_REDIS_URL || undefined,
|
|
125
|
+
cors: {
|
|
126
|
+
enabled: bool(env.FLIGHT_CORS_ENABLED, true),
|
|
127
|
+
origin: env.FLIGHT_CORS_ORIGIN || '*',
|
|
128
|
+
methods: env.FLIGHT_CORS_METHODS || 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
129
|
+
headers: env.FLIGHT_CORS_HEADERS || '',
|
|
130
|
+
credentials: bool(env.FLIGHT_CORS_CREDENTIALS, false),
|
|
131
|
+
maxAge: num(env.FLIGHT_CORS_MAX_AGE, 86400)
|
|
132
|
+
},
|
|
133
|
+
security: {
|
|
134
|
+
enabled: bool(env.FLIGHT_SECURITY_ENABLED, true),
|
|
135
|
+
hsts: bool(env.FLIGHT_HSTS, false),
|
|
136
|
+
hstsMaxAge: num(env.FLIGHT_HSTS_MAX_AGE, 15552000)
|
|
137
|
+
},
|
|
138
|
+
compression: {
|
|
139
|
+
enabled: bool(env.FLIGHT_COMPRESS_ENABLED, true),
|
|
140
|
+
threshold: num(env.FLIGHT_COMPRESS_THRESHOLD, 1024)
|
|
141
|
+
},
|
|
142
|
+
rateLimit: {
|
|
143
|
+
enabled: !bool(env.FLIGHT_RATE_LIMIT_DISABLE, false) && rateMax > 0,
|
|
144
|
+
max: rateMax,
|
|
145
|
+
durationMs: num(env.FLIGHT_RATE_LIMIT_DURATION_MS, 60000),
|
|
146
|
+
trustProxy: bool(env.FLIGHT_TRUST_PROXY, false),
|
|
147
|
+
skipPrefixes: [
|
|
148
|
+
...prefixes(env.FLIGHT_STATIC_PREFIXES, '/assets,/fonts'),
|
|
149
|
+
...prefixes(env.FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES, '')
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
session: {
|
|
153
|
+
enabled: sessionSecret.length > 0,
|
|
154
|
+
secret: sessionSecret,
|
|
155
|
+
cookie: env.FLIGHT_SESSION_COOKIE || 'flight.sid',
|
|
156
|
+
ttlSec: num(env.FLIGHT_SESSION_TTL, 86400)
|
|
157
|
+
},
|
|
158
|
+
cache: {
|
|
159
|
+
enabled: bool(env.FLIGHT_CACHE_ENABLED, false),
|
|
160
|
+
maxTtl: num(env.FLIGHT_CACHE_MAX_TTL, 3600)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|