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,309 @@
|
|
|
1
|
+
import type { ComposedHandler } from '../middleware/types.ts'
|
|
2
|
+
import type { HttpMethod } from './types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One node in the radix trie. Static segments win over `:param`, which wins
|
|
6
|
+
* over `*wild`. Method-specific composed handlers live at the leaf.
|
|
7
|
+
*/
|
|
8
|
+
export class TrieNode {
|
|
9
|
+
staticChildren: Map<string, TrieNode> = new Map()
|
|
10
|
+
paramChild: TrieNode | null = null
|
|
11
|
+
paramName: string | null = null
|
|
12
|
+
wildcardChild: TrieNode | null = null
|
|
13
|
+
wildcardName: string | null = null
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compiled inline constraint for this node *as a param child*, or `null`
|
|
17
|
+
* when the param is unconstrained. Set at insert time when the registered
|
|
18
|
+
* segment carries a `(regex)` group (e.g. `:id(\d+)`). The `find()` hot
|
|
19
|
+
* path loads this field and only runs `.test()` when it is non-null, so
|
|
20
|
+
* unconstrained routes pay zero extra cost. Lives on the param node itself
|
|
21
|
+
* (the child) so the matcher can test it the instant it descends.
|
|
22
|
+
*/
|
|
23
|
+
paramConstraint: RegExp | null = null
|
|
24
|
+
|
|
25
|
+
/** Per-method composed handlers, populated by `RouteRegistry` after compose. */
|
|
26
|
+
handlers: Partial<Record<HttpMethod, ComposedHandler>> = {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Param names accumulated from root → this node, in order. Cached so
|
|
30
|
+
* matching can fill the params object in O(k) without re-walking parents.
|
|
31
|
+
*/
|
|
32
|
+
paramNames: readonly string[] = []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Result of a trie lookup. `params` may be empty if the route had none. */
|
|
36
|
+
export interface MatchResult {
|
|
37
|
+
handler: ComposedHandler
|
|
38
|
+
params: Record<string, string>
|
|
39
|
+
/** Methods registered at this leaf — used to populate `Allow` on 405. */
|
|
40
|
+
allowed: readonly HttpMethod[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why a lookup failed. */
|
|
44
|
+
export type MatchMiss =
|
|
45
|
+
| { kind: 'not-found' }
|
|
46
|
+
| { kind: 'method-not-allowed'; allowed: readonly HttpMethod[] }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Radix trie router. `insert()` is called at registration; `find()` runs on
|
|
50
|
+
* every request and is the single hottest piece of code in the framework.
|
|
51
|
+
*/
|
|
52
|
+
export class RouterTrie {
|
|
53
|
+
readonly root = new TrieNode()
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walks/creates trie nodes for the path. Returns the leaf where handlers
|
|
57
|
+
* should be attached. Path must start with `/`.
|
|
58
|
+
*/
|
|
59
|
+
insert(path: string): TrieNode {
|
|
60
|
+
if (path.length === 0 || path[0] !== '/') {
|
|
61
|
+
throw new Error(`Route path must start with '/': ${path}`)
|
|
62
|
+
}
|
|
63
|
+
const segments = splitPath(path)
|
|
64
|
+
let node = this.root
|
|
65
|
+
const paramNames: string[] = []
|
|
66
|
+
|
|
67
|
+
for (const seg of segments) {
|
|
68
|
+
if (seg.length === 0) continue
|
|
69
|
+
|
|
70
|
+
if (seg[0] === ':') {
|
|
71
|
+
const { name, constraint } = parseParamSegment(seg)
|
|
72
|
+
if (!node.paramChild) {
|
|
73
|
+
node.paramChild = new TrieNode()
|
|
74
|
+
node.paramName = name
|
|
75
|
+
node.paramChild.paramConstraint = constraint
|
|
76
|
+
} else {
|
|
77
|
+
if (node.paramName !== name) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Conflicting param names at the same trie level: ':${node.paramName}' vs ':${name}'`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
// Same name, but the constraint may differ. Rule: a constraint is a
|
|
83
|
+
// promise about the shape of matched segments; two registrations of
|
|
84
|
+
// the same param must agree on that promise. We require the *source*
|
|
85
|
+
// of the compiled regex to match (or both to be unconstrained).
|
|
86
|
+
// Last-writer-wins would silently let one route's `:id(\d+)` weaken
|
|
87
|
+
// another's, which is a footgun, so we throw instead — same style as
|
|
88
|
+
// the param-name conflict above.
|
|
89
|
+
const existing = node.paramChild.paramConstraint
|
|
90
|
+
const incoming = constraint
|
|
91
|
+
const existingSrc = existing ? existing.source : ''
|
|
92
|
+
const incomingSrc = incoming ? incoming.source : ''
|
|
93
|
+
if (existingSrc !== incomingSrc) {
|
|
94
|
+
const fmt = (n: string, c: RegExp | null) => (c ? `:${n}(...)` : `:${n}`)
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Conflicting param constraints at the same trie level: ` +
|
|
97
|
+
`'${fmt(name, existing)}' vs '${fmt(name, incoming)}' for param ':${name}'`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
paramNames.push(name)
|
|
102
|
+
node = node.paramChild
|
|
103
|
+
} else if (seg[0] === '*') {
|
|
104
|
+
const name = seg.slice(1) || 'wildcard'
|
|
105
|
+
if (!node.wildcardChild) {
|
|
106
|
+
node.wildcardChild = new TrieNode()
|
|
107
|
+
node.wildcardName = name
|
|
108
|
+
}
|
|
109
|
+
paramNames.push(name)
|
|
110
|
+
node = node.wildcardChild
|
|
111
|
+
// Wildcards consume the rest of the path; later segments are ignored
|
|
112
|
+
// by the matcher anyway, but we don't allow more registration past *.
|
|
113
|
+
break
|
|
114
|
+
} else {
|
|
115
|
+
let child = node.staticChildren.get(seg)
|
|
116
|
+
if (!child) {
|
|
117
|
+
child = new TrieNode()
|
|
118
|
+
node.staticChildren.set(seg, child)
|
|
119
|
+
}
|
|
120
|
+
node = child
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
node.paramNames = paramNames
|
|
125
|
+
return node
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Look up a route. Iterative with single-level wildcard backtrack — if the
|
|
130
|
+
* static/param walk dead-ends and an ancestor had a `*wildcard` child, we
|
|
131
|
+
* retry from the wildcard with the remaining segments. Backtrack frames
|
|
132
|
+
* are tracked in a small stack (one per wildcard ancestor encountered).
|
|
133
|
+
*/
|
|
134
|
+
find(method: HttpMethod, path: string): MatchResult | MatchMiss {
|
|
135
|
+
const segments = splitPath(path)
|
|
136
|
+
|
|
137
|
+
// Stack of wildcard fallback points. `paramCount` is paramValues.length
|
|
138
|
+
// captured at the moment the fallback was recorded — used to truncate
|
|
139
|
+
// any params collected past that point if we have to backtrack.
|
|
140
|
+
type Fallback = { node: TrieNode; segIdx: number; paramCount: number }
|
|
141
|
+
const fallbacks: Fallback[] = []
|
|
142
|
+
|
|
143
|
+
let node: TrieNode = this.root
|
|
144
|
+
const paramValues: string[] = []
|
|
145
|
+
let consumedWildcard = false
|
|
146
|
+
|
|
147
|
+
let i = 0
|
|
148
|
+
walk: while (i < segments.length) {
|
|
149
|
+
const seg = segments[i]!
|
|
150
|
+
if (seg.length === 0) {
|
|
151
|
+
i++
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Record a wildcard fallback at this level *before* descending, so a
|
|
156
|
+
// later miss can rewind and consume from `i` greedily via the wildcard.
|
|
157
|
+
if (node.wildcardChild) {
|
|
158
|
+
fallbacks.push({ node: node.wildcardChild, segIdx: i, paramCount: paramValues.length })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const staticChild = node.staticChildren.get(seg)
|
|
162
|
+
if (staticChild) {
|
|
163
|
+
node = staticChild
|
|
164
|
+
i++
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (node.paramChild) {
|
|
169
|
+
// Hot-path gate: only constrained params (a tiny minority of routes)
|
|
170
|
+
// run a regex. The field load + `!== null` is one branch; unconstrained
|
|
171
|
+
// routes never touch `.test()`, so they pay zero extra cost. Constrained
|
|
172
|
+
// routes pay one anchored `.test()` against the raw segment — justified
|
|
173
|
+
// because the alternative (matching, then 404ing in user code) is both
|
|
174
|
+
// slower and wrong (a sibling `*wild` could legitimately catch it).
|
|
175
|
+
const constraint = node.paramChild.paramConstraint
|
|
176
|
+
if (constraint === null || constraint.test(seg)) {
|
|
177
|
+
paramValues.push(decodeParam(seg))
|
|
178
|
+
node = node.paramChild
|
|
179
|
+
i++
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
// Constraint miss: this param branch is dead. Fall through to the
|
|
183
|
+
// wildcard child / backtrack stack exactly as a structural dead-end
|
|
184
|
+
// would, so a sibling `*wild` can still catch the segment, else 404.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (node.wildcardChild) {
|
|
188
|
+
const remaining = segments.slice(i).join('/')
|
|
189
|
+
paramValues.push(decodeParam(remaining))
|
|
190
|
+
node = node.wildcardChild
|
|
191
|
+
consumedWildcard = true
|
|
192
|
+
break walk
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Dead end — try the most recent wildcard fallback.
|
|
196
|
+
const fb = fallbacks.pop()
|
|
197
|
+
if (!fb) return { kind: 'not-found' }
|
|
198
|
+
const remaining = segments.slice(fb.segIdx).join('/')
|
|
199
|
+
paramValues.length = fb.paramCount
|
|
200
|
+
paramValues.push(decodeParam(remaining))
|
|
201
|
+
node = fb.node
|
|
202
|
+
consumedWildcard = true
|
|
203
|
+
break walk
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!consumedWildcard && !node.handlers[method] && fallbacks.length > 0) {
|
|
207
|
+
// Walked to the end via static/param but no handler at this leaf —
|
|
208
|
+
// try the most recent wildcard fallback.
|
|
209
|
+
const fb = fallbacks.pop()!
|
|
210
|
+
const remaining = segments.slice(fb.segIdx).join('/')
|
|
211
|
+
paramValues.length = fb.paramCount
|
|
212
|
+
paramValues.push(decodeParam(remaining))
|
|
213
|
+
node = fb.node
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const handler = node.handlers[method]
|
|
217
|
+
if (!handler) {
|
|
218
|
+
const allowed = Object.keys(node.handlers) as HttpMethod[]
|
|
219
|
+
if (allowed.length === 0) return { kind: 'not-found' }
|
|
220
|
+
return { kind: 'method-not-allowed', allowed }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build params object — one allocation per match. Stable key insertion
|
|
224
|
+
// order (driven by paramNames recorded at insert time) → V8 monomorphic
|
|
225
|
+
// hidden class per route.
|
|
226
|
+
let params: Record<string, string>
|
|
227
|
+
if (node.paramNames.length === 0) {
|
|
228
|
+
params = EMPTY_PARAMS
|
|
229
|
+
} else {
|
|
230
|
+
params = {}
|
|
231
|
+
for (let j = 0; j < node.paramNames.length; j++) {
|
|
232
|
+
params[node.paramNames[j]!] = paramValues[j]!
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
handler,
|
|
238
|
+
params,
|
|
239
|
+
allowed: Object.keys(node.handlers) as HttpMethod[],
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Shared frozen empty-params sentinel — exported so the dispatcher can identity-compare. */
|
|
245
|
+
export const EMPTY_PARAMS: Record<string, string> = Object.freeze({}) as Record<string, string>
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Split `/users/42/posts` into `['users', '42', 'posts']`. Reused by both
|
|
249
|
+
* insert and lookup, so the implementation is hot — manual scan beats
|
|
250
|
+
* `String.prototype.split` only marginally; we use split for clarity.
|
|
251
|
+
*/
|
|
252
|
+
function splitPath(path: string): string[] {
|
|
253
|
+
// Strip leading and trailing slash for a stable segment count.
|
|
254
|
+
let start = 0
|
|
255
|
+
let end = path.length
|
|
256
|
+
if (start < end && path[start] === '/') start++
|
|
257
|
+
if (end > start && path[end - 1] === '/') end--
|
|
258
|
+
if (start >= end) return []
|
|
259
|
+
return path.slice(start, end).split('/')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse a `:param` segment into its clean name and an optional compiled
|
|
264
|
+
* constraint. Runs at *insert* time only (never on the request hot path), so
|
|
265
|
+
* the regex compile cost is paid once per route.
|
|
266
|
+
*
|
|
267
|
+
* Grammar handled:
|
|
268
|
+
* `:name` → { name: 'name', constraint: null }
|
|
269
|
+
* `:name?` → { name: 'name', constraint: null }
|
|
270
|
+
* `:name(regex)` → { name: 'name', constraint: /^(?:regex)$/ }
|
|
271
|
+
* `:name(regex)?` → { name: 'name', constraint: /^(?:regex)$/ }
|
|
272
|
+
*
|
|
273
|
+
* The constraint is anchored with `^(?:...)$` so it must match the *entire*
|
|
274
|
+
* segment — a partial match (e.g. `\d+` against `12a`) does NOT slip through.
|
|
275
|
+
* The `(?:...)` wrapper keeps the user's alternations (`a|b`) from binding
|
|
276
|
+
* past the anchors.
|
|
277
|
+
*/
|
|
278
|
+
function parseParamSegment(seg: string): { name: string; constraint: RegExp | null } {
|
|
279
|
+
// Strip the leading ':'.
|
|
280
|
+
let body = seg.slice(1)
|
|
281
|
+
|
|
282
|
+
// Strip a trailing optional marker first; it sits *after* the constraint
|
|
283
|
+
// group in the documented grammar (`:id(\d+)?`).
|
|
284
|
+
if (body.length > 0 && body[body.length - 1] === '?') {
|
|
285
|
+
body = body.slice(0, -1)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Detect a constraint group: `name(regex)`. The regex body is everything
|
|
289
|
+
// between the first '(' and the final ')'.
|
|
290
|
+
const open = body.indexOf('(')
|
|
291
|
+
if (open !== -1 && body[body.length - 1] === ')') {
|
|
292
|
+
const name = body.slice(0, open)
|
|
293
|
+
const pattern = body.slice(open + 1, -1)
|
|
294
|
+
// Anchor fully so the constraint governs the whole segment.
|
|
295
|
+
return { name, constraint: new RegExp(`^(?:${pattern})$`) }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { name: body, constraint: null }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function decodeParam(raw: string): string {
|
|
302
|
+
// Hot path: skip decode if no '%' present.
|
|
303
|
+
if (raw.indexOf('%') === -1) return raw
|
|
304
|
+
try {
|
|
305
|
+
return decodeURIComponent(raw)
|
|
306
|
+
} catch {
|
|
307
|
+
return raw
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** HTTP methods supported by the router. */
|
|
2
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
|
|
3
|
+
|
|
4
|
+
export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
5
|
+
'GET',
|
|
6
|
+
'POST',
|
|
7
|
+
'PUT',
|
|
8
|
+
'PATCH',
|
|
9
|
+
'DELETE',
|
|
10
|
+
'HEAD',
|
|
11
|
+
'OPTIONS',
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recursively extracts named params from a path string at the type level.
|
|
16
|
+
*
|
|
17
|
+
* - `:name` → required string
|
|
18
|
+
* - `:name?` → optional string (becomes `string | undefined`)
|
|
19
|
+
* - `:name(regex)` → required string. The regex is type-stripped here, but
|
|
20
|
+
* the constraint IS enforced at runtime by the trie
|
|
21
|
+
* (`RouterTrie.find` tests the segment against the
|
|
22
|
+
* compiled, fully-anchored pattern), so the `string`
|
|
23
|
+
* type is honest about the matched shape.
|
|
24
|
+
* Note: number-narrowing (typing `:id(\d+)` as `number`)
|
|
25
|
+
* remains deferred — constrained params stay `string`.
|
|
26
|
+
* - `*name` → required string (greedy wildcard tail)
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* type P = ExtractParams<'/users/:id(\\d+)/posts/:slug?'>
|
|
30
|
+
* // { id: string; slug?: string | undefined }
|
|
31
|
+
*/
|
|
32
|
+
export type ExtractParams<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}`
|
|
33
|
+
? ParamRecord<Param> & ExtractParams<`/${Rest}`>
|
|
34
|
+
: Path extends `${string}:${infer Param}`
|
|
35
|
+
? ParamRecord<Param>
|
|
36
|
+
: Path extends `${string}*${infer Wild}`
|
|
37
|
+
? { [K in Wild]: string }
|
|
38
|
+
: EmptyParams
|
|
39
|
+
|
|
40
|
+
type EmptyParams = Record<string, never>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drop a single parenthesized constraint group from a param name.
|
|
44
|
+
* `id(\\d+)` → `id`
|
|
45
|
+
* `id(\\d+)?` → `id?` (optionality marker preserved for ParamRecord)
|
|
46
|
+
* `id` → `id` (no-op when no constraint present)
|
|
47
|
+
*/
|
|
48
|
+
type StripConstraint<P extends string> = P extends `${infer Head}(${string})${infer Tail}`
|
|
49
|
+
? `${Head}${Tail}`
|
|
50
|
+
: P
|
|
51
|
+
|
|
52
|
+
type ParamRecord<P extends string> = StripConstraint<P> extends `${infer Name}?`
|
|
53
|
+
? { [K in Name]?: string }
|
|
54
|
+
: { [K in StripConstraint<P>]: string }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, zero-dependency type definitions for the
|
|
3
|
+
* [Standard Schema](https://standardschema.dev) v1 spec.
|
|
4
|
+
*
|
|
5
|
+
* Ingenium detects schemas implementing this contract on
|
|
6
|
+
* `IngeniumBody.json(schema)` and runs their `validate` function, mapping
|
|
7
|
+
* `issues` into a `IngeniumValidationError` with field-level messages.
|
|
8
|
+
*
|
|
9
|
+
* We intentionally do NOT import `@standard-schema/spec` to keep the
|
|
10
|
+
* core dependency-free. These types mirror the spec exactly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A successful validation result: parsed/transformed value. */
|
|
14
|
+
export interface StandardSuccessResult<TOut> {
|
|
15
|
+
readonly value: TOut
|
|
16
|
+
readonly issues?: undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A single issue describing why validation failed at a particular path. */
|
|
20
|
+
export interface StandardIssue {
|
|
21
|
+
readonly message: string
|
|
22
|
+
readonly path?: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A path segment may be a bare key OR an object with a `key` property. */
|
|
26
|
+
export interface StandardPathSegment {
|
|
27
|
+
readonly key: PropertyKey
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A failed validation result: one or more issues. */
|
|
31
|
+
export interface StandardFailureResult {
|
|
32
|
+
readonly issues: ReadonlyArray<StandardIssue>
|
|
33
|
+
readonly value?: undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Standard Schema validation result: success XOR failure. */
|
|
37
|
+
export type StandardResult<TOut> = StandardSuccessResult<TOut> | StandardFailureResult
|
|
38
|
+
|
|
39
|
+
/** The properties living under the `~standard` key. */
|
|
40
|
+
export interface StandardSchemaV1Props<TIn = unknown, TOut = TIn> {
|
|
41
|
+
readonly version: 1
|
|
42
|
+
readonly vendor: string
|
|
43
|
+
readonly validate: (input: unknown) => StandardResult<TOut> | Promise<StandardResult<TOut>>
|
|
44
|
+
readonly types?: {
|
|
45
|
+
readonly input: TIn
|
|
46
|
+
readonly output: TOut
|
|
47
|
+
} | undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The Standard Schema v1 interface — anything with a `~standard` property. */
|
|
51
|
+
export interface StandardSchemaV1<TIn = unknown, TOut = TIn> {
|
|
52
|
+
readonly '~standard': StandardSchemaV1Props<TIn, TOut>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Type guard: is `x` a Standard Schema v1?
|
|
57
|
+
*
|
|
58
|
+
* Checks for the `~standard` property and that its `version` is `1` and
|
|
59
|
+
* `validate` is a function. Cheap enough to call on every body.json() call.
|
|
60
|
+
*/
|
|
61
|
+
export function isStandardSchema(x: unknown): x is StandardSchemaV1 {
|
|
62
|
+
if (x === null || (typeof x !== 'object' && typeof x !== 'function')) return false
|
|
63
|
+
const std = (x as { '~standard'?: unknown })['~standard']
|
|
64
|
+
if (std === null || typeof std !== 'object') return false
|
|
65
|
+
const props = std as { version?: unknown; validate?: unknown }
|
|
66
|
+
return props.version === 1 && typeof props.validate === 'function'
|
|
67
|
+
}
|