@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,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,57 @@
1
+ import type { CacheConfig } from './config'
2
+ import { isStreamingResponse, 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
+ if (isStreamingResponse(response)) return withHeaders(response, { 'x-flight-cache': 'MISS' })
39
+ const cacheControl = response.headers.get('cache-control') || ''
40
+ const match = /max-age=(\d+)/.exec(cacheControl)
41
+ if (response.status === 200 && match && req.method === 'GET') {
42
+ const ttl = Math.min(Number(match[1]), cfg.maxTtl)
43
+ const contentType = response.headers.get('content-type') || ''
44
+ if (ttl > 0 && /^(text\/|application\/(json|javascript)|application\/.*\+json)/i.test(contentType)) {
45
+ const body = await response.clone().text()
46
+ const entry: CachedEntry = {
47
+ status: 200,
48
+ headers: [...response.headers.entries()],
49
+ body
50
+ }
51
+ await store.set(key, JSON.stringify(entry), ttl)
52
+ }
53
+ }
54
+
55
+ return withHeaders(response, { 'x-flight-cache': 'MISS' })
56
+ }
57
+ }
@@ -0,0 +1,182 @@
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
+ /**
61
+ * Seconds `Bun.serve` waits on an idle socket.
62
+ * Explicit `0` disables it. Positive fractions round up to at least 1. Clamped to 255.
63
+ */
64
+ idleTimeout: number
65
+ redisUrl?: string
66
+ cors: CorsConfig
67
+ security: SecurityConfig
68
+ compression: CompressionConfig
69
+ rateLimit: RateLimitConfig
70
+ session: SessionConfig
71
+ cache: CacheConfig
72
+ }
73
+
74
+ type Env = Record<string, string | undefined>
75
+
76
+ /**
77
+ * Parse a boolean environment flag. Empty or missing values use the default.
78
+ */
79
+ function bool(value: string | undefined, fallback: boolean): boolean {
80
+ if (value === undefined || value === '') return fallback
81
+ return value === '1' || value === 'true' || value === 'yes'
82
+ }
83
+
84
+ /**
85
+ * Parse a finite number. Empty or invalid values use the default.
86
+ */
87
+ function num(value: string | undefined, fallback: number): number {
88
+ if (value === undefined || value === '') return fallback
89
+ const parsed = Number(value)
90
+ return Number.isFinite(parsed) ? parsed : fallback
91
+ }
92
+
93
+ /**
94
+ * Parse `FLIGHT_IDLE_TIMEOUT` seconds. Missing, blank, negative, and non-numeric values use 10.
95
+ * An explicit `0` disables the timeout. Positive fractions round up to at least 1.
96
+ * Anything above Bun's maximum of 255 is clamped.
97
+ */
98
+ function idleSeconds(value: string | undefined): number {
99
+ if (value === undefined || value.trim() === '') return 10
100
+ const parsed = Number(value)
101
+ if (!Number.isFinite(parsed) || parsed < 0) return 10
102
+ if (parsed === 0) return 0
103
+ return Math.min(255, Math.max(1, Math.ceil(parsed)))
104
+ }
105
+
106
+ /**
107
+ * Split a comma-separated prefix list and ensure each entry starts with `/`.
108
+ */
109
+ function prefixes(value: string | undefined, fallback: string): string[] {
110
+ const raw = (value ?? fallback).trim()
111
+ if (!raw) return []
112
+ return raw
113
+ .split(',')
114
+ .map((part) => part.trim())
115
+ .filter(Boolean)
116
+ .map((part) => (part.startsWith('/') ? part : `/${part}`))
117
+ }
118
+
119
+ /**
120
+ * Build a config object from an environment map.
121
+ * Defaults keep sessions, caching, and rate limiting off so a load test is not throttled by surprise.
122
+ */
123
+ export function loadConfig(env: Env = process.env): Config {
124
+ const mode = env.FLIGHT_MODE || 'production'
125
+ const appHome = resolve(env.FLIGHT_APP_HOME || '.')
126
+ const sessionSecret = env.FLIGHT_SESSION_SECRET || ''
127
+ const rateMax = num(env.FLIGHT_RATE_LIMIT_MAX, 0)
128
+
129
+ return {
130
+ mode,
131
+ port: num(env.FLIGHT_PORT, 3000),
132
+ hostname: env.FLIGHT_HOSTNAME || '0.0.0.0',
133
+ appHome,
134
+ distPath: env.FLIGHT_DIST_PATH ? resolve(env.FLIGHT_DIST_PATH) : join(appHome, 'dist'),
135
+ excludePaths: (env.FLIGHT_EXCLUDE_PATHS || 'node_modules,dist')
136
+ .split(',')
137
+ .map((part) => part.trim())
138
+ .filter(Boolean),
139
+ spaDenyPrefixes: prefixes(env.FLIGHT_SPA_DENY_PREFIXES, '/api,/health,/healthz'),
140
+ payloadLimit: env.FLIGHT_PAYLOAD_LIMIT || '1mb',
141
+ logging: bool(env.FLIGHT_LOGGING, mode === 'development'),
142
+ idleTimeout: idleSeconds(env.FLIGHT_IDLE_TIMEOUT),
143
+ redisUrl: env.FLIGHT_REDIS_URL || undefined,
144
+ cors: {
145
+ enabled: bool(env.FLIGHT_CORS_ENABLED, true),
146
+ origin: env.FLIGHT_CORS_ORIGIN || '*',
147
+ methods: env.FLIGHT_CORS_METHODS || 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
148
+ headers: env.FLIGHT_CORS_HEADERS || '',
149
+ credentials: bool(env.FLIGHT_CORS_CREDENTIALS, false),
150
+ maxAge: num(env.FLIGHT_CORS_MAX_AGE, 86400)
151
+ },
152
+ security: {
153
+ enabled: bool(env.FLIGHT_SECURITY_ENABLED, true),
154
+ hsts: bool(env.FLIGHT_HSTS, false),
155
+ hstsMaxAge: num(env.FLIGHT_HSTS_MAX_AGE, 15552000)
156
+ },
157
+ compression: {
158
+ enabled: bool(env.FLIGHT_COMPRESS_ENABLED, true),
159
+ threshold: num(env.FLIGHT_COMPRESS_THRESHOLD, 1024)
160
+ },
161
+ rateLimit: {
162
+ enabled: !bool(env.FLIGHT_RATE_LIMIT_DISABLE, false) && rateMax > 0,
163
+ max: rateMax,
164
+ durationMs: num(env.FLIGHT_RATE_LIMIT_DURATION_MS, 60000),
165
+ trustProxy: bool(env.FLIGHT_TRUST_PROXY, false),
166
+ skipPrefixes: [
167
+ ...prefixes(env.FLIGHT_STATIC_PREFIXES, '/assets,/fonts'),
168
+ ...prefixes(env.FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES, '')
169
+ ]
170
+ },
171
+ session: {
172
+ enabled: sessionSecret.length > 0,
173
+ secret: sessionSecret,
174
+ cookie: env.FLIGHT_SESSION_COOKIE || 'flight.sid',
175
+ ttlSec: num(env.FLIGHT_SESSION_TTL, 86400)
176
+ },
177
+ cache: {
178
+ enabled: bool(env.FLIGHT_CACHE_ENABLED, false),
179
+ maxTtl: num(env.FLIGHT_CACHE_MAX_TTL, 3600)
180
+ }
181
+ }
182
+ }