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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- 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
|
+
}
|