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