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,251 @@
1
+ import type { IngeniumApp } from '../app.ts'
2
+ import { isStandardSchema } from '../schema/standard.ts'
3
+ import { Router, flattenRouter } from '../router/router.ts'
4
+ import type { HttpMethod } from '../router/types.ts'
5
+ import { descriptorKey, mergeDescriptor, type RouteDescriptor } from './describe.ts'
6
+ import { extractPathParams } from './extract-params.ts'
7
+ import type {
8
+ Components,
9
+ Info,
10
+ MediaType,
11
+ OpenApiSpec,
12
+ Operation,
13
+ PathItem,
14
+ RequestBody,
15
+ Response,
16
+ Schema,
17
+ SecurityRequirement,
18
+ SecurityScheme,
19
+ Server,
20
+ Tag,
21
+ } from './types.ts'
22
+
23
+ /** Public options for `generateOpenApi(app, opts)`. */
24
+ export interface GenerateOpenApiOptions {
25
+ info: Info
26
+ servers?: Server[]
27
+ tags?: Tag[]
28
+ security?: SecurityRequirement[]
29
+ /**
30
+ * Auto-tag generated operations by path prefix. The longest matching
31
+ * prefix wins. Routes that already have `tags` in their descriptor are
32
+ * left alone.
33
+ *
34
+ * @example { '/users': 'users', '/auth': 'auth' }
35
+ */
36
+ tagsByPrefix?: Record<string, string>
37
+ /**
38
+ * Hide routes whose path matches any entry. Strings match exactly,
39
+ * RegExps are tested against the full path.
40
+ */
41
+ excludePaths?: (string | RegExp)[]
42
+ /** Pass-through `components.securitySchemes`. */
43
+ securitySchemes?: Record<string, SecurityScheme>
44
+ /**
45
+ * Optional additional schemas to merge into `components.schemas`. Useful
46
+ * when you reference shared models via `$ref: '#/components/schemas/X'`.
47
+ */
48
+ componentSchemas?: Record<string, Schema>
49
+ }
50
+
51
+ /**
52
+ * Generate an OpenAPI 3.1 spec from a composed (or uncomposed) IngeniumApp.
53
+ * Walks the registration journal — does not require `compose()` to have run.
54
+ *
55
+ * Schema-conversion strategy (in priority order):
56
+ * 1. If a request/response schema has a `toJsonSchema()` method (Zod 3.24+,
57
+ * ArkType, Effect Schema, etc.), call it.
58
+ * 2. If it looks like a Standard Schema (has `~standard`), emit `{}` plus
59
+ * `x-schema-source: '<vendor>-untranslated'` as a TODO marker.
60
+ * 3. Otherwise, pass the value through unchanged (assumed JSON Schema).
61
+ */
62
+ export function generateOpenApi(
63
+ app: IngeniumApp,
64
+ opts: GenerateOpenApiOptions,
65
+ ): OpenApiSpec {
66
+ const router = getRouter(app)
67
+ const descriptors = getDescriptors(app)
68
+ const flat = flattenRouter(router)
69
+
70
+ const paths: Record<string, PathItem> = {}
71
+ const tagsByPrefix = sortedTagsByPrefix(opts.tagsByPrefix)
72
+ const exclude = opts.excludePaths ?? []
73
+
74
+ for (const route of flat.routes) {
75
+ if (isExcluded(route.path, exclude)) continue
76
+
77
+ const desc = descriptors.get(descriptorKey(route.method, route.path))
78
+ if (desc?.hidden) continue
79
+
80
+ const oasPath = toOpenApiPath(route.path)
81
+ const item: PathItem = paths[oasPath] ?? (paths[oasPath] = {})
82
+
83
+ const op: Operation = {
84
+ parameters: extractPathParams(route.path),
85
+ responses: { default: { description: 'Default response' } },
86
+ }
87
+
88
+ // Auto-tag by prefix if no descriptor tags were provided.
89
+ if (!desc?.tags) {
90
+ const tag = matchTag(route.path, tagsByPrefix)
91
+ if (tag) op.tags = [tag]
92
+ }
93
+
94
+ mergeDescriptor(op, desc)
95
+
96
+ // Convert any Standard/Zod-style schemas inside requestBody.content.
97
+ if (op.requestBody) {
98
+ op.requestBody = convertRequestBodySchemas(op.requestBody)
99
+ }
100
+ if (op.responses) {
101
+ const r: Record<string, Response> = {}
102
+ for (const k of Object.keys(op.responses)) {
103
+ r[k] = convertResponseSchemas(op.responses[k]!)
104
+ }
105
+ op.responses = r
106
+ }
107
+
108
+ // PathItem's method keys are typed as Operation but `keyof PathItem` widens
109
+ // to include `parameters` / `summary` / `description`. Cast the slot.
110
+ ;(item as Record<string, Operation>)[methodKey(route.method)] = op
111
+ }
112
+
113
+ const components: Components = {}
114
+ if (opts.securitySchemes) components.securitySchemes = opts.securitySchemes
115
+ if (opts.componentSchemas) components.schemas = opts.componentSchemas
116
+
117
+ const spec: OpenApiSpec = {
118
+ openapi: '3.1.0',
119
+ info: opts.info,
120
+ paths,
121
+ }
122
+ if (opts.servers) spec.servers = opts.servers
123
+ if (opts.tags) spec.tags = opts.tags
124
+ if (opts.security) spec.security = opts.security
125
+ if (Object.keys(components).length > 0) spec.components = components
126
+
127
+ return spec
128
+ }
129
+
130
+ // ───── helpers ──────────────────────────────────────────────────────────────
131
+
132
+ /** Reach into the app's private `router` field — public surface intentionally narrow. */
133
+ function getRouter(app: IngeniumApp): Router {
134
+ const r = (app as unknown as { router?: Router })['router']
135
+ if (!(r instanceof Router)) {
136
+ throw new TypeError(
137
+ 'generateOpenApi: app.router is not a Router instance — pass the value returned by `ingenium()`.',
138
+ )
139
+ }
140
+ return r
141
+ }
142
+
143
+ /** Reach into the descriptor map (set up by the integration in app.ts). */
144
+ function getDescriptors(app: IngeniumApp): Map<string, RouteDescriptor> {
145
+ const m = (app as unknown as { _routeDescriptors?: Map<string, RouteDescriptor> })['_routeDescriptors']
146
+ return m instanceof Map ? m : new Map()
147
+ }
148
+
149
+ /** Convert Ingenium path syntax to OpenAPI: `:id` → `{id}`, `*path` → `{path}`. */
150
+ function toOpenApiPath(path: string): string {
151
+ if (!path) return '/'
152
+ const out = path
153
+ .split('/')
154
+ .map((seg) => {
155
+ if (!seg) return seg
156
+ if (seg[0] === ':') {
157
+ const isOpt = seg.endsWith('?')
158
+ const name = isOpt ? seg.slice(1, -1) : seg.slice(1)
159
+ return `{${name}}`
160
+ }
161
+ if (seg[0] === '*') {
162
+ const name = seg.slice(1) || 'wildcard'
163
+ return `{${name}}`
164
+ }
165
+ return seg
166
+ })
167
+ .join('/')
168
+ return out || '/'
169
+ }
170
+
171
+ function methodKey(m: HttpMethod): keyof PathItem {
172
+ return m.toLowerCase() as keyof PathItem
173
+ }
174
+
175
+ function isExcluded(path: string, excludes: (string | RegExp)[]): boolean {
176
+ for (const ex of excludes) {
177
+ if (typeof ex === 'string') {
178
+ if (ex === path) return true
179
+ } else if (ex.test(path)) {
180
+ return true
181
+ }
182
+ }
183
+ return false
184
+ }
185
+
186
+ function sortedTagsByPrefix(map: Record<string, string> | undefined): [string, string][] {
187
+ if (!map) return []
188
+ return Object.entries(map).sort((a, b) => b[0].length - a[0].length)
189
+ }
190
+
191
+ function matchTag(path: string, tagsByPrefix: [string, string][]): string | undefined {
192
+ for (const [prefix, tag] of tagsByPrefix) {
193
+ if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix)) {
194
+ return tag
195
+ }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ function convertRequestBodySchemas(rb: RequestBody): RequestBody {
201
+ const out: RequestBody = { ...rb, content: {} }
202
+ for (const [type, media] of Object.entries(rb.content)) {
203
+ out.content[type] = convertMediaSchema(media)
204
+ }
205
+ return out
206
+ }
207
+
208
+ function convertResponseSchemas(res: Response): Response {
209
+ if (!res.content) return res
210
+ const next: Response = { ...res, content: {} }
211
+ for (const [type, media] of Object.entries(res.content)) {
212
+ next.content![type] = convertMediaSchema(media)
213
+ }
214
+ return next
215
+ }
216
+
217
+ function convertMediaSchema(media: MediaType): MediaType {
218
+ if (!media.schema) return media
219
+ const converted = toJsonSchema(media.schema)
220
+ if (converted === media.schema) return media
221
+ return { ...media, schema: converted }
222
+ }
223
+
224
+ /**
225
+ * Best-effort schema conversion. Returns the input unchanged if it's already
226
+ * a plain JSON Schema; otherwise tries known conversion paths.
227
+ */
228
+ function toJsonSchema(schema: unknown): Schema {
229
+ if (schema === null || typeof schema !== 'object') return schema as Schema
230
+
231
+ // 1. Native `toJsonSchema()` (Zod 3.24+, ArkType, Effect Schema, etc.)
232
+ const maybe = schema as { toJsonSchema?: () => unknown }
233
+ if (typeof maybe.toJsonSchema === 'function') {
234
+ try {
235
+ const out = maybe.toJsonSchema()
236
+ if (out && typeof out === 'object') return out as Schema
237
+ } catch {
238
+ // fall through to placeholder
239
+ }
240
+ }
241
+
242
+ // 2. Standard Schema fallback — emit a marker so users know to add a
243
+ // converter. We can't introspect the validator without running it.
244
+ if (isStandardSchema(schema)) {
245
+ const vendor = schema['~standard'].vendor || 'unknown'
246
+ return { 'x-schema-source': `${vendor}-untranslated` }
247
+ }
248
+
249
+ // 3. Pass through (assumed JSON Schema literal).
250
+ return schema as Schema
251
+ }
@@ -0,0 +1,73 @@
1
+ import type { IngeniumApp } from '../app.ts'
2
+ import type { IngeniumContext } from '../context/context.ts'
3
+ import type { IngeniumHandler } from '../middleware/types.ts'
4
+ import { generateOpenApi, type GenerateOpenApiOptions } from './generate.ts'
5
+ import type { OpenApiSpec } from './types.ts'
6
+
7
+ /**
8
+ * Build a route handler that serves the generated OpenAPI spec as JSON.
9
+ *
10
+ * The spec is generated lazily on the first request that hits this handler
11
+ * and cached on the app under a private symbol. The cache invalidates when
12
+ * the registration journal length changes — i.e. when new routes are added —
13
+ * so live-registered routes are reflected on the next request.
14
+ *
15
+ * @example
16
+ * app.get('/openapi.json', ingenium.openapiHandler({
17
+ * info: { title: 'My API', version: '1.0.0' },
18
+ * }))
19
+ */
20
+ export function openapiHandler(opts: GenerateOpenApiOptions): IngeniumHandler {
21
+ type Cache = { journalLen: number; descriptorVer: number; spec: OpenApiSpec }
22
+ let cache: Cache | null = null
23
+
24
+ return (ctx: IngeniumContext): void => {
25
+ const app = resolveApp(ctx)
26
+ if (!app) {
27
+ // The integration shim stamps `ctx.state._ingeniumApp` for us; if it's
28
+ // missing the user is on an older app build that hasn't applied the
29
+ // shim. Surface a clear error rather than silently emitting an empty
30
+ // spec.
31
+ ctx.json(
32
+ {
33
+ error: 'openapiHandler: ctx.state._ingeniumApp is missing — apply the integration shim from src/_pending-context-additions/openapi.ts',
34
+ },
35
+ 500,
36
+ )
37
+ return
38
+ }
39
+
40
+ const journalLen = readJournalLen(app)
41
+ const descriptorVer = readDescriptorVersion(app)
42
+
43
+ if (
44
+ cache === null
45
+ || cache.journalLen !== journalLen
46
+ || cache.descriptorVer !== descriptorVer
47
+ ) {
48
+ cache = { journalLen, descriptorVer, spec: generateOpenApi(app, opts) }
49
+ }
50
+ ctx.json(cache.spec)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Pull the owning IngeniumApp off the context. We stash a reference under
56
+ * `ctx.state._ingeniumApp` from the integration shim in app.ts; if it's
57
+ * missing (older app, no integration), fall back to `ctx.state.app`.
58
+ */
59
+ function resolveApp(ctx: IngeniumContext): IngeniumApp | null {
60
+ const fromState = (ctx.state as Record<string, unknown>)._ingeniumApp
61
+ ?? (ctx.state as Record<string, unknown>).app
62
+ return (fromState as IngeniumApp | undefined) ?? null
63
+ }
64
+
65
+ function readJournalLen(app: IngeniumApp): number {
66
+ const router = (app as unknown as { router?: { journal: unknown[] } }).router
67
+ return router?.journal?.length ?? 0
68
+ }
69
+
70
+ function readDescriptorVersion(app: IngeniumApp): number {
71
+ // Bumped by `app.describe()` so descriptor edits invalidate the cache too.
72
+ return (app as unknown as { _routeDescriptorVersion?: number })._routeDescriptorVersion ?? 0
73
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Minimal OpenAPI 3.1 type surface — just enough for what Ingenium
3
+ * generates today. Not a full mirror of the spec; we keep it intentionally
4
+ * narrow so the generator's outputs typecheck without dragging in a
5
+ * 4000-line ambient module.
6
+ *
7
+ * Spec reference: https://spec.openapis.org/oas/v3.1.0
8
+ *
9
+ * Intentional gaps (out of scope for v0.0.1, document-as-TODO):
10
+ * - `callbacks`, `links`, `webhooks` — none of these have a registration
11
+ * surface in Ingenium yet.
12
+ * - `discriminator` / `xml` — schema is passed through verbatim, so callers
13
+ * can include these themselves if they want to.
14
+ * - `pathItems` under `components` — we only emit operations under `paths`.
15
+ */
16
+
17
+ /** Permissive `$ref`-or-inline union used in many slots. */
18
+ export type Ref<T> = T | { $ref: string }
19
+
20
+ /** A JSON Schema fragment (per OpenAPI 3.1 = full JSON Schema 2020-12). */
21
+ export type Schema = Record<string, unknown>
22
+
23
+ /** Where a parameter lives. Ingenium only emits `path` from route syntax. */
24
+ export type ParameterLocation = 'query' | 'header' | 'path' | 'cookie'
25
+
26
+ export interface Parameter {
27
+ name: string
28
+ in: ParameterLocation
29
+ description?: string
30
+ required?: boolean
31
+ deprecated?: boolean
32
+ schema?: Schema
33
+ example?: unknown
34
+ examples?: Record<string, Example>
35
+ /** Free-form passthrough so callers can stamp `x-*` extensions. */
36
+ [extension: `x-${string}`]: unknown
37
+ }
38
+
39
+ export interface Example {
40
+ summary?: string
41
+ description?: string
42
+ value?: unknown
43
+ externalValue?: string
44
+ }
45
+
46
+ export interface MediaType {
47
+ schema?: Schema
48
+ example?: unknown
49
+ examples?: Record<string, Example>
50
+ }
51
+
52
+ export interface RequestBody {
53
+ description?: string
54
+ required?: boolean
55
+ content: Record<string, MediaType>
56
+ }
57
+
58
+ export interface Response {
59
+ description: string
60
+ headers?: Record<string, Ref<Header>>
61
+ content?: Record<string, MediaType>
62
+ }
63
+
64
+ export interface Header {
65
+ description?: string
66
+ required?: boolean
67
+ deprecated?: boolean
68
+ schema?: Schema
69
+ }
70
+
71
+ export interface SecurityRequirement {
72
+ [name: string]: string[]
73
+ }
74
+
75
+ export interface Operation {
76
+ tags?: string[]
77
+ summary?: string
78
+ description?: string
79
+ operationId?: string
80
+ parameters?: Parameter[]
81
+ requestBody?: RequestBody
82
+ responses?: Record<string, Response>
83
+ deprecated?: boolean
84
+ security?: SecurityRequirement[]
85
+ /** Free-form passthrough so callers can stamp `x-*` extensions. */
86
+ [extension: `x-${string}`]: unknown
87
+ }
88
+
89
+ export type PathItem = Partial<Record<
90
+ 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace',
91
+ Operation
92
+ >> & {
93
+ summary?: string
94
+ description?: string
95
+ parameters?: Parameter[]
96
+ }
97
+
98
+ export interface Server {
99
+ url: string
100
+ description?: string
101
+ variables?: Record<string, { default: string; enum?: string[]; description?: string }>
102
+ }
103
+
104
+ export interface Tag {
105
+ name: string
106
+ description?: string
107
+ }
108
+
109
+ export interface Info {
110
+ title: string
111
+ version: string
112
+ description?: string
113
+ termsOfService?: string
114
+ contact?: { name?: string; url?: string; email?: string }
115
+ license?: { name: string; url?: string; identifier?: string }
116
+ summary?: string
117
+ }
118
+
119
+ export interface Components {
120
+ schemas?: Record<string, Schema>
121
+ responses?: Record<string, Response>
122
+ parameters?: Record<string, Parameter>
123
+ examples?: Record<string, Example>
124
+ requestBodies?: Record<string, RequestBody>
125
+ headers?: Record<string, Header>
126
+ securitySchemes?: Record<string, SecurityScheme>
127
+ }
128
+
129
+ /**
130
+ * Loose security-scheme type — we do not interpret this, we pass it through
131
+ * verbatim to `components.securitySchemes`. Use the OpenAPI spec's full
132
+ * shape (apiKey / http / oauth2 / openIdConnect / mutualTLS).
133
+ */
134
+ export type SecurityScheme = Record<string, unknown>
135
+
136
+ export interface OpenApiSpec {
137
+ openapi: '3.1.0'
138
+ info: Info
139
+ servers?: Server[]
140
+ paths: Record<string, PathItem>
141
+ components?: Components
142
+ security?: SecurityRequirement[]
143
+ tags?: Tag[]
144
+ externalDocs?: { url: string; description?: string }
145
+ }
@@ -0,0 +1,100 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import type { Decorator, EagerDecorator, LazyDecorator } from './types.ts'
3
+
4
+ interface LazyEntry {
5
+ name: string
6
+ factory: LazyDecorator
7
+ }
8
+
9
+ interface EagerEntry {
10
+ name: string
11
+ factory: EagerDecorator
12
+ }
13
+
14
+ /**
15
+ * Per-app registry of decorators. Decorators are NOT installed onto
16
+ * `IngeniumContext.prototype` — that would mutate a shared class and leak across
17
+ * apps in the same process. Instead, `applyTo(ctx)` writes them onto each
18
+ * pooled context instance at request start.
19
+ *
20
+ * # Lazy vs eager — perf trade-off
21
+ *
22
+ * - **Lazy** (`decorate`): installed via `Object.defineProperty` with a
23
+ * getter. The getter computes on first access, then redefines itself as
24
+ * a plain data property holding the resolved value (define-self pattern).
25
+ * Subsequent reads cost a normal property access — no getter call. Use
26
+ * this for values that may not be needed (e.g. `ctx.user` on public
27
+ * routes), and for values whose computation is non-trivial (DB lookups,
28
+ * token decoding).
29
+ *
30
+ * - **Eager** (`decorateRequest`): factory is invoked at request start,
31
+ * value assigned directly. Use this for cheap values that virtually every
32
+ * handler will read (e.g. `ctx.startedAt = Date.now()`). Avoids the
33
+ * per-property getter-redefinition overhead.
34
+ *
35
+ * # Pool reuse
36
+ *
37
+ * Pooled contexts are reset between requests; the `IngeniumContext.reset()`
38
+ * method does not know about decorator names, so each request re-applies
39
+ * via `applyTo(ctx)`. Lazy `defineProperty` overwrites the previous slot
40
+ * configuration cleanly; eager assignment overwrites the previous value.
41
+ * No leakage between requests.
42
+ */
43
+ export class DecoratorRegistry {
44
+ private readonly lazy: LazyEntry[] = []
45
+ private readonly eager: EagerEntry[] = []
46
+
47
+ /** Register a lazy decorator. Computed on first access; cached thereafter. */
48
+ decorate<T>(name: string, factory: LazyDecorator<T>): void {
49
+ this.lazy.push({ name, factory: factory as Decorator })
50
+ }
51
+
52
+ /** Register an eager decorator. Factory runs at the start of every request. */
53
+ decorateRequest<T>(name: string, factory: EagerDecorator<T>): void {
54
+ this.eager.push({ name, factory: factory as Decorator })
55
+ }
56
+
57
+ /** True when any decorator is registered (lets the hot path skip work). */
58
+ hasAny(): boolean {
59
+ return this.lazy.length > 0 || this.eager.length > 0
60
+ }
61
+
62
+ /**
63
+ * Install all registered decorators onto a single context instance.
64
+ * Called by `app.handle` after `onRequest` hooks and before dispatch.
65
+ */
66
+ applyTo(ctx: IngeniumContext): void {
67
+ // Eager: simple assignment.
68
+ for (let i = 0; i < this.eager.length; i++) {
69
+ const entry = this.eager[i]!
70
+ ;(ctx as unknown as Record<string, unknown>)[entry.name] = entry.factory(ctx)
71
+ }
72
+ // Lazy: define-self getter.
73
+ for (let i = 0; i < this.lazy.length; i++) {
74
+ const entry = this.lazy[i]!
75
+ defineLazy(ctx, entry.name, entry.factory)
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Install a getter that computes once, then replaces itself with a plain
82
+ * data property holding the resolved value. After first access, reads are
83
+ * free of any getter overhead.
84
+ */
85
+ function defineLazy(ctx: IngeniumContext, name: string, factory: LazyDecorator): void {
86
+ Object.defineProperty(ctx, name, {
87
+ configurable: true,
88
+ enumerable: true,
89
+ get() {
90
+ const value = factory(ctx)
91
+ Object.defineProperty(ctx, name, {
92
+ configurable: true,
93
+ enumerable: true,
94
+ writable: true,
95
+ value,
96
+ })
97
+ return value
98
+ },
99
+ })
100
+ }
@@ -0,0 +1,114 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import type {
3
+ Hooks,
4
+ OnComposeHook,
5
+ OnErrorHook,
6
+ OnRequestHook,
7
+ OnResponseHook,
8
+ OnRouteHook,
9
+ RegistrationEvent,
10
+ } from './types.ts'
11
+
12
+ /**
13
+ * Registry for the framework's lifecycle hooks. Implements the `Hooks`
14
+ * interface that plugins call into via `app.hooks`.
15
+ *
16
+ * # Execution model
17
+ *
18
+ * `runOn*` methods invoke listeners **sequentially** in registration order,
19
+ * awaiting each one before invoking the next. This is intentional:
20
+ *
21
+ * - Predictable ordering: a hook registered first ALWAYS observes state
22
+ * before a hook registered later. Plugins can rely on this.
23
+ * - Backpressure: an async hook (e.g. fetching a session) blocks
24
+ * subsequent hooks, ensuring downstream hooks see decorated state.
25
+ * - Errors short-circuit `runOnRequest`/`runOnResponse`/`runOnCompose` —
26
+ * they propagate to the caller (the request enters the error boundary).
27
+ *
28
+ * `runOnError` is the exception: it wraps each listener in a try/catch and
29
+ * swallows throws, because observers must not mask the original error.
30
+ *
31
+ * # Reading order
32
+ *
33
+ * Within a single `run*` call, listeners run in the order they were added.
34
+ * Across hook types within one request, the order is fixed by `app.handle`:
35
+ *
36
+ * onRequest -> (decorators applied) -> dispatch -> onResponse
37
+ * \-> onError (on throw)
38
+ *
39
+ * # Hot-path note
40
+ *
41
+ * Each `runOn*` returns immediately if no listeners are registered. Callers
42
+ * should additionally check `hasAny()` (or the per-hook `has*()` helpers) to
43
+ * skip the `await` entirely on the zero-plugin path.
44
+ */
45
+ export class HooksRegistry implements Hooks {
46
+ private readonly _onRoute: OnRouteHook[] = []
47
+ private readonly _onCompose: OnComposeHook[] = []
48
+ private readonly _onRequest: OnRequestHook[] = []
49
+ private readonly _onResponse: OnResponseHook[] = []
50
+ private readonly _onError: OnErrorHook[] = []
51
+
52
+ // ───── Registration (Hooks interface) ──────────────────────────────────
53
+
54
+ onRoute(fn: OnRouteHook): void { this._onRoute.push(fn) }
55
+ onCompose(fn: OnComposeHook): void { this._onCompose.push(fn) }
56
+ onRequest(fn: OnRequestHook): void { this._onRequest.push(fn) }
57
+ onResponse(fn: OnResponseHook): void { this._onResponse.push(fn) }
58
+ onError(fn: OnErrorHook): void { this._onError.push(fn) }
59
+
60
+ // ───── Hot-path checks ─────────────────────────────────────────────────
61
+
62
+ /** True when any request-time hook is registered. */
63
+ hasAny(): boolean {
64
+ return (
65
+ this._onRequest.length > 0 ||
66
+ this._onResponse.length > 0 ||
67
+ this._onError.length > 0
68
+ )
69
+ }
70
+
71
+ hasOnRequest(): boolean { return this._onRequest.length > 0 }
72
+ hasOnResponse(): boolean { return this._onResponse.length > 0 }
73
+ hasOnError(): boolean { return this._onError.length > 0 }
74
+ hasOnRoute(): boolean { return this._onRoute.length > 0 }
75
+ hasOnCompose(): boolean { return this._onCompose.length > 0 }
76
+
77
+ // ───── Run (sequential, registration order) ────────────────────────────
78
+
79
+ /** Synchronous — `onRoute` is invoked during composition for each route. */
80
+ runOnRoute(event: RegistrationEvent): void {
81
+ for (let i = 0; i < this._onRoute.length; i++) {
82
+ this._onRoute[i]!(event)
83
+ }
84
+ }
85
+
86
+ async runOnCompose(): Promise<void> {
87
+ for (let i = 0; i < this._onCompose.length; i++) {
88
+ await this._onCompose[i]!()
89
+ }
90
+ }
91
+
92
+ async runOnRequest(ctx: IngeniumContext): Promise<void> {
93
+ for (let i = 0; i < this._onRequest.length; i++) {
94
+ await this._onRequest[i]!(ctx)
95
+ }
96
+ }
97
+
98
+ async runOnResponse(ctx: IngeniumContext): Promise<void> {
99
+ for (let i = 0; i < this._onResponse.length; i++) {
100
+ await this._onResponse[i]!(ctx)
101
+ }
102
+ }
103
+
104
+ /** Observation only. Throws inside listeners are swallowed. */
105
+ async runOnError(err: unknown, ctx: IngeniumContext): Promise<void> {
106
+ for (let i = 0; i < this._onError.length; i++) {
107
+ try {
108
+ await this._onError[i]!(err, ctx)
109
+ } catch {
110
+ // Swallow — observers must not mask the original error.
111
+ }
112
+ }
113
+ }
114
+ }