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,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
|
+
}
|