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