ingenium 0.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Sinatra-style top-level shorthand.
3
+ *
4
+ * Lets users skip the app object entirely:
5
+ *
6
+ * ```ts
7
+ * import { get, post, listen } from 'ingenium'
8
+ *
9
+ * get('/', () => 'hi')
10
+ * get('/users/:id', (ctx) => ({ id: ctx.params.id }))
11
+ * post('/echo', async (ctx) => ctx.body.json())
12
+ *
13
+ * await listen(3000)
14
+ * ```
15
+ *
16
+ * All exported verbs route to a lazy singleton `IngeniumApp` created on first
17
+ * call. The instance is retained for the lifetime of the process; tests can
18
+ * call `_resetDefaultApp()` to drop it (this throws in production).
19
+ */
20
+
21
+ import { IngeniumApp, type IngeniumErrorHandler } from '../app.ts'
22
+ import type { IngeniumHandler, IngeniumMiddleware } from '../middleware/types.ts'
23
+ import { Router } from '../router/router.ts'
24
+ import type { ListeningServer } from '../transport/types.ts'
25
+
26
+ let _defaultApp: IngeniumApp | null = null
27
+
28
+ /**
29
+ * Get the lazy default app. Created on first call, retained for the
30
+ * lifetime of the process (or until `_resetDefaultApp()` is invoked).
31
+ *
32
+ * The same instance is returned on every subsequent call, so all
33
+ * top-level verb functions and `listen()` operate on a single coherent
34
+ * registration journal.
35
+ */
36
+ export function defaultApp(): IngeniumApp {
37
+ if (!_defaultApp) _defaultApp = new IngeniumApp()
38
+ return _defaultApp
39
+ }
40
+
41
+ /**
42
+ * Reset the default app — for tests only. The next call to any top-level
43
+ * function will lazily create a fresh `IngeniumApp`. Throws when
44
+ * `NODE_ENV === 'production'` so accidental production calls are loud.
45
+ */
46
+ export function _resetDefaultApp(): void {
47
+ if (process.env.NODE_ENV === 'production') {
48
+ throw new Error('_resetDefaultApp is a test-only API')
49
+ }
50
+ _defaultApp = null
51
+ }
52
+
53
+ // ───── HTTP verb shorthand ──────────────────────────────────────────────────
54
+ //
55
+ // Signatures mirror `IngeniumApp.get/post/...` exactly (`(path, handler)`),
56
+ // so the typed-ctx story (e.g. `IngeniumHandler<{ id: string }>`) is preserved
57
+ // for users who import these as drop-in replacements for `app.get(...)`.
58
+
59
+ export function get(path: string, handler: IngeniumHandler): IngeniumApp {
60
+ return defaultApp().get(path, handler)
61
+ }
62
+
63
+ export function post(path: string, handler: IngeniumHandler): IngeniumApp {
64
+ return defaultApp().post(path, handler)
65
+ }
66
+
67
+ export function put(path: string, handler: IngeniumHandler): IngeniumApp {
68
+ return defaultApp().put(path, handler)
69
+ }
70
+
71
+ export function patch(path: string, handler: IngeniumHandler): IngeniumApp {
72
+ return defaultApp().patch(path, handler)
73
+ }
74
+
75
+ /**
76
+ * Default-app shorthand for `app.delete(path, handler)`.
77
+ * Exported as `del` because `delete` is a reserved word in JavaScript and
78
+ * cannot be used as a top-level identifier. `index.ts` re-exports this as
79
+ * `{ del as delete }` so the public name is `delete`.
80
+ */
81
+ export function del(path: string, handler: IngeniumHandler): IngeniumApp {
82
+ return defaultApp().delete(path, handler)
83
+ }
84
+
85
+ export function head(path: string, handler: IngeniumHandler): IngeniumApp {
86
+ return defaultApp().head(path, handler)
87
+ }
88
+
89
+ export function options(path: string, handler: IngeniumHandler): IngeniumApp {
90
+ return defaultApp().options(path, handler)
91
+ }
92
+
93
+ // ───── use / onError / listen ───────────────────────────────────────────────
94
+
95
+ /**
96
+ * Mount middleware on the default app. Same overload set as `app.use`:
97
+ * - `use(mw)` — global
98
+ * - `use(prefix, mw | Router)` — prefix-scoped
99
+ */
100
+ export function use(mw: IngeniumMiddleware): IngeniumApp
101
+ export function use(prefix: string, mw: IngeniumMiddleware | Router): IngeniumApp
102
+ export function use(
103
+ arg1: string | IngeniumMiddleware,
104
+ arg2?: IngeniumMiddleware | Router,
105
+ ): IngeniumApp {
106
+ const app = defaultApp()
107
+ if (typeof arg1 === 'string') {
108
+ return app.use(arg1, arg2 as IngeniumMiddleware | Router)
109
+ }
110
+ return app.use(arg1)
111
+ }
112
+
113
+ /** Default-app shorthand for `app.onError(handler)`. */
114
+ export function onError(handler: IngeniumErrorHandler): IngeniumApp {
115
+ return defaultApp().onError(handler)
116
+ }
117
+
118
+ /**
119
+ * Bind the default app to a port. Returns a `ListeningServer` whose
120
+ * `.close()` shuts down the underlying transport. Pass `0` for an
121
+ * ephemeral port (useful in tests).
122
+ */
123
+ export function listen(port: number, host?: string): Promise<ListeningServer> {
124
+ return host !== undefined ? defaultApp().listen(port, host) : defaultApp().listen(port)
125
+ }
126
+
127
+ // ───── Sinatra-style filter shorthand ───────────────────────────────────────
128
+ //
129
+ // Mirrors `IngeniumApp.before/after` overloads exactly.
130
+
131
+ export function before(handler: IngeniumMiddleware): IngeniumApp
132
+ export function before(pattern: string, handler: IngeniumMiddleware): IngeniumApp
133
+ export function before(
134
+ arg1: string | IngeniumMiddleware,
135
+ arg2?: IngeniumMiddleware,
136
+ ): IngeniumApp {
137
+ const app = defaultApp()
138
+ if (typeof arg1 === 'string') return app.before(arg1, arg2 as IngeniumMiddleware)
139
+ return app.before(arg1)
140
+ }
141
+
142
+ export function after(handler: IngeniumMiddleware): IngeniumApp
143
+ export function after(pattern: string, handler: IngeniumMiddleware): IngeniumApp
144
+ export function after(
145
+ arg1: string | IngeniumMiddleware,
146
+ arg2?: IngeniumMiddleware,
147
+ ): IngeniumApp {
148
+ const app = defaultApp()
149
+ if (typeof arg1 === 'string') return app.after(arg1, arg2 as IngeniumMiddleware)
150
+ return app.after(arg1)
151
+ }
@@ -0,0 +1,52 @@
1
+ import type { SseStream } from './sse.ts'
2
+
3
+ /**
4
+ * Send a `:keepalive` comment to the given SSE stream every `intervalMs`
5
+ * milliseconds. Returns a cancellation function.
6
+ *
7
+ * The interval is automatically cancelled when the stream closes — but
8
+ * callers should still hold onto the cancel function for explicit cleanup
9
+ * (e.g. on a separate teardown signal).
10
+ *
11
+ * The internal timer is `unref()`'d so it won't keep the Node event loop
12
+ * alive on its own.
13
+ *
14
+ * @example
15
+ * const stream = sse(ctx)
16
+ * const cancel = startKeepAlive(stream, 15_000)
17
+ * ctx.req.on('close', cancel) // optional
18
+ */
19
+ export function startKeepAlive(
20
+ stream: SseStream,
21
+ intervalMs = 15_000,
22
+ ): () => void {
23
+ let cancelled = false
24
+
25
+ const timer = setInterval(() => {
26
+ if (cancelled || stream.closed) {
27
+ clearInterval(timer)
28
+ return
29
+ }
30
+ stream.comment('keepalive')
31
+ }, intervalMs)
32
+
33
+ if (typeof timer.unref === 'function') timer.unref()
34
+
35
+ // Best-effort: clear the interval as soon as we observe the stream closing.
36
+ // The interval also self-clears via the closed-check above, but this
37
+ // shortens the window before the next tick fires.
38
+ const watcher = setInterval(() => {
39
+ if (stream.closed) {
40
+ clearInterval(timer)
41
+ clearInterval(watcher)
42
+ }
43
+ }, Math.max(50, Math.min(intervalMs, 1000)))
44
+ if (typeof watcher.unref === 'function') watcher.unref()
45
+
46
+ return () => {
47
+ if (cancelled) return
48
+ cancelled = true
49
+ clearInterval(timer)
50
+ clearInterval(watcher)
51
+ }
52
+ }
package/src/sse/sse.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { PassThrough } from 'node:stream'
2
+ import type { IngeniumContext } from '../context/context.ts'
3
+
4
+ /**
5
+ * A single Server-Sent Event. The `data` field is required; if you pass an
6
+ * object, it's `JSON.stringify`'d before being written. All other fields are
7
+ * optional and serialized per the EventSource specification.
8
+ *
9
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html
10
+ */
11
+ export interface SseEvent {
12
+ /** Payload. Strings are written verbatim; objects are JSON-encoded. */
13
+ data: string | object
14
+ /** Optional event name — populates `event:` field. */
15
+ event?: string
16
+ /** Optional event id — populates `id:` field. */
17
+ id?: string
18
+ /** Optional retry hint in milliseconds — populates `retry:` field. */
19
+ retry?: number
20
+ }
21
+
22
+ /**
23
+ * Handle for an open SSE connection. Returned by {@link sse}. Use `send()`
24
+ * to push events, `comment()` for keep-alive frames, and `close()` to end
25
+ * the response stream cleanly.
26
+ */
27
+ export interface SseStream {
28
+ /**
29
+ * Send a single event. A bare string is treated as `{ data: <string> }`.
30
+ */
31
+ send(event: SseEvent | string): void
32
+ /** Write a comment line (`: <text>`). Useful for heartbeats / keep-alive. */
33
+ comment(text: string): void
34
+ /** End the response stream. Subsequent calls are no-ops. */
35
+ close(): void
36
+ /** Whether the underlying stream has been closed (locally or by the client). */
37
+ readonly closed: boolean
38
+ }
39
+
40
+ /**
41
+ * Open a Server-Sent Events response on the given context. Sets the
42
+ * appropriate headers (`Content-Type: text/event-stream`, no caching, no
43
+ * proxy buffering) and wires a `PassThrough` into `ctx.stream()`.
44
+ *
45
+ * @example
46
+ * app.get('/events', (ctx) => {
47
+ * const stream = sse(ctx)
48
+ * stream.send({ event: 'hello', data: { msg: 'world' } })
49
+ * setTimeout(() => stream.close(), 1000)
50
+ * })
51
+ */
52
+ export function sse(ctx: IngeniumContext): SseStream {
53
+ const passthrough = new PassThrough()
54
+
55
+ // SSE headers — set BEFORE ctx.stream() so the adapter can flush them.
56
+ ctx.set('cache-control', 'no-cache')
57
+ ctx.set('connection', 'keep-alive')
58
+ // Disable proxy buffering (nginx-specific but harmless elsewhere).
59
+ ctx.set('x-accel-buffering', 'no')
60
+
61
+ ctx.stream(passthrough, 'text/event-stream; charset=utf-8')
62
+
63
+ let closed = false
64
+ passthrough.on('close', () => {
65
+ closed = true
66
+ })
67
+
68
+ function write(chunk: string): void {
69
+ if (closed) return
70
+ if (!passthrough.writable) {
71
+ closed = true
72
+ return
73
+ }
74
+ passthrough.write(chunk)
75
+ }
76
+
77
+ return {
78
+ get closed(): boolean {
79
+ return closed
80
+ },
81
+
82
+ send(eventOrString: SseEvent | string): void {
83
+ if (closed) return
84
+ const evt: SseEvent =
85
+ typeof eventOrString === 'string' ? { data: eventOrString } : eventOrString
86
+
87
+ let frame = ''
88
+ if (evt.event !== undefined) frame += `event: ${evt.event}\n`
89
+ if (evt.id !== undefined) frame += `id: ${evt.id}\n`
90
+ if (evt.retry !== undefined) frame += `retry: ${evt.retry}\n`
91
+
92
+ const dataStr =
93
+ typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data)
94
+ // Spec: split on newlines, emit one `data:` line per chunk.
95
+ const lines = dataStr.split('\n')
96
+ for (const line of lines) {
97
+ frame += `data: ${line}\n`
98
+ }
99
+ frame += '\n'
100
+ write(frame)
101
+ },
102
+
103
+ comment(text: string): void {
104
+ if (closed) return
105
+ // Comment lines start with ':'. Use \n\n terminator to flush.
106
+ write(`: ${text}\n\n`)
107
+ },
108
+
109
+ close(): void {
110
+ if (closed) return
111
+ closed = true
112
+ passthrough.end()
113
+ },
114
+ }
115
+ }
@@ -0,0 +1,254 @@
1
+ import { createReadStream } from 'node:fs'
2
+ import { stat } from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import type { Stats } from 'node:fs'
5
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
6
+ import type { StaticOptions } from './types.ts'
7
+
8
+ /**
9
+ * Minimal MIME table — extension (lowercase, without dot) → content-type.
10
+ * Unknown extensions fall back to `application/octet-stream`.
11
+ */
12
+ const MIME_TYPES: Readonly<Record<string, string>> = {
13
+ html: 'text/html; charset=utf-8',
14
+ css: 'text/css; charset=utf-8',
15
+ js: 'application/javascript; charset=utf-8',
16
+ mjs: 'application/javascript; charset=utf-8',
17
+ json: 'application/json; charset=utf-8',
18
+ svg: 'image/svg+xml',
19
+ png: 'image/png',
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ gif: 'image/gif',
23
+ webp: 'image/webp',
24
+ ico: 'image/x-icon',
25
+ txt: 'text/plain; charset=utf-8',
26
+ woff: 'font/woff',
27
+ woff2: 'font/woff2',
28
+ }
29
+
30
+ const DEFAULT_INDEX = 'index.html'
31
+
32
+ function mimeFor(file: string): string {
33
+ const ext = path.extname(file).slice(1).toLowerCase()
34
+ return MIME_TYPES[ext] ?? 'application/octet-stream'
35
+ }
36
+
37
+ function makeEtag(stats: Stats): string {
38
+ // Express-style weak etag: W/"<size>-<mtimeMs-as-hex>"
39
+ return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`
40
+ }
41
+
42
+ /**
43
+ * Parse a `Range: bytes=N-M` header against a known total size.
44
+ * Returns `{ start, end }` (inclusive), `'invalid'` if the header is malformed
45
+ * or unsatisfiable (caller should respond 416), or `null` if no/multi range
46
+ * (caller should serve the full body).
47
+ */
48
+ function parseRange(
49
+ header: string | undefined,
50
+ size: number,
51
+ ): { start: number; end: number } | 'invalid' | null {
52
+ if (!header) return null
53
+ if (!header.startsWith('bytes=')) return null
54
+ const spec = header.slice(6)
55
+ // Multiple ranges: not supported — fall back to full body.
56
+ if (spec.includes(',')) return null
57
+ const dash = spec.indexOf('-')
58
+ if (dash === -1) return 'invalid'
59
+ const startStr = spec.slice(0, dash)
60
+ const endStr = spec.slice(dash + 1)
61
+ let start: number
62
+ let end: number
63
+ if (startStr === '') {
64
+ // Suffix range: bytes=-N → last N bytes
65
+ const suffix = Number(endStr)
66
+ if (!Number.isFinite(suffix) || suffix <= 0) return 'invalid'
67
+ start = Math.max(0, size - suffix)
68
+ end = size - 1
69
+ } else {
70
+ start = Number(startStr)
71
+ end = endStr === '' ? size - 1 : Number(endStr)
72
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return 'invalid'
73
+ if (start < 0 || end < start) return 'invalid'
74
+ if (start >= size) return 'invalid'
75
+ if (end >= size) end = size - 1
76
+ }
77
+ return { start, end }
78
+ }
79
+
80
+ /**
81
+ * Static-file middleware. Serves files from `root`, supporting directory
82
+ * indexes, weak ETags, `If-None-Match`, byte-range requests, and basic
83
+ * dotfile policy. Misses (file not found) call `next()` — they do NOT
84
+ * write 404 themselves, so downstream routes still get a chance.
85
+ *
86
+ * @example
87
+ * app.use(ingenium.static('./public', { maxAge: 60_000 }))
88
+ */
89
+ export function staticMiddleware(root: string, opts: StaticOptions = {}): IngeniumMiddleware {
90
+ const absRoot = path.resolve(root)
91
+ const indexFile = opts.index === undefined ? DEFAULT_INDEX : opts.index
92
+ const maxAgeMs = opts.maxAge ?? 0
93
+ const cacheControl = `public, max-age=${Math.floor(maxAgeMs / 1000)}`
94
+ const extensions = opts.extensions ?? []
95
+ const dotfiles = opts.dotfiles ?? 'ignore'
96
+
97
+ return async (ctx, next) => {
98
+ // Only GET / HEAD make sense for static.
99
+ if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
100
+ return next()
101
+ }
102
+
103
+ // Decode percent-escapes; reject malformed URLs.
104
+ let urlPath: string
105
+ try {
106
+ urlPath = decodeURIComponent(ctx.path)
107
+ } catch {
108
+ ctx.status(400).text('Bad Request')
109
+ return
110
+ }
111
+
112
+ // Path-traversal protection: resolve, then ensure it stays under root.
113
+ const joined = path.join(absRoot, urlPath)
114
+ const resolved = path.resolve(joined)
115
+ const isUnderRoot =
116
+ resolved === absRoot ||
117
+ resolved.startsWith(absRoot + path.sep)
118
+ if (!isUnderRoot) {
119
+ ctx.status(403).text('Forbidden')
120
+ return
121
+ }
122
+
123
+ // Dotfile policy: check every segment of the path BELOW root.
124
+ const rel = path.relative(absRoot, resolved)
125
+ const segments = rel.length === 0 ? [] : rel.split(/[/\\]/)
126
+ const hasDot = segments.some((s) => s.length > 0 && s.startsWith('.'))
127
+ if (hasDot) {
128
+ if (dotfiles === 'deny') {
129
+ ctx.status(403).text('Forbidden')
130
+ return
131
+ }
132
+ if (dotfiles === 'ignore') {
133
+ return next()
134
+ }
135
+ // 'allow' falls through.
136
+ }
137
+
138
+ // Try to stat the resolved path. Fall through on ENOENT / not-a-file.
139
+ let target = resolved
140
+ let stats: Stats | null = null
141
+ try {
142
+ stats = await stat(target)
143
+ } catch {
144
+ stats = null
145
+ }
146
+
147
+ // Try `extensions` if the bare path didn't exist.
148
+ if (!stats && extensions.length > 0) {
149
+ for (const ext of extensions) {
150
+ const withExt = `${target}.${ext.replace(/^\./, '')}`
151
+ try {
152
+ const s = await stat(withExt)
153
+ if (s.isFile()) {
154
+ target = withExt
155
+ stats = s
156
+ break
157
+ }
158
+ } catch {
159
+ // try next
160
+ }
161
+ }
162
+ }
163
+
164
+ // Directory → optional index file.
165
+ if (stats && stats.isDirectory()) {
166
+ if (!indexFile) return next()
167
+ const idx = path.join(target, indexFile)
168
+ try {
169
+ const s = await stat(idx)
170
+ if (s.isFile()) {
171
+ target = idx
172
+ stats = s
173
+ } else {
174
+ return next()
175
+ }
176
+ } catch {
177
+ return next()
178
+ }
179
+ }
180
+
181
+ if (!stats || !stats.isFile()) {
182
+ return next()
183
+ }
184
+
185
+ // ───── Cacheable response headers ─────
186
+ const etag = makeEtag(stats)
187
+ const lastModified = new Date(stats.mtimeMs).toUTCString()
188
+ ctx.set('etag', etag)
189
+ ctx.set('last-modified', lastModified)
190
+ ctx.set('cache-control', cacheControl)
191
+ ctx.set('content-type', mimeFor(target))
192
+ ctx.set('accept-ranges', 'bytes')
193
+
194
+ // Conditional GET via If-None-Match (preferred) or If-Modified-Since
195
+ // (fallback per RFC 7232 §6). If-None-Match wins when both are present.
196
+ const ifNoneMatch = ctx.headers['if-none-match']
197
+ let notModified = typeof ifNoneMatch === 'string' && ifNoneMatch === etag
198
+
199
+ if (!notModified && !ifNoneMatch) {
200
+ const ifModifiedSince = ctx.headers['if-modified-since']
201
+ if (typeof ifModifiedSince === 'string') {
202
+ const sinceMs = Date.parse(ifModifiedSince)
203
+ // mtime is compared at second-resolution because HTTP-dates have no
204
+ // sub-second precision — Math.floor matches both sides.
205
+ const lastMs = Math.floor(stats.mtimeMs / 1000) * 1000
206
+ if (Number.isFinite(sinceMs) && lastMs <= sinceMs) notModified = true
207
+ }
208
+ }
209
+
210
+ if (notModified) {
211
+ ctx.status(304)
212
+ // 304 must not have a body.
213
+ ctx._body = { kind: 'none' }
214
+ ctx._written = true
215
+ return
216
+ }
217
+
218
+ const size = stats.size
219
+ const rangeHeader = ctx.headers.range
220
+ const range = parseRange(typeof rangeHeader === 'string' ? rangeHeader : undefined, size)
221
+
222
+ if (range === 'invalid') {
223
+ ctx.status(416)
224
+ ctx.set('content-range', `bytes */${size}`)
225
+ ctx._body = { kind: 'none' }
226
+ ctx._written = true
227
+ return
228
+ }
229
+
230
+ if (range) {
231
+ const { start, end } = range
232
+ const chunk = end - start + 1
233
+ ctx.status(206)
234
+ ctx.set('content-range', `bytes ${start}-${end}/${size}`)
235
+ ctx.set('content-length', String(chunk))
236
+ if (ctx.method === 'HEAD') {
237
+ ctx._body = { kind: 'none' }
238
+ ctx._written = true
239
+ return
240
+ }
241
+ ctx.stream(createReadStream(target, { start, end }))
242
+ return
243
+ }
244
+
245
+ // Full body.
246
+ ctx.set('content-length', String(size))
247
+ if (ctx.method === 'HEAD') {
248
+ ctx._body = { kind: 'none' }
249
+ ctx._written = true
250
+ return
251
+ }
252
+ ctx.stream(createReadStream(target))
253
+ }
254
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Options for the `ingenium.static` middleware.
3
+ */
4
+ export interface StaticOptions {
5
+ /**
6
+ * The file to serve when a directory is requested. Set to `false` to
7
+ * disable directory-index resolution. Default: `'index.html'`.
8
+ */
9
+ index?: string | false
10
+
11
+ /**
12
+ * `Cache-Control: max-age=<seconds>` to set on served files, in
13
+ * MILLISECONDS (Express convention). Default: `0` (no caching).
14
+ */
15
+ maxAge?: number
16
+
17
+ /**
18
+ * Extensions to try (in order) when the requested path doesn't exist.
19
+ * For example, `['html']` lets `/about` resolve to `/about.html`.
20
+ * Default: `[]` (off).
21
+ */
22
+ extensions?: string[]
23
+
24
+ /**
25
+ * How to treat files / directories whose name starts with `.`:
26
+ * - `'allow'` — serve normally
27
+ * - `'deny'` — respond with 403
28
+ * - `'ignore'` — call `next()` (let routes 404 it). DEFAULT.
29
+ */
30
+ dotfiles?: 'allow' | 'deny' | 'ignore'
31
+ }