@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,123 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
2
+
3
+ import type { SessionConfig } from './config'
4
+ import { preserveStreaming, 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 preserveStreaming(
67
+ response,
68
+ new Response(response.body, {
69
+ status: response.status,
70
+ statusText: response.statusText,
71
+ headers
72
+ })
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Signed-cookie sessions backed by the KV store.
78
+ * The cookie is issued only after the session holds data, so anonymous requests stay cookie-free.
79
+ * There is no default secret: sessions stay off until `FLIGHT_SESSION_SECRET` is set.
80
+ */
81
+ export function session(cfg: SessionConfig, store: KVStore): Middleware {
82
+ return async (req, next) => {
83
+ const cookies = parseCookies(req.headers.get('cookie'))
84
+ const raw = cookies[cfg.cookie]
85
+ const sessionRequest = req as SessionRequest
86
+
87
+ let sid: string | null = null
88
+ let data: Record<string, unknown> = {}
89
+ if (raw) {
90
+ const verified = unsign(raw, cfg.secret)
91
+ if (verified) {
92
+ sid = verified
93
+ const stored = await store.get(`sess:${sid}`)
94
+ if (stored) {
95
+ try {
96
+ data = JSON.parse(stored) as Record<string, unknown>
97
+ } catch {
98
+ data = {}
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ const before = JSON.stringify(data)
105
+ sessionRequest.session = data
106
+ const response = await next()
107
+ const after = JSON.stringify(sessionRequest.session ?? {})
108
+ const isEmpty = after === '{}'
109
+
110
+ if (sid && isEmpty && before !== '{}') {
111
+ await store.del(`sess:${sid}`)
112
+ return appendSetCookie(response, serializeCookie(cfg.cookie, '', 0))
113
+ }
114
+ if (isEmpty) return response
115
+
116
+ if (!sid) sid = randomUUID()
117
+ await store.set(`sess:${sid}`, after, cfg.ttlSec)
118
+ if (!raw || unsign(raw, cfg.secret) !== sid) {
119
+ return appendSetCookie(response, serializeCookie(cfg.cookie, sign(sid, cfg.secret), cfg.ttlSec))
120
+ }
121
+ return response
122
+ }
123
+ }
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /** Key/value store shared by sessions and the response cache. */
2
+ export interface KVStore {
3
+ get(key: string): Promise<string | null>
4
+ set(key: string, value: string, ttlSec: number): Promise<void>
5
+ del(key: string): Promise<void>
6
+ }
7
+
8
+ /**
9
+ * In-process store used when Redis is unset or unreachable.
10
+ */
11
+ class MemoryStore implements KVStore {
12
+ private entries = new Map<string, { value: string; exp: number }>()
13
+
14
+ /** Read a value, dropping it when the TTL has passed. */
15
+ async get(key: string): Promise<string | null> {
16
+ const entry = this.entries.get(key)
17
+ if (!entry) return null
18
+ if (entry.exp && entry.exp < Date.now()) {
19
+ this.entries.delete(key)
20
+ return null
21
+ }
22
+ return entry.value
23
+ }
24
+
25
+ /** Store a value. A non-positive TTL means no expiry. */
26
+ async set(key: string, value: string, ttlSec: number): Promise<void> {
27
+ this.entries.set(key, { value, exp: ttlSec > 0 ? Date.now() + ttlSec * 1000 : 0 })
28
+ }
29
+
30
+ /** Remove a key. */
31
+ async del(key: string): Promise<void> {
32
+ this.entries.delete(key)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Bun's built-in Redis client. Typed loosely because the surface varies by Bun version.
38
+ */
39
+ class RedisStore implements KVStore {
40
+ constructor(private client: any) {}
41
+
42
+ /** Read a Redis string. */
43
+ async get(key: string): Promise<string | null> {
44
+ return await this.client.get(key)
45
+ }
46
+
47
+ /** Write a Redis string with an optional EX expiry. */
48
+ async set(key: string, value: string, ttlSec: number): Promise<void> {
49
+ if (ttlSec > 0) await this.client.send('SET', [key, value, 'EX', String(ttlSec)])
50
+ else await this.client.set(key, value)
51
+ }
52
+
53
+ /** Delete a Redis key. */
54
+ async del(key: string): Promise<void> {
55
+ await this.client.del(key)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Use Redis until a command fails, then stay on the in-memory store.
61
+ */
62
+ class FallbackStore implements KVStore {
63
+ private degraded = false
64
+
65
+ constructor(
66
+ private primary: KVStore,
67
+ private fallback: KVStore,
68
+ private label: string
69
+ ) {}
70
+
71
+ /** Log the first Redis failure and switch to memory. */
72
+ private trip(err: unknown) {
73
+ if (this.degraded) return
74
+ this.degraded = true
75
+ const message = err instanceof Error ? err.message : String(err)
76
+ console.warn(`flight-bun: ${this.label} store degraded to in-memory (Redis error): ${message}`)
77
+ }
78
+
79
+ /** Read from Redis, or from memory after a failure. */
80
+ async get(key: string): Promise<string | null> {
81
+ if (this.degraded) return this.fallback.get(key)
82
+ try {
83
+ return await this.primary.get(key)
84
+ } catch (err) {
85
+ this.trip(err)
86
+ return this.fallback.get(key)
87
+ }
88
+ }
89
+
90
+ /** Write to Redis, or to memory after a failure. */
91
+ async set(key: string, value: string, ttlSec: number): Promise<void> {
92
+ if (this.degraded) return this.fallback.set(key, value, ttlSec)
93
+ try {
94
+ await this.primary.set(key, value, ttlSec)
95
+ } catch (err) {
96
+ this.trip(err)
97
+ await this.fallback.set(key, value, ttlSec)
98
+ }
99
+ }
100
+
101
+ /** Delete from Redis, or from memory after a failure. */
102
+ async del(key: string): Promise<void> {
103
+ if (this.degraded) return this.fallback.del(key)
104
+ try {
105
+ await this.primary.del(key)
106
+ } catch (err) {
107
+ this.trip(err)
108
+ await this.fallback.del(key)
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Redis when `FLIGHT_REDIS_URL` is set, otherwise memory.
115
+ * A down Redis degrades the request instead of failing it.
116
+ */
117
+ export function createStore(redisUrl: string | undefined, label: string): KVStore {
118
+ const memory = new MemoryStore()
119
+ if (!redisUrl) return memory
120
+ try {
121
+ const client = new Bun.RedisClient(redisUrl)
122
+ return new FallbackStore(new RedisStore(client), memory, label)
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err)
125
+ console.warn(`flight-bun: ${label} store could not init Redis, using in-memory: ${message}`)
126
+ return memory
127
+ }
128
+ }
package/src/flight.ts CHANGED
@@ -21,6 +21,7 @@ import ratelimit from 'koa-ratelimit'
21
21
  import serve from 'koa-static'
22
22
  import session from 'koa-session'
23
23
 
24
+ import { backendDiscoveryIgnorePatterns, dedupeStrings, mergeExcludePaths } from './backend-discovery.js'
24
25
  import {
25
26
  applyTrustProxy,
26
27
  httpCacheEnabledInSpaPipeline,
@@ -45,45 +46,6 @@ interface FlightArgv {
45
46
  exclude_paths?: string[]
46
47
  }
47
48
 
48
- function normalizeExcludePaths(value: unknown): string[] {
49
- if (value == null || value === '') return []
50
- const parts = Array.isArray(value) ? value : [value]
51
- const out: string[] = []
52
- for (const p of parts) {
53
- const s = String(p).trim()
54
- if (!s) continue
55
- out.push(
56
- ...s
57
- .split(',')
58
- .map((x) => x.trim())
59
- .filter(Boolean)
60
- )
61
- }
62
- return out
63
- }
64
-
65
- function dedupeStrings(items: string[]): string[] {
66
- return [...new Set(items)]
67
- }
68
-
69
- /** Build fast-glob ignore globs for trees rooted under `appRootAbs`. */
70
- function backendDiscoveryIgnorePatterns(appRootAbs: string, excludeRelativeDirs: string[]): string[] {
71
- const patterns: string[] = []
72
- for (const raw of excludeRelativeDirs) {
73
- const trimmed = raw.trim()
74
- if (!trimmed) continue
75
- const resolved = path.resolve(appRootAbs, trimmed)
76
- const rel = path.relative(appRootAbs, resolved)
77
- const relPosix = rel.replace(/\\/g, '/')
78
- if (!relPosix || relPosix.startsWith('..') || path.isAbsolute(rel)) {
79
- console.warn(`Flight: exclude_paths entry skipped (outside app_home): ${trimmed}`)
80
- continue
81
- }
82
- patterns.push(`${relPosix}/**`)
83
- }
84
- return patterns
85
- }
86
-
87
49
  // tsx (and similar loaders) may expose require('yargs/yargs') as { default: factory } instead of factory.
88
50
  interface YargsInstance {
89
51
  option(key: string, opts: Record<string, unknown>): YargsInstance
@@ -102,7 +64,7 @@ const argv = yargsEntry(process.argv.slice(2))
102
64
  })
103
65
  .option('mode', {
104
66
  type: 'string',
105
- describe: 'development (Vite HMR) or production'
67
+ describe: 'development (Vite HMR for Vue/React per vite.config) or production'
106
68
  })
107
69
  .option('port', {
108
70
  type: 'number',
@@ -188,10 +150,7 @@ if (argv.disable_vite === undefined) {
188
150
  argv.disable_vite = Boolean(argv.disable_vite)
189
151
 
190
152
  const appHomePath = path.resolve(argv.app_home)
191
- const excludePathsConfigured = dedupeStrings([
192
- ...normalizeExcludePaths(argv.exclude_paths),
193
- ...normalizeExcludePaths(process.env.FLIGHT_EXCLUDE_PATHS)
194
- ])
153
+ const excludePathsConfigured = mergeExcludePaths(argv.exclude_paths, process.env.FLIGHT_EXCLUDE_PATHS)
195
154
  const backendDiscoveryIgnores = backendDiscoveryIgnorePatterns(appHomePath, excludePathsConfigured)
196
155
 
197
156
  process.chdir(appHomePath)
@@ -359,12 +318,12 @@ if (cluster.isPrimary) {
359
318
  })
360
319
 
361
320
  viteProcess.on('error', (error) => {
362
- console.error('Failed to start vite server:', error)
321
+ console.error('Failed to start Vite dev server:', error)
363
322
  })
364
323
 
365
324
  viteProcess.on('exit', (code) => {
366
325
  if (code !== 0) {
367
- console.error(`Vite server exited with code ${code}`)
326
+ console.error(`Vite dev server exited with code ${code}`)
368
327
  }
369
328
  })
370
329
 
@@ -373,6 +332,6 @@ if (cluster.isPrimary) {
373
332
  process.exit(0)
374
333
  })
375
334
 
376
- console.log(`Vite development server with hot module reload ${process.pid} started on 3001`)
335
+ console.log(`Vite dev server (HMR for Vue/React per your vite.config) for worker ${process.pid} on port 3001`)
377
336
  }
378
337
  }
package/LICENSE.md DELETED
@@ -1,19 +0,0 @@
1
- # MIT License
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
-
10
- The above copyright notice and this permission notice shall be included in all
11
- copies or substantial portions of the Software.
12
-
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
- SOFTWARE.