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
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ingenium",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Express DX, Hono/Fastify throughput. A Node.js HTTP framework.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": ["dist", "src", "README.md", "LICENSE"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20.0.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": ["http", "express", "framework", "router", "fast"],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Contra-Collective/ingenium.git",
|
|
30
|
+
"directory": "packages/ingenium"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Contra-Collective/ingenium#readme",
|
|
33
|
+
"bugs": "https://github.com/Contra-Collective/ingenium/issues",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"zod": "^3.23.0",
|
|
36
|
+
"ws": "^8.18.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"zod": { "optional": true },
|
|
40
|
+
"ws": { "optional": true }
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"zod": "^3.23.0",
|
|
44
|
+
"ws": "^8.18.0",
|
|
45
|
+
"@types/ws": "^8.5.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { IngeniumUnauthorizedError } from '../errors.ts'
|
|
4
|
+
import type { IngeniumMiddleware } from '../middleware/types.ts'
|
|
5
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
6
|
+
import type { ApiKeyLogger, ApiKeyOptions, ApiKeyValidator } from './types.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* API-key authentication middleware.
|
|
10
|
+
*
|
|
11
|
+
* Attaches the validated key string at `ctx.apiKey`. Callers should
|
|
12
|
+
* module-augment `IngeniumContext` for typed access:
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* declare module 'ingenium' {
|
|
17
|
+
* interface IngeniumContext { apiKey?: string }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* import { ingenium } from 'ingenium'
|
|
21
|
+
* const app = ingenium()
|
|
22
|
+
* app.use(ingenium.apiKey({
|
|
23
|
+
* keys: process.env.API_KEYS!.split(','),
|
|
24
|
+
* scheme: 'ApiKey',
|
|
25
|
+
* query: 'api_key',
|
|
26
|
+
* }))
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Security choices:
|
|
30
|
+
* - Allow-list comparisons go through `crypto.timingSafeEqual` after an
|
|
31
|
+
* explicit length check, so neither equality nor length leaks via timing.
|
|
32
|
+
* - The wire-facing error is always `IngeniumUnauthorizedError('Invalid API key')`
|
|
33
|
+
* regardless of which lookup failed (header vs scheme vs query) — no
|
|
34
|
+
* oracle for which transport surface the legit key uses.
|
|
35
|
+
* - Custom validators get the candidate key + ctx; their boolean result is
|
|
36
|
+
* trusted as-is. Validators should be constant-time when comparing keys.
|
|
37
|
+
*/
|
|
38
|
+
export function apiKeyMiddleware(opts: ApiKeyOptions): IngeniumMiddleware {
|
|
39
|
+
// ── Construction-time validation ─────────────────────────────────────────
|
|
40
|
+
if (opts == null || opts.keys === undefined || opts.keys === null) {
|
|
41
|
+
throw new Error('apiKeyMiddleware: `keys` is required')
|
|
42
|
+
}
|
|
43
|
+
const validator = resolveValidator(opts.keys)
|
|
44
|
+
const headerName = (opts.header ?? 'x-api-key').toLowerCase()
|
|
45
|
+
const queryParam = opts.query ?? null
|
|
46
|
+
const scheme = opts.scheme ?? null
|
|
47
|
+
const schemeLower = scheme ? scheme.toLowerCase() : null
|
|
48
|
+
const required = opts.required ?? true
|
|
49
|
+
const logger: ApiKeyLogger =
|
|
50
|
+
opts.logger ?? ((event) => process.emitWarning(`api-key auth failed: ${event.reason}`, 'IngeniumApiKeyWarning'))
|
|
51
|
+
|
|
52
|
+
return async (ctx, next) => {
|
|
53
|
+
const key = readKey(ctx, headerName, schemeLower, queryParam)
|
|
54
|
+
if (!key) {
|
|
55
|
+
if (required) {
|
|
56
|
+
// Missing-key surface is not an oracle — there is no secret material
|
|
57
|
+
// to compare against, so we use a distinct message.
|
|
58
|
+
throw new IngeniumUnauthorizedError('Missing API key')
|
|
59
|
+
}
|
|
60
|
+
await next()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let ok = false
|
|
65
|
+
try {
|
|
66
|
+
ok = await validator(key, ctx)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger({ reason: 'validator_threw' })
|
|
69
|
+
throw new IngeniumUnauthorizedError('Invalid API key')
|
|
70
|
+
}
|
|
71
|
+
if (!ok) {
|
|
72
|
+
logger({ reason: 'no_match' })
|
|
73
|
+
throw new IngeniumUnauthorizedError('Invalid API key')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
;(ctx as IngeniumContext & { apiKey?: string }).apiKey = key
|
|
77
|
+
await next()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build a validator from either an allow-list or a user-supplied function. */
|
|
82
|
+
function resolveValidator(keys: readonly string[] | ApiKeyValidator): ApiKeyValidator {
|
|
83
|
+
if (typeof keys === 'function') return keys
|
|
84
|
+
if (!Array.isArray(keys)) {
|
|
85
|
+
throw new Error('apiKeyMiddleware: `keys` must be a string[] or a validator function')
|
|
86
|
+
}
|
|
87
|
+
if (keys.length === 0) {
|
|
88
|
+
throw new Error('apiKeyMiddleware: `keys` array must contain at least one key')
|
|
89
|
+
}
|
|
90
|
+
// Pre-encode the allow-list once, at construction, so the per-request path
|
|
91
|
+
// does no allocation work beyond hashing the candidate buffer.
|
|
92
|
+
const allow: Buffer[] = []
|
|
93
|
+
for (const k of keys) {
|
|
94
|
+
if (typeof k !== 'string' || k.length === 0) {
|
|
95
|
+
throw new Error('apiKeyMiddleware: every key in the array must be a non-empty string')
|
|
96
|
+
}
|
|
97
|
+
allow.push(Buffer.from(k, 'utf8'))
|
|
98
|
+
}
|
|
99
|
+
return (candidate) => {
|
|
100
|
+
const cand = Buffer.from(candidate, 'utf8')
|
|
101
|
+
// We deliberately walk the entire list even after a match — a length-
|
|
102
|
+
// dependent early-out would let an attacker probe how many keys exist
|
|
103
|
+
// by measuring response time against length-classes. The list is small
|
|
104
|
+
// and bounded by the user.
|
|
105
|
+
let matched = false
|
|
106
|
+
for (const a of allow) {
|
|
107
|
+
if (a.length !== cand.length) continue
|
|
108
|
+
if (timingSafeEqual(a, cand)) matched = true
|
|
109
|
+
}
|
|
110
|
+
return matched
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the candidate key from the request, in priority order:
|
|
116
|
+
* 1. The configured header (default `x-api-key`).
|
|
117
|
+
* 2. The configured `Authorization` scheme, if any.
|
|
118
|
+
* 3. The configured query parameter, if any.
|
|
119
|
+
*
|
|
120
|
+
* Returns `null` when no surface produced a non-empty key.
|
|
121
|
+
*/
|
|
122
|
+
function readKey(
|
|
123
|
+
ctx: IngeniumContext,
|
|
124
|
+
headerName: string,
|
|
125
|
+
schemeLower: string | null,
|
|
126
|
+
queryParam: string | null,
|
|
127
|
+
): string | null {
|
|
128
|
+
const headerVal = ctx.headers[headerName]
|
|
129
|
+
if (headerVal) {
|
|
130
|
+
const v = Array.isArray(headerVal) ? headerVal[0] : headerVal
|
|
131
|
+
if (v && v.length > 0) return v
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (schemeLower) {
|
|
135
|
+
const auth = ctx.headers['authorization']
|
|
136
|
+
if (auth) {
|
|
137
|
+
const v = Array.isArray(auth) ? auth[0] : auth
|
|
138
|
+
if (v) {
|
|
139
|
+
const space = v.indexOf(' ')
|
|
140
|
+
if (space > 0) {
|
|
141
|
+
const s = v.slice(0, space).toLowerCase()
|
|
142
|
+
if (s === schemeLower) {
|
|
143
|
+
const tail = v.slice(space + 1).trim()
|
|
144
|
+
if (tail.length > 0) return tail
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (queryParam) {
|
|
152
|
+
const q = ctx.query.get(queryParam)
|
|
153
|
+
if (q && q.length > 0) return q
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
|
|
3
|
+
/** Result of a custom API-key validator. */
|
|
4
|
+
export type ApiKeyValidator = (
|
|
5
|
+
key: string,
|
|
6
|
+
ctx: IngeniumContext,
|
|
7
|
+
) => boolean | Promise<boolean>
|
|
8
|
+
|
|
9
|
+
/** Optional logger for redacted failure reasons. */
|
|
10
|
+
export type ApiKeyLogger = (event: { reason: string }) => void
|
|
11
|
+
|
|
12
|
+
export interface ApiKeyOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Either an allow-list of valid keys (compared with `timingSafeEqual`) or a
|
|
15
|
+
* custom validator. Functions get the candidate key and the request ctx.
|
|
16
|
+
*/
|
|
17
|
+
keys: readonly string[] | ApiKeyValidator
|
|
18
|
+
/** Header to read the key from. Default `'x-api-key'`. */
|
|
19
|
+
header?: string
|
|
20
|
+
/**
|
|
21
|
+
* Optional fallback query-string parameter, e.g. `'api_key'`. When set,
|
|
22
|
+
* the middleware checks `?api_key=...` if no header / scheme key matched.
|
|
23
|
+
*/
|
|
24
|
+
query?: string
|
|
25
|
+
/**
|
|
26
|
+
* Optional `Authorization` scheme to accept, e.g. `'ApiKey'`. When set,
|
|
27
|
+
* the middleware accepts `Authorization: ApiKey <key>`.
|
|
28
|
+
*/
|
|
29
|
+
scheme?: string
|
|
30
|
+
/**
|
|
31
|
+
* If `true` (default), missing keys raise `IngeniumUnauthorizedError`. If
|
|
32
|
+
* `false`, missing keys just call `next()` with no `ctx.apiKey`.
|
|
33
|
+
*/
|
|
34
|
+
required?: boolean
|
|
35
|
+
/** Optional sink for redacted failure reasons. Defaults to `process.emitWarning`. */
|
|
36
|
+
logger?: ApiKeyLogger
|
|
37
|
+
}
|
package/src/app/scope.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ScopedApp` — registration facade returned to the callback of
|
|
3
|
+
* `app.scope(prefix, registrar)`. Translates every registration call into a
|
|
4
|
+
* prefix-qualified registration on the underlying `IngeniumApp`, leveraging
|
|
5
|
+
* the existing Router scoping primitives (`use-prefix`, prefix-prepended
|
|
6
|
+
* paths) so the COMPOSE-TIME machinery does all the heavy lifting and the
|
|
7
|
+
* per-request hot path stays untouched.
|
|
8
|
+
*
|
|
9
|
+
* # Design
|
|
10
|
+
*
|
|
11
|
+
* - Holds a reference to the root `IngeniumApp` plus the ABSOLUTE prefix
|
|
12
|
+
* (already includes any outer scope prefix). Nested scopes just construct
|
|
13
|
+
* a new `ScopedApp` with `parent.prefix + sub`.
|
|
14
|
+
* - `scope.use(mw)` becomes `app.use(absolutePrefix, mw)` — the existing
|
|
15
|
+
* `Router.use(prefix, mw)` plumbing produces a `use-prefix` registration,
|
|
16
|
+
* and `flattenRouter` emits a `scopedMiddleware` entry that `app.compose()`
|
|
17
|
+
* intersects against route paths via `pathStartsWith`.
|
|
18
|
+
* - `scope.get(path, handler)` becomes `app.method('GET', absolutePrefix + path, handler)`.
|
|
19
|
+
* - `scope.register(plugin, opts)` invokes the plugin with `this` as the
|
|
20
|
+
* target. Any `target.use(...)` inside the plugin body is therefore
|
|
21
|
+
* scope-prefixed; the plugin can't accidentally leak global middleware.
|
|
22
|
+
* - `scope.scope(sub, fn)` constructs a child `ScopedApp` and runs `fn`
|
|
23
|
+
* against it.
|
|
24
|
+
*
|
|
25
|
+
* # Out of scope for V1 (documented footguns)
|
|
26
|
+
*
|
|
27
|
+
* - **Decorators**: `scope.decorate(...)` / `scope.decorateRequest(...)`
|
|
28
|
+
* forward to the root app and decorate EVERY request, not just requests
|
|
29
|
+
* under the scope's prefix. The reason is structural: decorators install
|
|
30
|
+
* onto pooled `IngeniumContext` instances at request start, BEFORE the
|
|
31
|
+
* route is matched — there's no path information available at that point
|
|
32
|
+
* without re-shaping the dispatch path. Per-scope decorators would require
|
|
33
|
+
* either (a) a runtime path check on every property access, or (b) a
|
|
34
|
+
* separate decorator registry per scope keyed by matched route — both of
|
|
35
|
+
* which move work onto the hot path and complicate the pool. For V1 we
|
|
36
|
+
* accept the footgun and emit a one-shot `process.emitWarning` in
|
|
37
|
+
* non-production environments to surface it.
|
|
38
|
+
* - **Hooks**: `scope.hooks` returns the SAME registry the root app uses.
|
|
39
|
+
* Hook registration is global. A plugin that wants scope-aware hook
|
|
40
|
+
* behavior should inspect `ctx.path` inside the hook body.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { IngeniumApp } from '../app.ts'
|
|
44
|
+
import type { IngeniumHandler, IngeniumMiddleware } from '../middleware/types.ts'
|
|
45
|
+
import { RouteBuilder, type Router } from '../router/router.ts'
|
|
46
|
+
import type { HttpMethod } from '../router/types.ts'
|
|
47
|
+
import type {
|
|
48
|
+
EagerDecorator,
|
|
49
|
+
Hooks,
|
|
50
|
+
IngeniumPlugin,
|
|
51
|
+
LazyDecorator,
|
|
52
|
+
PluginTarget,
|
|
53
|
+
} from '../plugin/types.ts'
|
|
54
|
+
import { registerAfter, registerBefore } from '../sinatra/filters.ts'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @internal Friend-access surface a `ScopedApp` needs from its parent
|
|
58
|
+
* `IngeniumApp`. Kept narrow so refactors don't accidentally widen the
|
|
59
|
+
* coupling. The methods are implemented on `IngeniumApp` itself.
|
|
60
|
+
*/
|
|
61
|
+
export interface ScopeHost {
|
|
62
|
+
use(mw: IngeniumMiddleware): IngeniumApp
|
|
63
|
+
use(prefix: string, mw: IngeniumMiddleware | Router): IngeniumApp
|
|
64
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): IngeniumApp
|
|
65
|
+
method(
|
|
66
|
+
method: HttpMethod,
|
|
67
|
+
path: string,
|
|
68
|
+
...args: [...IngeniumMiddleware[], IngeniumHandler]
|
|
69
|
+
): IngeniumApp
|
|
70
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): IngeniumApp
|
|
71
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): IngeniumApp
|
|
72
|
+
readonly hooks: Hooks
|
|
73
|
+
/** @internal Marks the app's compose-cache dirty. */
|
|
74
|
+
_markDirty(): void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize an absolute-or-relative prefix piece so concatenation is clean.
|
|
79
|
+
* Drops the trailing slash, ensures a leading slash. Empty string and `'/'`
|
|
80
|
+
* collapse to `''` (no prefix).
|
|
81
|
+
*/
|
|
82
|
+
function normalizePrefix(p: string): string {
|
|
83
|
+
if (p === '' || p === '/') return ''
|
|
84
|
+
let out = p
|
|
85
|
+
if (out[0] !== '/') out = '/' + out
|
|
86
|
+
if (out.length > 1 && out[out.length - 1] === '/') out = out.slice(0, -1)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize a route path so `'users'` and `'/users'` both end up `/users` and
|
|
92
|
+
* `''` resolves to `'/'`. Mirrors `Router.normalizePath`.
|
|
93
|
+
*/
|
|
94
|
+
function normalizePath(p: string): string {
|
|
95
|
+
if (!p) return '/'
|
|
96
|
+
if (p[0] !== '/') return '/' + p
|
|
97
|
+
return p
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Join the scope's absolute prefix with a relative path. The result is what
|
|
102
|
+
* goes onto the underlying `Router` (so it's the absolute path inside the
|
|
103
|
+
* trie at compose time).
|
|
104
|
+
*/
|
|
105
|
+
function joinScopePath(prefix: string, path: string): string {
|
|
106
|
+
const np = normalizePath(path)
|
|
107
|
+
if (prefix === '') return np
|
|
108
|
+
if (np === '/') return prefix
|
|
109
|
+
return prefix + np
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Process-wide flag — true once we've emitted the "decorators are global"
|
|
114
|
+
* warning. Gated on `NODE_ENV !== 'production'` so production deploys aren't
|
|
115
|
+
* spammed. We accept the once-per-process granularity (rather than once per
|
|
116
|
+
* scope) because the message is the same regardless of which scope tripped it.
|
|
117
|
+
*/
|
|
118
|
+
let _decoratorWarningEmitted = false
|
|
119
|
+
|
|
120
|
+
function maybeEmitDecoratorWarning(name: string): void {
|
|
121
|
+
if (_decoratorWarningEmitted) return
|
|
122
|
+
if (typeof process === 'undefined') return
|
|
123
|
+
if (process.env?.NODE_ENV === 'production') return
|
|
124
|
+
_decoratorWarningEmitted = true
|
|
125
|
+
try {
|
|
126
|
+
process.emitWarning(
|
|
127
|
+
`ingenium: scope.decorate('${name}', ...) is GLOBAL — decorators apply to every request regardless of scope prefix. ` +
|
|
128
|
+
`Make the decorator's resolver path-aware (read ctx.path) if you want scoped behavior. ` +
|
|
129
|
+
`This warning fires once per process.`,
|
|
130
|
+
{ type: 'IngeniumScopedDecoratorWarning' },
|
|
131
|
+
)
|
|
132
|
+
} catch {
|
|
133
|
+
// process.emitWarning may throw in unusual runtimes (workers); swallow.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @internal Test-only — reset the one-shot decorator warning latch. */
|
|
138
|
+
export function _resetDecoratorWarningLatch(): void {
|
|
139
|
+
_decoratorWarningEmitted = false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A `ScopedApp` is the registration target passed to the `app.scope(prefix, registrar)`
|
|
144
|
+
* callback. It exposes the registration surface a plugin needs (`use`, verbs,
|
|
145
|
+
* `register`, `decorate`, `before`/`after`, nested `scope`) but NOT the
|
|
146
|
+
* dispatch surface (`compose`, `handle`, `listen`) — those still belong to
|
|
147
|
+
* the root app.
|
|
148
|
+
*
|
|
149
|
+
* Instances are cheap: a couple of fields and method-call forwarding. Do not
|
|
150
|
+
* cache them across recompose boundaries — they hold a reference to the
|
|
151
|
+
* `IngeniumApp` and rely on its mutable router journal.
|
|
152
|
+
*/
|
|
153
|
+
export class ScopedApp implements PluginTarget {
|
|
154
|
+
/** @internal The root app this scope translates registrations onto. */
|
|
155
|
+
private readonly _app: ScopeHost
|
|
156
|
+
/** @internal Absolute prefix (already includes any outer scope's prefix). */
|
|
157
|
+
private readonly _prefix: string
|
|
158
|
+
|
|
159
|
+
/** @internal Construct via `app.scope(...)`; not meant to be `new`'d directly. */
|
|
160
|
+
constructor(app: ScopeHost, prefix: string) {
|
|
161
|
+
this._app = app
|
|
162
|
+
this._prefix = normalizePrefix(prefix)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Absolute prefix this scope rewrites against (for debugging / introspection). */
|
|
166
|
+
get prefix(): string {
|
|
167
|
+
return this._prefix
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Lifecycle hooks. SHARED with the root app — hooks are global by design. */
|
|
171
|
+
get hooks(): Hooks {
|
|
172
|
+
return this._app.hooks
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ───── Middleware ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
use(mw: IngeniumMiddleware): this
|
|
178
|
+
use(subPrefix: string, mw: IngeniumMiddleware | Router): this
|
|
179
|
+
use(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware | Router): this {
|
|
180
|
+
if (typeof arg1 === 'string') {
|
|
181
|
+
// Subprefix is relative to this scope's prefix.
|
|
182
|
+
const joined = this._prefix + normalizePrefix(arg1)
|
|
183
|
+
// If both prefix and subPrefix were empty, fall through to global.
|
|
184
|
+
if (joined === '') {
|
|
185
|
+
this._app.use(arg2 as IngeniumMiddleware)
|
|
186
|
+
} else {
|
|
187
|
+
this._app.use(joined, arg2 as IngeniumMiddleware | Router)
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// No prefix arg — scope.use(mw) means "scoped to this scope's prefix".
|
|
191
|
+
if (this._prefix === '') {
|
|
192
|
+
this._app.use(arg1)
|
|
193
|
+
} else {
|
|
194
|
+
this._app.use(this._prefix, arg1)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this._app._markDirty()
|
|
198
|
+
return this
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ───── Verbs ───────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
get(path: string, handler: IngeniumHandler): this
|
|
204
|
+
get(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
205
|
+
get(path: string, ...args: unknown[]): this {
|
|
206
|
+
return this.method('GET', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
post(path: string, handler: IngeniumHandler): this
|
|
210
|
+
post(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
211
|
+
post(path: string, ...args: unknown[]): this {
|
|
212
|
+
return this.method('POST', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
put(path: string, handler: IngeniumHandler): this
|
|
216
|
+
put(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
217
|
+
put(path: string, ...args: unknown[]): this {
|
|
218
|
+
return this.method('PUT', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
patch(path: string, handler: IngeniumHandler): this
|
|
222
|
+
patch(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
223
|
+
patch(path: string, ...args: unknown[]): this {
|
|
224
|
+
return this.method('PATCH', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
delete(path: string, handler: IngeniumHandler): this
|
|
228
|
+
delete(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
229
|
+
delete(path: string, ...args: unknown[]): this {
|
|
230
|
+
return this.method('DELETE', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
head(path: string, handler: IngeniumHandler): this
|
|
234
|
+
head(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
235
|
+
head(path: string, ...args: unknown[]): this {
|
|
236
|
+
return this.method('HEAD', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
options(path: string, handler: IngeniumHandler): this
|
|
240
|
+
options(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
|
|
241
|
+
options(path: string, ...args: unknown[]): this {
|
|
242
|
+
return this.method('OPTIONS', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): this
|
|
246
|
+
method(
|
|
247
|
+
method: HttpMethod,
|
|
248
|
+
path: string,
|
|
249
|
+
...args: [...IngeniumMiddleware[], IngeniumHandler]
|
|
250
|
+
): this
|
|
251
|
+
method(method: HttpMethod, path: string, ...args: unknown[]): this {
|
|
252
|
+
const absolute = joinScopePath(this._prefix, path)
|
|
253
|
+
// Delegate to the underlying app — which already validates handler-is-
|
|
254
|
+
// function and middleware-is-function (via Router), and sets dirty.
|
|
255
|
+
;(this._app.method as (m: HttpMethod, p: string, ...a: unknown[]) => IngeniumApp)(
|
|
256
|
+
method,
|
|
257
|
+
absolute,
|
|
258
|
+
...args,
|
|
259
|
+
)
|
|
260
|
+
return this
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Chainable per-path builder. The builder closes over `this.method`, which
|
|
265
|
+
* already does the scope-prefix join — so the same builder works identically
|
|
266
|
+
* on the root app and inside a scope. Typed params via `ExtractParams<P>`
|
|
267
|
+
* narrow against the RELATIVE path the user wrote, matching what the bare
|
|
268
|
+
* verb form does.
|
|
269
|
+
*/
|
|
270
|
+
route<P extends string>(path: P): RouteBuilder<P> {
|
|
271
|
+
return new RouteBuilder<P>((method, args) =>
|
|
272
|
+
(this.method as (m: HttpMethod, p: string, ...a: unknown[]) => unknown)(method, path, ...args),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ───── Decorators (GLOBAL — see file header) ───────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Register a lazy decorator. **WARNING:** decorators are GLOBAL even when
|
|
280
|
+
* registered inside a scope — they apply to every request regardless of
|
|
281
|
+
* the scope's prefix. The first call from inside any scope in a process
|
|
282
|
+
* emits a `process.emitWarning` (non-production only). See file header.
|
|
283
|
+
*/
|
|
284
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): this {
|
|
285
|
+
maybeEmitDecoratorWarning(name)
|
|
286
|
+
this._app.decorate(name, factory)
|
|
287
|
+
// Decorators don't affect routing, but they DO affect the per-request
|
|
288
|
+
// dispatch flags (`_hasDecorators`), which are cached at compose time.
|
|
289
|
+
// Mark dirty so a recompose picks up the new decorator if registration
|
|
290
|
+
// happens after the first request.
|
|
291
|
+
this._app._markDirty()
|
|
292
|
+
return this
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Register an eager decorator. **WARNING:** see {@link ScopedApp.decorate}.
|
|
297
|
+
*/
|
|
298
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): this {
|
|
299
|
+
maybeEmitDecoratorWarning(name)
|
|
300
|
+
this._app.decorateRequest(name, factory)
|
|
301
|
+
this._app._markDirty()
|
|
302
|
+
return this
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ───── Plugin registration ─────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Register a plugin against THIS scope. The plugin receives the `ScopedApp`
|
|
309
|
+
* as its `target`, so any `target.use(...)` inside the plugin body is
|
|
310
|
+
* automatically prefix-scoped.
|
|
311
|
+
*/
|
|
312
|
+
register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>
|
|
313
|
+
register(plugin: IngeniumPlugin<void>): Promise<this>
|
|
314
|
+
async register<O>(plugin: IngeniumPlugin<O>, opts?: O): Promise<this> {
|
|
315
|
+
await plugin(this, opts as O)
|
|
316
|
+
this._app._markDirty()
|
|
317
|
+
return this
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ───── Nested scope ────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Open a nested scope. `subPrefix` is relative to this scope's prefix.
|
|
324
|
+
* The registrar may be async; the call returns a Promise that resolves
|
|
325
|
+
* once the registrar finishes if it returned one, otherwise resolves
|
|
326
|
+
* synchronously to `this`. We type-erase to `this` to match the
|
|
327
|
+
* `PluginTarget` interface, which can't express the sync-or-async return
|
|
328
|
+
* without polluting every caller.
|
|
329
|
+
*/
|
|
330
|
+
scope(
|
|
331
|
+
subPrefix: string,
|
|
332
|
+
registrar: (scope: PluginTarget) => void,
|
|
333
|
+
): this {
|
|
334
|
+
const child = new ScopedApp(this._app, this._prefix + normalizePrefix(subPrefix))
|
|
335
|
+
const ret: unknown = registrar(child)
|
|
336
|
+
if (ret && typeof (ret as Promise<void>).then === 'function') {
|
|
337
|
+
// Best-effort: surface async failures via the returned promise. We can't
|
|
338
|
+
// wait synchronously, so we annotate the chain to mark dirty after the
|
|
339
|
+
// registrar settles. Tests should `await app.scope(..., async (s) => {})`
|
|
340
|
+
// by chaining via the parent app or by awaiting the registrar themselves.
|
|
341
|
+
;(ret as Promise<void>).then(() => this._app._markDirty())
|
|
342
|
+
} else {
|
|
343
|
+
this._app._markDirty()
|
|
344
|
+
}
|
|
345
|
+
return this
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ───── Sinatra filters ─────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Register a `before` filter scoped to this scope's prefix. If a pattern
|
|
352
|
+
* is given it's appended to the scope's prefix (so `scope('/api').before('/users', h)`
|
|
353
|
+
* matches `/api/users` and below). If omitted, the filter applies to the
|
|
354
|
+
* scope's full subtree.
|
|
355
|
+
*/
|
|
356
|
+
before(handler: IngeniumMiddleware): this
|
|
357
|
+
before(pattern: string, handler: IngeniumMiddleware): this
|
|
358
|
+
before(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
|
|
359
|
+
// We delegate to the existing Sinatra filter implementation, but with the
|
|
360
|
+
// scope's prefix folded in. `registerBefore` takes `(app, pattern, fn)`
|
|
361
|
+
// and uses `app.use(prefix, wrapped)` under the hood — so passing the
|
|
362
|
+
// root app + a prefixed pattern yields exactly the desired scoping.
|
|
363
|
+
const root = this._app as unknown as IngeniumApp
|
|
364
|
+
if (typeof arg1 === 'function') {
|
|
365
|
+
if (this._prefix === '') registerBefore(root, arg1)
|
|
366
|
+
else registerBefore(root, this._prefix, arg1)
|
|
367
|
+
} else {
|
|
368
|
+
const joined = this._prefix + normalizePrefix(arg1)
|
|
369
|
+
if (joined === '') registerBefore(root, arg2 as IngeniumMiddleware)
|
|
370
|
+
else registerBefore(root, joined, arg2 as IngeniumMiddleware)
|
|
371
|
+
}
|
|
372
|
+
this._app._markDirty()
|
|
373
|
+
return this
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Register an `after` filter scoped to this scope's prefix. See {@link ScopedApp.before}. */
|
|
377
|
+
after(handler: IngeniumMiddleware): this
|
|
378
|
+
after(pattern: string, handler: IngeniumMiddleware): this
|
|
379
|
+
after(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
|
|
380
|
+
const root = this._app as unknown as IngeniumApp
|
|
381
|
+
if (typeof arg1 === 'function') {
|
|
382
|
+
if (this._prefix === '') registerAfter(root, arg1)
|
|
383
|
+
else registerAfter(root, this._prefix, arg1)
|
|
384
|
+
} else {
|
|
385
|
+
const joined = this._prefix + normalizePrefix(arg1)
|
|
386
|
+
if (joined === '') registerAfter(root, arg2 as IngeniumMiddleware)
|
|
387
|
+
else registerAfter(root, joined, arg2 as IngeniumMiddleware)
|
|
388
|
+
}
|
|
389
|
+
this._app._markDirty()
|
|
390
|
+
return this
|
|
391
|
+
}
|
|
392
|
+
}
|