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