@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.
@@ -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
+ }
@@ -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
+ }