@thoughtpivot/flight 2.0.2 → 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,166 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { Readable } from 'node:stream'
3
+
4
+ import type { KoaMiddleware } from './backends'
5
+
6
+ /** Node-style response stand-in that Koa's `res.end()` can finish. */
7
+ class MockResponse extends EventEmitter {
8
+ statusCode = 404
9
+ headersSent = false
10
+ finished = false
11
+ writableEnded = false
12
+ /** Left unset so `on-finished` does not treat a plain object as a socket. */
13
+ socket: EventEmitter | null = null
14
+ flightMatched = false
15
+ private chunks: Buffer[] = []
16
+ private headers = new Map<string, string | string[]>()
17
+
18
+ /** Store a response header. Repeated Set-Cookie values accumulate. */
19
+ setHeader(name: string, value: string | number | string[]) {
20
+ const key = name.toLowerCase()
21
+ if (key === 'set-cookie') {
22
+ const next = (Array.isArray(value) ? value : [value]).map(String)
23
+ const prev = this.headers.get(key)
24
+ const existing = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev]
25
+ this.headers.set(key, existing.concat(next))
26
+ return
27
+ }
28
+ this.headers.set(key, Array.isArray(value) ? value.map(String) : String(value))
29
+ }
30
+
31
+ /** Read a response header. */
32
+ getHeader(name: string): string | string[] | undefined {
33
+ return this.headers.get(name.toLowerCase())
34
+ }
35
+
36
+ /** Remove a response header. */
37
+ removeHeader(name: string) {
38
+ this.headers.delete(name.toLowerCase())
39
+ }
40
+
41
+ /** Record the status line. Koa calls this before `end` when headers are flushed. */
42
+ writeHead(status: number, headers?: Record<string, string | string[]>) {
43
+ this.statusCode = status
44
+ if (headers) {
45
+ for (const [key, value] of Object.entries(headers)) this.setHeader(key, value)
46
+ }
47
+ this.headersSent = true
48
+ return this
49
+ }
50
+
51
+ /** No-op. Koa sometimes flushes headers early. */
52
+ flushHeaders() {
53
+ this.headersSent = true
54
+ }
55
+
56
+ /** Buffer a chunk. */
57
+ write(chunk?: string | Buffer) {
58
+ if (chunk) this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
59
+ return true
60
+ }
61
+
62
+ /** Finish the response and emit the events `on-finished` listens for. */
63
+ end(chunk?: string | Buffer) {
64
+ if (chunk) this.write(chunk)
65
+ this.finished = true
66
+ this.writableEnded = true
67
+ this.headersSent = true
68
+ this.emit('finish')
69
+ this.emit('close')
70
+ return this
71
+ }
72
+
73
+ /** Build the Fetch response from the buffered status, headers, and body. */
74
+ toResponse(): Response {
75
+ const headers = new Headers()
76
+ for (const [key, value] of this.headers) {
77
+ if (Array.isArray(value)) {
78
+ for (const item of value) headers.append(key, item)
79
+ } else {
80
+ headers.set(key, value)
81
+ }
82
+ }
83
+ const body = this.chunks.length ? Buffer.concat(this.chunks) : null
84
+ return new Response(body, { status: this.statusCode, headers })
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Adapt a Fetch request into the readable Node request Koa and bodyparser expect.
90
+ */
91
+ function requestFromFetch(request: Request, body: Buffer): Readable {
92
+ const url = new URL(request.url)
93
+ const headers: Record<string, string> = {}
94
+ request.headers.forEach((value, key) => {
95
+ headers[key] = value
96
+ })
97
+ if (!headers['content-length']) headers['content-length'] = String(body.length)
98
+
99
+ const req = Readable.from(body.length ? [body] : []) as Readable & Record<string, unknown>
100
+ req.method = request.method
101
+ req.url = url.pathname + url.search
102
+ req.headers = headers
103
+ req.httpVersion = '1.1'
104
+ req.httpVersionMajor = 1
105
+ req.httpVersionMinor = 1
106
+ req.socket = { remoteAddress: '127.0.0.1', encrypted: false, readable: false, writable: true }
107
+ req.connection = req.socket
108
+ return req
109
+ }
110
+
111
+ /**
112
+ * Run one Fetch request through a Koa callback. Returns null when no route matched,
113
+ * so the caller can still serve the SPA.
114
+ */
115
+ function callbackToFetch(callback: (req: Readable, res: MockResponse) => void) {
116
+ return async function dispatch(request: Request): Promise<Response | null> {
117
+ const body =
118
+ request.method === 'GET' || request.method === 'HEAD'
119
+ ? Buffer.alloc(0)
120
+ : Buffer.from(await request.arrayBuffer())
121
+ const req = requestFromFetch(request, body)
122
+ const res = new MockResponse()
123
+
124
+ await new Promise<void>((resolve, reject) => {
125
+ res.on('finish', () => resolve())
126
+ res.on('error', reject)
127
+ try {
128
+ callback(req, res)
129
+ } catch (err) {
130
+ reject(err)
131
+ }
132
+ })
133
+
134
+ if (!res.flightMatched) return null
135
+ return res.toResponse()
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Build a Fetch dispatcher for existing Koa `router.routes()` backends.
141
+ * JSON bodies are parsed with `koa-bodyparser`. Unmatched paths return null.
142
+ */
143
+ export async function createKoaDispatcher(
144
+ middlewares: KoaMiddleware[],
145
+ payloadLimit: string
146
+ ): Promise<(req: Request) => Promise<Response | null>> {
147
+ const koaModule = await import('koa')
148
+ const bodyParserModule = await import('koa-bodyparser')
149
+ const Koa = koaModule.default
150
+ const bodyParser = bodyParserModule.default
151
+ const app = new Koa()
152
+
153
+ app.use(async (ctx: any, next: () => Promise<void>) => {
154
+ await next()
155
+ ctx.res.flightMatched = Array.isArray(ctx.matched) && ctx.matched.length > 0
156
+ })
157
+ app.use(
158
+ bodyParser({
159
+ jsonLimit: payloadLimit,
160
+ enableTypes: ['json', 'form', 'text']
161
+ })
162
+ )
163
+ for (const middleware of middlewares) app.use(middleware as any)
164
+
165
+ return callbackToFetch(app.callback())
166
+ }
@@ -0,0 +1,217 @@
1
+ import type { CompressionConfig, CorsConfig, RateLimitConfig, SecurityConfig } from './config'
2
+
3
+ /** Web-standard request handler used by discovered routes. */
4
+ export type Handler = (req: Request) => Response | Promise<Response>
5
+
6
+ /** Continues the onion to the next middleware or the core handler. */
7
+ export type Next = () => Promise<Response>
8
+
9
+ /** Koa-style middleware over the Fetch API. */
10
+ export type Middleware = (req: Request, next: Next) => Response | Promise<Response>
11
+
12
+ /** Minimal server surface used to read the peer address. */
13
+ export interface AddressServer {
14
+ requestIP(req: Request): { address: string } | null
15
+ }
16
+
17
+ /**
18
+ * Compose middleware so index 0 is outermost. A middleware may return without calling `next`.
19
+ */
20
+ export function compose(middlewares: Middleware[]): (req: Request, core: Handler) => Promise<Response> {
21
+ return function run(req: Request, core: Handler): Promise<Response> {
22
+ const dispatch = (index: number): Promise<Response> => {
23
+ const middleware = middlewares[index]
24
+ if (!middleware) return Promise.resolve(core(req))
25
+ return Promise.resolve(middleware(req, () => dispatch(index + 1)))
26
+ }
27
+ return dispatch(0)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Copy a response and set extra headers. Works for streaming bodies.
33
+ */
34
+ export function withHeaders(response: Response, extra: Record<string, string>): Response {
35
+ const headers = new Headers(response.headers)
36
+ for (const [key, value] of Object.entries(extra)) headers.set(key, value)
37
+ return new Response(response.body, {
38
+ status: response.status,
39
+ statusText: response.statusText,
40
+ headers
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Log method, path, status, and duration. Off in production unless `FLIGHT_LOGGING` is set.
46
+ */
47
+ export function logger(): Middleware {
48
+ return async (req, next) => {
49
+ const start = performance.now()
50
+ const response = await next()
51
+ const ms = (performance.now() - start).toFixed(1)
52
+ const { pathname } = new URL(req.url)
53
+ console.log(`${req.method} ${pathname} ${response.status} ${ms}ms`)
54
+ return response
55
+ }
56
+ }
57
+
58
+ const SECURITY_HEADERS: Record<string, string> = {
59
+ 'x-content-type-options': 'nosniff',
60
+ 'x-frame-options': 'SAMEORIGIN',
61
+ 'x-dns-prefetch-control': 'off',
62
+ 'referrer-policy': 'no-referrer',
63
+ 'x-download-options': 'noopen',
64
+ 'x-permitted-cross-domain-policies': 'none',
65
+ 'cross-origin-opener-policy': 'same-origin',
66
+ 'origin-agent-cluster': '?1'
67
+ }
68
+
69
+ /**
70
+ * Apply a helmet-style header set. CSP is omitted because a wrong policy breaks the SPA.
71
+ */
72
+ export function security(cfg: SecurityConfig): Middleware {
73
+ const headers = { ...SECURITY_HEADERS }
74
+ if (cfg.hsts) headers['strict-transport-security'] = `max-age=${cfg.hstsMaxAge}; includeSubDomains`
75
+ return async (_req, next) => withHeaders(await next(), headers)
76
+ }
77
+
78
+ /**
79
+ * Build the CORS header set for this request.
80
+ */
81
+ function corsHeaders(req: Request, cfg: CorsConfig): Record<string, string> {
82
+ const requestOrigin = req.headers.get('origin')
83
+ const allowOrigin = cfg.origin === '*' ? (cfg.credentials ? (requestOrigin ?? '*') : '*') : cfg.origin
84
+ const headers: Record<string, string> = {
85
+ 'access-control-allow-origin': allowOrigin,
86
+ 'access-control-allow-methods': cfg.methods,
87
+ 'access-control-allow-headers': cfg.headers || req.headers.get('access-control-request-headers') || '*',
88
+ 'access-control-max-age': String(cfg.maxAge)
89
+ }
90
+ if (cfg.credentials) headers['access-control-allow-credentials'] = 'true'
91
+ if (allowOrigin !== '*') headers.vary = 'Origin'
92
+ return headers
93
+ }
94
+
95
+ /**
96
+ * Answer CORS preflight and attach `Access-Control-*` headers to every other response.
97
+ */
98
+ export function cors(cfg: CorsConfig): Middleware {
99
+ return async (req, next) => {
100
+ if (req.method === 'OPTIONS') {
101
+ return new Response(null, { status: 204, headers: corsHeaders(req, cfg) })
102
+ }
103
+ return withHeaders(await next(), corsHeaders(req, cfg))
104
+ }
105
+ }
106
+
107
+ /**
108
+ * True when the content type is worth gzipping.
109
+ */
110
+ function compressible(contentType: string): boolean {
111
+ return /^(text\/|application\/(json|javascript|xml|.*\+json|.*\+xml)|image\/svg)/i.test(contentType)
112
+ }
113
+
114
+ /**
115
+ * Append a Vary token if it is not already present.
116
+ */
117
+ function appendVary(existing: string | null, value: string): string {
118
+ if (!existing) return value
119
+ const parts = existing.split(',').map((part) => part.trim().toLowerCase())
120
+ return parts.includes(value.toLowerCase()) ? existing : `${existing}, ${value}`
121
+ }
122
+
123
+ /**
124
+ * Gzip text and JSON bodies above the threshold when the client accepts gzip.
125
+ */
126
+ export function compress(cfg: CompressionConfig): Middleware {
127
+ return async (req, next) => {
128
+ const response = await next()
129
+ if (req.method === 'HEAD' || !response.body || response.status === 204 || response.status === 304) {
130
+ return response
131
+ }
132
+ if (response.headers.get('content-encoding')) return response
133
+ if (!(req.headers.get('accept-encoding') || '').includes('gzip')) return response
134
+ if (!compressible(response.headers.get('content-type') || '')) return response
135
+
136
+ const bytes = new Uint8Array(await response.arrayBuffer())
137
+ if (bytes.byteLength < cfg.threshold) {
138
+ return new Response(bytes, {
139
+ status: response.status,
140
+ statusText: response.statusText,
141
+ headers: response.headers
142
+ })
143
+ }
144
+
145
+ const gzipped = Bun.gzipSync(bytes)
146
+ const headers = new Headers(response.headers)
147
+ headers.set('content-encoding', 'gzip')
148
+ headers.set('content-length', String(gzipped.byteLength))
149
+ headers.set('vary', appendVary(headers.get('vary'), 'Accept-Encoding'))
150
+ return new Response(gzipped, {
151
+ status: response.status,
152
+ statusText: response.statusText,
153
+ headers
154
+ })
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Identity for the rate limiter.
160
+ * Forwarded headers count only when `FLIGHT_TRUST_PROXY` is on, so a client cannot spoof past the limit.
161
+ */
162
+ export function clientKey(req: Request, trustProxy: boolean, server?: AddressServer): string {
163
+ if (trustProxy) {
164
+ const forwarded = req.headers.get('x-forwarded-for')
165
+ if (forwarded) return forwarded.split(',')[0].trim()
166
+ const realIp = req.headers.get('x-real-ip')
167
+ if (realIp) return realIp
168
+ }
169
+ return server?.requestIP(req)?.address || 'local'
170
+ }
171
+
172
+ /**
173
+ * True when this GET or HEAD should not consume a rate-limit token.
174
+ */
175
+ function shouldSkip(pathname: string, method: string, prefixes: string[]): boolean {
176
+ if (method !== 'GET' && method !== 'HEAD') return false
177
+ return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
178
+ }
179
+
180
+ /**
181
+ * Fixed-window rate limit. Hashed static prefixes are skipped so assets do not burn the API budget.
182
+ */
183
+ export function rateLimit(cfg: RateLimitConfig, addressOf: (req: Request) => string): Middleware {
184
+ const windows = new Map<string, { count: number; resetAt: number }>()
185
+ return async (req, next) => {
186
+ const pathname = new URL(req.url).pathname
187
+ if (shouldSkip(pathname, req.method, cfg.skipPrefixes)) return next()
188
+
189
+ const now = Date.now()
190
+ const key = addressOf(req)
191
+ let window = windows.get(key)
192
+ if (!window || window.resetAt <= now) {
193
+ window = { count: 0, resetAt: now + cfg.durationMs }
194
+ windows.set(key, window)
195
+ if (windows.size > 10000) {
196
+ for (const [storedKey, stored] of windows) {
197
+ if (stored.resetAt <= now) windows.delete(storedKey)
198
+ }
199
+ }
200
+ }
201
+ window.count++
202
+ const remaining = Math.max(0, cfg.max - window.count)
203
+ const resetSec = Math.ceil((window.resetAt - now) / 1000)
204
+ const limitHeaders = {
205
+ 'x-ratelimit-limit': String(cfg.max),
206
+ 'x-ratelimit-remaining': String(remaining),
207
+ 'x-ratelimit-reset': String(resetSec)
208
+ }
209
+ if (window.count > cfg.max) {
210
+ return new Response('Too Many Requests', {
211
+ status: 429,
212
+ headers: { ...limitHeaders, 'retry-after': String(resetSec) }
213
+ })
214
+ }
215
+ return withHeaders(await next(), limitHeaders)
216
+ }
217
+ }
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bun
2
+ import { loadConfig } from './config'
3
+ import { openFlight } from './app'
4
+
5
+ const cfg = loadConfig()
6
+ await openFlight(cfg)
7
+
8
+ if (cfg.mode === 'development') {
9
+ Bun.spawn(['bunx', 'vite', '--port', '3001', '--host', '0.0.0.0'], {
10
+ cwd: cfg.appHome,
11
+ stdout: 'inherit',
12
+ stderr: 'inherit',
13
+ stdin: 'inherit'
14
+ })
15
+ console.log('flight-bun: spawned Vite dev server on :3001')
16
+ }
@@ -0,0 +1,120 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
2
+
3
+ import type { SessionConfig } from './config'
4
+ import type { Middleware } from './middleware'
5
+ import type { KVStore } from './store'
6
+
7
+ /** Request carrying the mutable session object. */
8
+ export interface SessionRequest extends Request {
9
+ session?: Record<string, unknown>
10
+ }
11
+
12
+ /**
13
+ * HMAC-sign a session id. The mac is base64url so it is cookie-safe.
14
+ */
15
+ function sign(value: string, secret: string): string {
16
+ const mac = createHmac('sha256', secret).update(value).digest('base64url')
17
+ return `${value}.${mac}`
18
+ }
19
+
20
+ /**
21
+ * Return the value when the HMAC matches. Reject tampered cookies.
22
+ */
23
+ function unsign(signed: string, secret: string): string | null {
24
+ const splitAt = signed.lastIndexOf('.')
25
+ if (splitAt < 0) return null
26
+ const value = signed.slice(0, splitAt)
27
+ const mac = signed.slice(splitAt + 1)
28
+ const expected = createHmac('sha256', secret).update(value).digest('base64url')
29
+ const actualBytes = Buffer.from(mac)
30
+ const expectedBytes = Buffer.from(expected)
31
+ if (actualBytes.length !== expectedBytes.length) return null
32
+ return timingSafeEqual(actualBytes, expectedBytes) ? value : null
33
+ }
34
+
35
+ /**
36
+ * Parse a Cookie header into a name/value map.
37
+ */
38
+ function parseCookies(header: string | null): Record<string, string> {
39
+ const cookies: Record<string, string> = {}
40
+ if (!header) return cookies
41
+ for (const part of header.split(';')) {
42
+ const eq = part.indexOf('=')
43
+ if (eq < 0) continue
44
+ const name = part.slice(0, eq).trim()
45
+ const value = part.slice(eq + 1).trim()
46
+ if (name) cookies[name] = decodeURIComponent(value)
47
+ }
48
+ return cookies
49
+ }
50
+
51
+ /**
52
+ * Serialize one Set-Cookie attribute string.
53
+ */
54
+ function serializeCookie(name: string, value: string, maxAgeSec: number): string {
55
+ return [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAgeSec}`].join(
56
+ '; '
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Append a Set-Cookie header without dropping ones already on the response.
62
+ */
63
+ function appendSetCookie(response: Response, cookie: string): Response {
64
+ const headers = new Headers(response.headers)
65
+ headers.append('set-cookie', cookie)
66
+ return new Response(response.body, {
67
+ status: response.status,
68
+ statusText: response.statusText,
69
+ headers
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Signed-cookie sessions backed by the KV store.
75
+ * The cookie is issued only after the session holds data, so anonymous requests stay cookie-free.
76
+ * There is no default secret: sessions stay off until `FLIGHT_SESSION_SECRET` is set.
77
+ */
78
+ export function session(cfg: SessionConfig, store: KVStore): Middleware {
79
+ return async (req, next) => {
80
+ const cookies = parseCookies(req.headers.get('cookie'))
81
+ const raw = cookies[cfg.cookie]
82
+ const sessionRequest = req as SessionRequest
83
+
84
+ let sid: string | null = null
85
+ let data: Record<string, unknown> = {}
86
+ if (raw) {
87
+ const verified = unsign(raw, cfg.secret)
88
+ if (verified) {
89
+ sid = verified
90
+ const stored = await store.get(`sess:${sid}`)
91
+ if (stored) {
92
+ try {
93
+ data = JSON.parse(stored) as Record<string, unknown>
94
+ } catch {
95
+ data = {}
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ const before = JSON.stringify(data)
102
+ sessionRequest.session = data
103
+ const response = await next()
104
+ const after = JSON.stringify(sessionRequest.session ?? {})
105
+ const isEmpty = after === '{}'
106
+
107
+ if (sid && isEmpty && before !== '{}') {
108
+ await store.del(`sess:${sid}`)
109
+ return appendSetCookie(response, serializeCookie(cfg.cookie, '', 0))
110
+ }
111
+ if (isEmpty) return response
112
+
113
+ if (!sid) sid = randomUUID()
114
+ await store.set(`sess:${sid}`, after, cfg.ttlSec)
115
+ if (!raw || unsign(raw, cfg.secret) !== sid) {
116
+ return appendSetCookie(response, serializeCookie(cfg.cookie, sign(sid, cfg.secret), cfg.ttlSec))
117
+ }
118
+ return response
119
+ }
120
+ }
@@ -0,0 +1,72 @@
1
+ import { join, resolve, sep } from 'node:path'
2
+
3
+ /**
4
+ * True when the last path segment looks like a file name (`app.js`, `secret.txt`).
5
+ */
6
+ function lastSegmentLooksLikeFile(pathname: string): boolean {
7
+ const base = pathname.slice(pathname.lastIndexOf('/') + 1)
8
+ return base.includes('.')
9
+ }
10
+
11
+ /**
12
+ * True when `pathname` is exactly `prefix` or a child of it.
13
+ */
14
+ function hasPrefix(pathname: string, prefix: string): boolean {
15
+ return pathname === prefix || (prefix !== '/' && pathname.startsWith(`${prefix}/`))
16
+ }
17
+
18
+ /**
19
+ * Resolve `pathname` under `root`. Returns null when the target escapes the root.
20
+ */
21
+ export function safeTarget(root: string, pathname: string): string | null {
22
+ const base = resolve(root)
23
+ const target = resolve(base, `.${pathname}`)
24
+ if (target !== base && !target.startsWith(base + sep)) return null
25
+ return target
26
+ }
27
+
28
+ /**
29
+ * Serve a built SPA from `distPath` with an `index.html` fallback for document navigations.
30
+ * Development mode returns null so Vite owns the UI. `/api`, `/health`, and `/healthz` never fall back.
31
+ */
32
+ export function makeStaticHandler(distPath: string, mode: string, denyPrefixes: string[]) {
33
+ const indexPath = join(distPath, 'index.html')
34
+ const root = resolve(distPath)
35
+
36
+ return async function serveStatic(req: Request): Promise<Response | null> {
37
+ if (mode !== 'production') return null
38
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
39
+
40
+ const url = new URL(req.url)
41
+ let pathname: string
42
+ try {
43
+ pathname = decodeURIComponent(url.pathname)
44
+ } catch {
45
+ return new Response('Bad Request', { status: 400 })
46
+ }
47
+
48
+ if (denyPrefixes.some((prefix) => hasPrefix(pathname, prefix))) return null
49
+
50
+ const target = safeTarget(root, pathname)
51
+ if (!target) return new Response('Forbidden', { status: 403 })
52
+
53
+ if (target !== root) {
54
+ const file = Bun.file(target)
55
+ if (await file.exists()) {
56
+ return new Response(req.method === 'HEAD' ? null : file)
57
+ }
58
+ }
59
+
60
+ const accept = req.headers.get('accept') || ''
61
+ const wantsHtml = !accept || accept.includes('text/html') || accept.includes('*/*')
62
+ if (!wantsHtml || lastSegmentLooksLikeFile(pathname)) return null
63
+
64
+ const index = Bun.file(indexPath)
65
+ if (await index.exists()) {
66
+ return new Response(req.method === 'HEAD' ? null : index, {
67
+ headers: { 'content-type': 'text/html; charset=utf-8' }
68
+ })
69
+ }
70
+ return null
71
+ }
72
+ }