@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,381 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { Readable } from 'node:stream'
|
|
3
|
+
|
|
4
|
+
import type { KoaMiddleware } from './backends'
|
|
5
|
+
import { markStreaming } from './middleware'
|
|
6
|
+
|
|
7
|
+
type WriteCallback = (error?: Error | null) => void
|
|
8
|
+
|
|
9
|
+
/** Node-style response stand-in that Koa's `res.end()` can finish. */
|
|
10
|
+
class MockResponse extends EventEmitter {
|
|
11
|
+
statusCode = 404
|
|
12
|
+
headersSent = false
|
|
13
|
+
finished = false
|
|
14
|
+
writableEnded = false
|
|
15
|
+
/** Left unset so `on-finished` does not treat a plain object as a socket. */
|
|
16
|
+
socket: EventEmitter | null = null
|
|
17
|
+
flightMatched = false
|
|
18
|
+
/** Re-read whether any mounted router has matched. Runs before the dispatcher decides. */
|
|
19
|
+
refreshMatch: () => void = () => {}
|
|
20
|
+
private chunks: Buffer[] = []
|
|
21
|
+
private headers = new Map<string, string | string[]>()
|
|
22
|
+
/** True once `writeHead`, `write`, or `ctx.respond = false` starts a live body. */
|
|
23
|
+
private streaming = false
|
|
24
|
+
/** True while `end()` is copying its final chunk, so that write does not start a stream. */
|
|
25
|
+
private ending = false
|
|
26
|
+
private closeEmitted = false
|
|
27
|
+
private clientAborted = false
|
|
28
|
+
private controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
29
|
+
|
|
30
|
+
/** Store a response header. Repeated Set-Cookie values accumulate. */
|
|
31
|
+
setHeader(name: string, value: string | number | string[]) {
|
|
32
|
+
const key = name.toLowerCase()
|
|
33
|
+
if (key === 'set-cookie') {
|
|
34
|
+
const next = (Array.isArray(value) ? value : [value]).map(String)
|
|
35
|
+
const prev = this.headers.get(key)
|
|
36
|
+
const existing = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev]
|
|
37
|
+
this.headers.set(key, existing.concat(next))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
this.headers.set(key, Array.isArray(value) ? value.map(String) : String(value))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a response header. */
|
|
44
|
+
getHeader(name: string): string | string[] | undefined {
|
|
45
|
+
return this.headers.get(name.toLowerCase())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove a response header. */
|
|
49
|
+
removeHeader(name: string) {
|
|
50
|
+
this.headers.delete(name.toLowerCase())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Record the status line. Calling this begins a streamed body. */
|
|
54
|
+
writeHead(
|
|
55
|
+
status: number,
|
|
56
|
+
reason?: string | Record<string, string | number | string[]>,
|
|
57
|
+
maybeHeaders?: Record<string, string | number | string[]>
|
|
58
|
+
) {
|
|
59
|
+
this.statusCode = status
|
|
60
|
+
const headers = typeof reason === 'string' || reason === undefined ? maybeHeaders : reason
|
|
61
|
+
if (headers) {
|
|
62
|
+
for (const [key, value] of Object.entries(headers)) this.setHeader(key, value)
|
|
63
|
+
}
|
|
64
|
+
this.headersSent = true
|
|
65
|
+
if (!this.finished && !this.ending) this.startStream()
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Flush headers and begin a streamed body. */
|
|
70
|
+
flushHeaders() {
|
|
71
|
+
this.headersSent = true
|
|
72
|
+
if (!this.finished && !this.ending) this.startStream()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Begin a streamed body without waiting for `end()`.
|
|
77
|
+
* Headers set in the same turn are included; the Fetch response resolves on the next microtask.
|
|
78
|
+
*/
|
|
79
|
+
startStream() {
|
|
80
|
+
if (this.streaming || this.finished || this.writableEnded) return
|
|
81
|
+
this.streaming = true
|
|
82
|
+
this.refreshMatch()
|
|
83
|
+
queueMicrotask(() => {
|
|
84
|
+
this.emit('stream-open')
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Buffer a chunk, or enqueue it when a live stream is already open. */
|
|
89
|
+
write(chunk?: string | Buffer | Uint8Array, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback) {
|
|
90
|
+
if (this.writableEnded || this.clientAborted) return false
|
|
91
|
+
let cb: WriteCallback | undefined
|
|
92
|
+
let enc: BufferEncoding | undefined
|
|
93
|
+
if (typeof encoding === 'function') cb = encoding
|
|
94
|
+
else {
|
|
95
|
+
enc = encoding
|
|
96
|
+
if (typeof callback === 'function') cb = callback
|
|
97
|
+
}
|
|
98
|
+
if (chunk != null && chunk.length !== 0) {
|
|
99
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, enc) : Buffer.from(chunk)
|
|
100
|
+
this.enqueue(bytes)
|
|
101
|
+
}
|
|
102
|
+
if (!this.ending) this.startStream()
|
|
103
|
+
if (cb) queueMicrotask(() => cb!())
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Finish the response and emit the events `on-finished` listens for. */
|
|
108
|
+
end(chunk?: string | Buffer | Uint8Array, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback) {
|
|
109
|
+
if (this.writableEnded) return this
|
|
110
|
+
this.ending = true
|
|
111
|
+
if (chunk != null && chunk !== '') this.write(chunk, encoding, callback)
|
|
112
|
+
else if (typeof encoding === 'function') queueMicrotask(() => encoding())
|
|
113
|
+
else if (typeof callback === 'function') queueMicrotask(() => callback())
|
|
114
|
+
this.finished = true
|
|
115
|
+
this.writableEnded = true
|
|
116
|
+
this.headersSent = true
|
|
117
|
+
this.refreshMatch()
|
|
118
|
+
this.closeController()
|
|
119
|
+
this.emit('finish')
|
|
120
|
+
this.emitClose()
|
|
121
|
+
return this
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Client went away. `res.on('close')` handlers use this to drop heartbeats and subscribers. */
|
|
125
|
+
clientDisconnect() {
|
|
126
|
+
if (this.clientAborted) return
|
|
127
|
+
this.clientAborted = true
|
|
128
|
+
this.closeController()
|
|
129
|
+
this.emitClose()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build the Fetch response from the buffered status, headers, and body. */
|
|
133
|
+
toResponse(): Response {
|
|
134
|
+
if (this.streaming && !this.finished) return this.liveResponse()
|
|
135
|
+
const body = this.chunks.length ? Buffer.concat(this.chunks) : null
|
|
136
|
+
return new Response(body, { status: this.statusCode, headers: this.headerInit() })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Push bytes into the live controller, or hold them until that controller exists. */
|
|
140
|
+
private enqueue(bytes: Buffer) {
|
|
141
|
+
if (!this.controller) {
|
|
142
|
+
this.chunks.push(bytes)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
this.controller.enqueue(bytes)
|
|
147
|
+
} catch {
|
|
148
|
+
// The stream is already closed.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Close the live body if the consumer has already attached. */
|
|
153
|
+
private closeController() {
|
|
154
|
+
if (!this.controller) return
|
|
155
|
+
try {
|
|
156
|
+
this.controller.close()
|
|
157
|
+
} catch {
|
|
158
|
+
// Already closed or cancelled.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Emit `close` once, whether the route finished or the client aborted. */
|
|
163
|
+
private emitClose() {
|
|
164
|
+
if (this.closeEmitted) return
|
|
165
|
+
this.closeEmitted = true
|
|
166
|
+
this.emit('close')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Copy stored headers into a Fetch `Headers` object. */
|
|
170
|
+
private headerInit(): Headers {
|
|
171
|
+
const headers = new Headers()
|
|
172
|
+
for (const [key, value] of this.headers) {
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
for (const item of value) headers.append(key, item)
|
|
175
|
+
} else {
|
|
176
|
+
headers.set(key, value)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return headers
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Return a Fetch response whose body stays open until `end()` or the client disconnects.
|
|
184
|
+
* Chunks written before this runs are flushed first.
|
|
185
|
+
*/
|
|
186
|
+
private liveResponse(): Response {
|
|
187
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
188
|
+
start: (controller) => {
|
|
189
|
+
this.controller = controller
|
|
190
|
+
for (const chunk of this.chunks.splice(0)) controller.enqueue(chunk)
|
|
191
|
+
if (this.finished || this.clientAborted) this.closeController()
|
|
192
|
+
},
|
|
193
|
+
cancel: () => {
|
|
194
|
+
this.clientDisconnect()
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
markStreaming(stream)
|
|
198
|
+
return new Response(stream, { status: this.statusCode, headers: this.headerInit() })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Adapt a Fetch request into the readable Node request Koa and bodyparser expect.
|
|
204
|
+
*/
|
|
205
|
+
function requestFromFetch(request: Request, body: Buffer): Readable {
|
|
206
|
+
const url = new URL(request.url)
|
|
207
|
+
const headers: Record<string, string> = {}
|
|
208
|
+
request.headers.forEach((value, key) => {
|
|
209
|
+
headers[key] = value
|
|
210
|
+
})
|
|
211
|
+
if (!headers['content-length']) headers['content-length'] = String(body.length)
|
|
212
|
+
|
|
213
|
+
const req = Readable.from(body.length ? [body] : []) as Readable & Record<string, unknown>
|
|
214
|
+
req.method = request.method
|
|
215
|
+
req.url = url.pathname + url.search
|
|
216
|
+
req.headers = headers
|
|
217
|
+
req.httpVersion = '1.1'
|
|
218
|
+
req.httpVersionMajor = 1
|
|
219
|
+
req.httpVersionMinor = 1
|
|
220
|
+
req.socket = { remoteAddress: '127.0.0.1', encrypted: false, readable: false, writable: true }
|
|
221
|
+
req.connection = req.socket
|
|
222
|
+
return req
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Koa context fields the compat layer observes. */
|
|
226
|
+
interface KoaCtx {
|
|
227
|
+
matched?: unknown
|
|
228
|
+
/** Set by `@koa/router` when the path and the method both match. */
|
|
229
|
+
_matchedRoute?: unknown
|
|
230
|
+
respond?: unknown
|
|
231
|
+
res: MockResponse
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Arrays whose `push` already updates `flightMatched`. Later routers append instead of assigning. */
|
|
235
|
+
const observedMatches = new WeakSet<object>()
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run one Fetch request through a Koa callback. Returns null when no route matched,
|
|
239
|
+
* so the caller can still serve the SPA.
|
|
240
|
+
*/
|
|
241
|
+
function callbackToFetch(callback: (req: Readable, res: MockResponse) => void) {
|
|
242
|
+
return async function dispatch(request: Request): Promise<Response | null> {
|
|
243
|
+
const body =
|
|
244
|
+
request.method === 'GET' || request.method === 'HEAD'
|
|
245
|
+
? Buffer.alloc(0)
|
|
246
|
+
: Buffer.from(await request.arrayBuffer())
|
|
247
|
+
const req = requestFromFetch(request, body)
|
|
248
|
+
const res = new MockResponse()
|
|
249
|
+
|
|
250
|
+
const onAbort = () => res.clientDisconnect()
|
|
251
|
+
if (request.signal.aborted) onAbort()
|
|
252
|
+
else request.signal.addEventListener('abort', onAbort, { once: true })
|
|
253
|
+
res.once('finish', () => request.signal.removeEventListener('abort', onAbort))
|
|
254
|
+
|
|
255
|
+
await new Promise<void>((resolve, reject) => {
|
|
256
|
+
let settled = false
|
|
257
|
+
const finish = () => {
|
|
258
|
+
res.refreshMatch()
|
|
259
|
+
if (settled) return
|
|
260
|
+
settled = true
|
|
261
|
+
resolve()
|
|
262
|
+
}
|
|
263
|
+
res.on('finish', finish)
|
|
264
|
+
res.on('stream-open', finish)
|
|
265
|
+
res.on('error', (err) => {
|
|
266
|
+
if (settled) return
|
|
267
|
+
settled = true
|
|
268
|
+
reject(err)
|
|
269
|
+
})
|
|
270
|
+
try {
|
|
271
|
+
callback(req, res)
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (!settled) {
|
|
274
|
+
settled = true
|
|
275
|
+
reject(err)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
if (!res.flightMatched) return null
|
|
281
|
+
return res.toResponse()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* True when this request hit a route.
|
|
287
|
+
* `@koa/router` assigns `ctx.matched` for the first router only. Every later router
|
|
288
|
+
* `push`es onto that same array, and sets `_matchedRoute` when the method matches.
|
|
289
|
+
*/
|
|
290
|
+
function hasMatch(ctx: KoaCtx): boolean {
|
|
291
|
+
const matched = ctx.matched
|
|
292
|
+
if (Array.isArray(matched) && matched.length > 0) return true
|
|
293
|
+
return typeof ctx._matchedRoute === 'string' && ctx._matchedRoute.length > 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Notice routes appended with `push` after the first router assigned `ctx.matched`.
|
|
298
|
+
*/
|
|
299
|
+
function observePushes(ctx: KoaCtx, layers: unknown[]) {
|
|
300
|
+
if (observedMatches.has(layers)) return
|
|
301
|
+
observedMatches.add(layers)
|
|
302
|
+
const original = Array.prototype.push
|
|
303
|
+
layers.push = function (this: unknown[], ...items: unknown[]) {
|
|
304
|
+
const result = original.apply(this, items)
|
|
305
|
+
ctx.res.refreshMatch()
|
|
306
|
+
return result
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Keep `flightMatched` current for every router, including ones that only `push`.
|
|
312
|
+
* The dispatcher reads it again when a stream opens or the response finishes.
|
|
313
|
+
*/
|
|
314
|
+
function trackMatch(ctx: KoaCtx) {
|
|
315
|
+
ctx.res.refreshMatch = () => {
|
|
316
|
+
ctx.res.flightMatched = hasMatch(ctx)
|
|
317
|
+
}
|
|
318
|
+
let matched = ctx.matched
|
|
319
|
+
if (Array.isArray(matched)) observePushes(ctx, matched)
|
|
320
|
+
Object.defineProperty(ctx, 'matched', {
|
|
321
|
+
configurable: true,
|
|
322
|
+
enumerable: true,
|
|
323
|
+
get() {
|
|
324
|
+
return matched
|
|
325
|
+
},
|
|
326
|
+
set(value: unknown) {
|
|
327
|
+
matched = value
|
|
328
|
+
if (Array.isArray(value)) observePushes(ctx, value)
|
|
329
|
+
ctx.res.refreshMatch()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `ctx.respond = false` means the route owns the raw response and Koa must not wait for `end()`.
|
|
336
|
+
*/
|
|
337
|
+
function trackRespond(ctx: KoaCtx) {
|
|
338
|
+
let respond = ctx.respond
|
|
339
|
+
Object.defineProperty(ctx, 'respond', {
|
|
340
|
+
configurable: true,
|
|
341
|
+
enumerable: true,
|
|
342
|
+
get() {
|
|
343
|
+
return respond
|
|
344
|
+
},
|
|
345
|
+
set(value: unknown) {
|
|
346
|
+
respond = value
|
|
347
|
+
if (value === false) ctx.res.startStream()
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build a Fetch dispatcher for existing Koa `router.routes()` backends.
|
|
354
|
+
* JSON bodies are parsed with `koa-bodyparser`. Unmatched paths return null.
|
|
355
|
+
*/
|
|
356
|
+
export async function createKoaDispatcher(
|
|
357
|
+
middlewares: KoaMiddleware[],
|
|
358
|
+
payloadLimit: string
|
|
359
|
+
): Promise<(req: Request) => Promise<Response | null>> {
|
|
360
|
+
const koaModule = await import('koa')
|
|
361
|
+
const bodyParserModule = await import('koa-bodyparser')
|
|
362
|
+
const Koa = koaModule.default
|
|
363
|
+
const bodyParser = bodyParserModule.default
|
|
364
|
+
const app = new Koa()
|
|
365
|
+
|
|
366
|
+
app.use(async (ctx: KoaCtx, next: () => Promise<void>) => {
|
|
367
|
+
trackMatch(ctx)
|
|
368
|
+
trackRespond(ctx)
|
|
369
|
+
await next()
|
|
370
|
+
ctx.res.flightMatched = Array.isArray(ctx.matched) && ctx.matched.length > 0
|
|
371
|
+
})
|
|
372
|
+
app.use(
|
|
373
|
+
bodyParser({
|
|
374
|
+
jsonLimit: payloadLimit,
|
|
375
|
+
enableTypes: ['json', 'form', 'text']
|
|
376
|
+
})
|
|
377
|
+
)
|
|
378
|
+
for (const middleware of middlewares) app.use(middleware as any)
|
|
379
|
+
|
|
380
|
+
return callbackToFetch(app.callback())
|
|
381
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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 preserveStreaming(
|
|
38
|
+
response,
|
|
39
|
+
new Response(response.body, {
|
|
40
|
+
status: response.status,
|
|
41
|
+
statusText: response.statusText,
|
|
42
|
+
headers
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keep a live-stream mark when a middleware copies the body onto a new `Response`.
|
|
49
|
+
*/
|
|
50
|
+
export function preserveStreaming(source: Response, target: Response): Response {
|
|
51
|
+
if (source.body && target.body && streamingBodies.has(source.body)) streamingBodies.add(target.body)
|
|
52
|
+
return target
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Log method, path, status, and duration. Off in production unless `FLIGHT_LOGGING` is set.
|
|
57
|
+
*/
|
|
58
|
+
export function logger(): Middleware {
|
|
59
|
+
return async (req, next) => {
|
|
60
|
+
const start = performance.now()
|
|
61
|
+
const response = await next()
|
|
62
|
+
const ms = (performance.now() - start).toFixed(1)
|
|
63
|
+
const { pathname } = new URL(req.url)
|
|
64
|
+
console.log(`${req.method} ${pathname} ${response.status} ${ms}ms`)
|
|
65
|
+
return response
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SECURITY_HEADERS: Record<string, string> = {
|
|
70
|
+
'x-content-type-options': 'nosniff',
|
|
71
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
72
|
+
'x-dns-prefetch-control': 'off',
|
|
73
|
+
'referrer-policy': 'no-referrer',
|
|
74
|
+
'x-download-options': 'noopen',
|
|
75
|
+
'x-permitted-cross-domain-policies': 'none',
|
|
76
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
77
|
+
'origin-agent-cluster': '?1'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply a helmet-style header set. CSP is omitted because a wrong policy breaks the SPA.
|
|
82
|
+
*/
|
|
83
|
+
export function security(cfg: SecurityConfig): Middleware {
|
|
84
|
+
const headers = { ...SECURITY_HEADERS }
|
|
85
|
+
if (cfg.hsts) headers['strict-transport-security'] = `max-age=${cfg.hstsMaxAge}; includeSubDomains`
|
|
86
|
+
return async (_req, next) => withHeaders(await next(), headers)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the CORS header set for this request.
|
|
91
|
+
*/
|
|
92
|
+
function corsHeaders(req: Request, cfg: CorsConfig): Record<string, string> {
|
|
93
|
+
const requestOrigin = req.headers.get('origin')
|
|
94
|
+
const allowOrigin = cfg.origin === '*' ? (cfg.credentials ? (requestOrigin ?? '*') : '*') : cfg.origin
|
|
95
|
+
const headers: Record<string, string> = {
|
|
96
|
+
'access-control-allow-origin': allowOrigin,
|
|
97
|
+
'access-control-allow-methods': cfg.methods,
|
|
98
|
+
'access-control-allow-headers': cfg.headers || req.headers.get('access-control-request-headers') || '*',
|
|
99
|
+
'access-control-max-age': String(cfg.maxAge)
|
|
100
|
+
}
|
|
101
|
+
if (cfg.credentials) headers['access-control-allow-credentials'] = 'true'
|
|
102
|
+
if (allowOrigin !== '*') headers.vary = 'Origin'
|
|
103
|
+
return headers
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Answer CORS preflight and attach `Access-Control-*` headers to every other response.
|
|
108
|
+
*/
|
|
109
|
+
export function cors(cfg: CorsConfig): Middleware {
|
|
110
|
+
return async (req, next) => {
|
|
111
|
+
if (req.method === 'OPTIONS') {
|
|
112
|
+
return new Response(null, { status: 204, headers: corsHeaders(req, cfg) })
|
|
113
|
+
}
|
|
114
|
+
return withHeaders(await next(), corsHeaders(req, cfg))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Bodies that must not be read to completion. Keyed by the stream so header copies keep the mark. */
|
|
119
|
+
const streamingBodies = new WeakSet<ReadableStream<Uint8Array>>()
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remember a body that stays open until the producer ends it.
|
|
123
|
+
* Compression and the response cache must not buffer it.
|
|
124
|
+
*/
|
|
125
|
+
export function markStreaming(body: ReadableStream<Uint8Array>) {
|
|
126
|
+
streamingBodies.add(body)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True for `text/event-stream` and for any body marked as a live stream.
|
|
131
|
+
*/
|
|
132
|
+
export function isStreamingResponse(response: Response): boolean {
|
|
133
|
+
const type = response.headers.get('content-type') || ''
|
|
134
|
+
if (/text\/event-stream/i.test(type)) return true
|
|
135
|
+
return response.body != null && streamingBodies.has(response.body)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* True when this response is a server-sent event stream.
|
|
140
|
+
*/
|
|
141
|
+
export function isEventStream(response: Response): boolean {
|
|
142
|
+
return /text\/event-stream/i.test(response.headers.get('content-type') || '')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* True when the content type is worth gzipping.
|
|
147
|
+
*/
|
|
148
|
+
function compressible(contentType: string): boolean {
|
|
149
|
+
return /^(text\/|application\/(json|javascript|xml|.*\+json|.*\+xml)|image\/svg)/i.test(contentType)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Append a Vary token if it is not already present.
|
|
154
|
+
*/
|
|
155
|
+
function appendVary(existing: string | null, value: string): string {
|
|
156
|
+
if (!existing) return value
|
|
157
|
+
const parts = existing.split(',').map((part) => part.trim().toLowerCase())
|
|
158
|
+
return parts.includes(value.toLowerCase()) ? existing : `${existing}, ${value}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gzip text and JSON bodies above the threshold when the client accepts gzip.
|
|
163
|
+
*/
|
|
164
|
+
export function compress(cfg: CompressionConfig): Middleware {
|
|
165
|
+
return async (req, next) => {
|
|
166
|
+
const response = await next()
|
|
167
|
+
if (req.method === 'HEAD' || !response.body || response.status === 204 || response.status === 304) {
|
|
168
|
+
return response
|
|
169
|
+
}
|
|
170
|
+
if (response.headers.get('content-encoding')) return response
|
|
171
|
+
if (!(req.headers.get('accept-encoding') || '').includes('gzip')) return response
|
|
172
|
+
if (isStreamingResponse(response)) return response
|
|
173
|
+
if (!compressible(response.headers.get('content-type') || '')) return response
|
|
174
|
+
|
|
175
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
176
|
+
if (bytes.byteLength < cfg.threshold) {
|
|
177
|
+
return new Response(bytes, {
|
|
178
|
+
status: response.status,
|
|
179
|
+
statusText: response.statusText,
|
|
180
|
+
headers: response.headers
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const gzipped = Bun.gzipSync(bytes)
|
|
185
|
+
const headers = new Headers(response.headers)
|
|
186
|
+
headers.set('content-encoding', 'gzip')
|
|
187
|
+
headers.set('content-length', String(gzipped.byteLength))
|
|
188
|
+
headers.set('vary', appendVary(headers.get('vary'), 'Accept-Encoding'))
|
|
189
|
+
return new Response(gzipped, {
|
|
190
|
+
status: response.status,
|
|
191
|
+
statusText: response.statusText,
|
|
192
|
+
headers
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Identity for the rate limiter.
|
|
199
|
+
* Forwarded headers count only when `FLIGHT_TRUST_PROXY` is on, so a client cannot spoof past the limit.
|
|
200
|
+
*/
|
|
201
|
+
export function clientKey(req: Request, trustProxy: boolean, server?: AddressServer): string {
|
|
202
|
+
if (trustProxy) {
|
|
203
|
+
const forwarded = req.headers.get('x-forwarded-for')
|
|
204
|
+
if (forwarded) return forwarded.split(',')[0].trim()
|
|
205
|
+
const realIp = req.headers.get('x-real-ip')
|
|
206
|
+
if (realIp) return realIp
|
|
207
|
+
}
|
|
208
|
+
return server?.requestIP(req)?.address || 'local'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True when this GET or HEAD should not consume a rate-limit token.
|
|
213
|
+
*/
|
|
214
|
+
function shouldSkip(pathname: string, method: string, prefixes: string[]): boolean {
|
|
215
|
+
if (method !== 'GET' && method !== 'HEAD') return false
|
|
216
|
+
return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fixed-window rate limit. Hashed static prefixes are skipped so assets do not burn the API budget.
|
|
221
|
+
*/
|
|
222
|
+
export function rateLimit(cfg: RateLimitConfig, addressOf: (req: Request) => string): Middleware {
|
|
223
|
+
const windows = new Map<string, { count: number; resetAt: number }>()
|
|
224
|
+
return async (req, next) => {
|
|
225
|
+
const pathname = new URL(req.url).pathname
|
|
226
|
+
if (shouldSkip(pathname, req.method, cfg.skipPrefixes)) return next()
|
|
227
|
+
|
|
228
|
+
const now = Date.now()
|
|
229
|
+
const key = addressOf(req)
|
|
230
|
+
let window = windows.get(key)
|
|
231
|
+
if (!window || window.resetAt <= now) {
|
|
232
|
+
window = { count: 0, resetAt: now + cfg.durationMs }
|
|
233
|
+
windows.set(key, window)
|
|
234
|
+
if (windows.size > 10000) {
|
|
235
|
+
for (const [storedKey, stored] of windows) {
|
|
236
|
+
if (stored.resetAt <= now) windows.delete(storedKey)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
window.count++
|
|
241
|
+
const remaining = Math.max(0, cfg.max - window.count)
|
|
242
|
+
const resetSec = Math.ceil((window.resetAt - now) / 1000)
|
|
243
|
+
const limitHeaders = {
|
|
244
|
+
'x-ratelimit-limit': String(cfg.max),
|
|
245
|
+
'x-ratelimit-remaining': String(remaining),
|
|
246
|
+
'x-ratelimit-reset': String(resetSec)
|
|
247
|
+
}
|
|
248
|
+
if (window.count > cfg.max) {
|
|
249
|
+
return new Response('Too Many Requests', {
|
|
250
|
+
status: 429,
|
|
251
|
+
headers: { ...limitHeaders, 'retry-after': String(resetSec) }
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
return withHeaders(await next(), limitHeaders)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|