@znight/http 0.0.0-stage → 0.1.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 +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/src/path.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route path grammar of `@znight/http`, shared by the runtime (adapters, router) and the compiler
|
|
3
|
+
* extension (ZNIGHT007 diagnostics, `fullPath`). This module is **pure**: no imports, no I/O, no
|
|
4
|
+
* global state, so the compiler can load it without loading the HTTP runtime.
|
|
5
|
+
*
|
|
6
|
+
* Grammar (after normalisation, see {@link normalizePath}):
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* path = "/" | ( "/" segment )+
|
|
10
|
+
* segment = static | param | wildcard ; wildcard only as the last segment
|
|
11
|
+
* static = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "!" / "$" / "&" / "'" / "+" / "," / ";" / "=" / "@" )
|
|
12
|
+
* param = ":" name ; name = [A-Za-z_][A-Za-z0-9_]*, unique in the path
|
|
13
|
+
* wildcard = "*" ; matches the rest of the path (may be empty)
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Matching is case-sensitive and works on the raw (percent-encoded) request path. A `:param`
|
|
17
|
+
* matches one non-empty segment; `/files/*` matches `/files/` and `/files/a/b` (not `/files`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A segment of a parsed route path. */
|
|
21
|
+
export type RouteSegment =
|
|
22
|
+
| { readonly kind: 'static'; readonly value: string }
|
|
23
|
+
| { readonly kind: 'param'; readonly name: string }
|
|
24
|
+
| { readonly kind: 'wildcard' }
|
|
25
|
+
|
|
26
|
+
/** A validated, normalised route path. */
|
|
27
|
+
export interface RoutePath {
|
|
28
|
+
/** Normalised path (`/users/:id`, `/` for the root). */
|
|
29
|
+
readonly path: string
|
|
30
|
+
readonly segments: readonly RouteSegment[]
|
|
31
|
+
/** Parameter names, in order. The wildcard, when present, is last and named {@link WILDCARD_PARAM}. */
|
|
32
|
+
readonly params: readonly string[]
|
|
33
|
+
readonly wildcard: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Matching shape: param names are erased (`/users/:` for `/users/:id`). Two routes with the same
|
|
36
|
+
* method and shape conflict.
|
|
37
|
+
*/
|
|
38
|
+
readonly shape: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A grammar violation. */
|
|
42
|
+
export interface RoutePathIssue {
|
|
43
|
+
readonly message: string
|
|
44
|
+
/** The offending segment, when the issue concerns one. */
|
|
45
|
+
readonly segment?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Name of the parameter holding the value matched by a trailing wildcard (`ctx.params['*']`). */
|
|
49
|
+
export const WILDCARD_PARAM = '*'
|
|
50
|
+
|
|
51
|
+
/** Characters allowed in static segments. */
|
|
52
|
+
const STATIC_SEGMENT = /^[A-Za-z0-9\-._~!$&'+,;=@]+$/
|
|
53
|
+
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
54
|
+
const EDGE_SLASHES = /^\/+|\/+$/g
|
|
55
|
+
|
|
56
|
+
/** Error thrown by {@link parseRoutePath}. */
|
|
57
|
+
export class RoutePathError extends TypeError {
|
|
58
|
+
readonly path: string
|
|
59
|
+
readonly issue: RoutePathIssue
|
|
60
|
+
|
|
61
|
+
constructor(path: string, issue: RoutePathIssue) {
|
|
62
|
+
super(`Invalid route path "${path}": ${issue.message}`)
|
|
63
|
+
this.name = 'RoutePathError'
|
|
64
|
+
this.path = path
|
|
65
|
+
this.issue = issue
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalises a path: exactly one leading slash, no trailing slash (except the root `/`). Inner
|
|
71
|
+
* content is left untouched (`//` stays and is rejected by {@link parseRoutePath}).
|
|
72
|
+
*
|
|
73
|
+
* `normalizePath('users/')` → `/users`, `normalizePath('')` → `/`.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizePath(path: string): string {
|
|
76
|
+
const trimmed = path.replace(EDGE_SLASHES, '')
|
|
77
|
+
return trimmed === '' ? '/' : `/${trimmed}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Joins path parts (global prefix, controller base path, route path) with single slashes and
|
|
82
|
+
* normalises the result. Empty parts and `/` are ignored.
|
|
83
|
+
*
|
|
84
|
+
* `joinPaths('/api', '/users/', ':id')` → `/api/users/:id`, `joinPaths('/', '')` → `/`.
|
|
85
|
+
*/
|
|
86
|
+
export function joinPaths(...parts: readonly string[]): string {
|
|
87
|
+
let result = ''
|
|
88
|
+
for (const part of parts) {
|
|
89
|
+
const trimmed = part.replace(EDGE_SLASHES, '')
|
|
90
|
+
if (trimmed !== '') result += `/${trimmed}`
|
|
91
|
+
}
|
|
92
|
+
return result === '' ? '/' : result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validates `path` (normalised first). Returns the first issue, or `undefined` when it is valid. */
|
|
96
|
+
export function checkRoutePath(path: string): RoutePathIssue | undefined {
|
|
97
|
+
const result = analyse(path)
|
|
98
|
+
return 'message' in result ? result : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parses and validates a route path (normalised first).
|
|
103
|
+
*
|
|
104
|
+
* @throws RoutePathError when the path violates the grammar.
|
|
105
|
+
*/
|
|
106
|
+
export function parseRoutePath(path: string): RoutePath {
|
|
107
|
+
const result = analyse(path)
|
|
108
|
+
if ('message' in result) throw new RoutePathError(path, result)
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function analyse(input: string): RoutePath | RoutePathIssue {
|
|
113
|
+
if (typeof input !== 'string') return { message: 'a route path must be a string' }
|
|
114
|
+
if (input.includes('?') || input.includes('#')) {
|
|
115
|
+
return { message: 'a route path cannot contain "?" or "#" (query strings are not part of routes)' }
|
|
116
|
+
}
|
|
117
|
+
const path = normalizePath(input)
|
|
118
|
+
if (path === '/') return { path, segments: [], params: [], wildcard: false, shape: '/' }
|
|
119
|
+
const raw = path.slice(1).split('/')
|
|
120
|
+
const segments: RouteSegment[] = []
|
|
121
|
+
const params: string[] = []
|
|
122
|
+
for (let index = 0; index < raw.length; index++) {
|
|
123
|
+
const segment = raw[index] as string
|
|
124
|
+
if (segment === '') return { message: 'empty segment ("//")' }
|
|
125
|
+
if (segment === '*') {
|
|
126
|
+
if (index !== raw.length - 1) {
|
|
127
|
+
return { message: 'the wildcard "*" must be the last segment', segment }
|
|
128
|
+
}
|
|
129
|
+
segments.push({ kind: 'wildcard' })
|
|
130
|
+
params.push(WILDCARD_PARAM)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (segment.includes('*')) {
|
|
134
|
+
return { message: `"*" must be a whole segment (found "${segment}")`, segment }
|
|
135
|
+
}
|
|
136
|
+
if (segment.startsWith(':')) {
|
|
137
|
+
const name = segment.slice(1)
|
|
138
|
+
if (!PARAM_NAME.test(name)) {
|
|
139
|
+
return {
|
|
140
|
+
message: `invalid parameter name "${name}" (expected letters, digits and "_", not starting with a digit)`,
|
|
141
|
+
segment,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (params.includes(name)) return { message: `duplicate parameter ":${name}"`, segment }
|
|
145
|
+
segments.push({ kind: 'param', name })
|
|
146
|
+
params.push(name)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (segment.includes(':')) {
|
|
150
|
+
return { message: `":" is only allowed at the start of a segment (found "${segment}")`, segment }
|
|
151
|
+
}
|
|
152
|
+
if (segment === '.' || segment === '..') return { message: 'dot segments ("." and "..") are not allowed', segment }
|
|
153
|
+
if (!STATIC_SEGMENT.test(segment)) {
|
|
154
|
+
const bad = [...segment].find(char => !STATIC_SEGMENT.test(char)) ?? segment
|
|
155
|
+
return {
|
|
156
|
+
message: `invalid character ${JSON.stringify(bad)} in "${segment}" (allowed: letters, digits and - . _ ~ ! $ & ' + , ; = @)`,
|
|
157
|
+
segment,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
segments.push({ kind: 'static', value: segment })
|
|
161
|
+
}
|
|
162
|
+
return { path, segments, params, wildcard: params.at(-1) === WILDCARD_PARAM, shape: shapeOf(segments) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function shapeOf(segments: readonly RouteSegment[]): string {
|
|
166
|
+
let shape = ''
|
|
167
|
+
for (const segment of segments) {
|
|
168
|
+
if (segment.kind === 'static') shape += `/${segment.value}`
|
|
169
|
+
else if (segment.kind === 'param') shape += '/:'
|
|
170
|
+
else shape += '/*'
|
|
171
|
+
}
|
|
172
|
+
return shape === '' ? '/' : shape
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Matches a raw request path (no query string) against a route. Returns the **raw**
|
|
177
|
+
* (percent-encoded) parameter values, or `undefined` when the path does not match.
|
|
178
|
+
*/
|
|
179
|
+
export function matchRoutePath(route: RoutePath, path: string): Record<string, string> | undefined {
|
|
180
|
+
if (path.charCodeAt(0) !== 47 /* / */) return undefined
|
|
181
|
+
const segments = route.segments
|
|
182
|
+
if (segments.length === 0) return path === '/' ? {} : undefined
|
|
183
|
+
const params: Record<string, string> = {}
|
|
184
|
+
let position = 1
|
|
185
|
+
for (let index = 0; index < segments.length; index++) {
|
|
186
|
+
const segment = segments[index] as RouteSegment
|
|
187
|
+
if (segment.kind === 'wildcard') {
|
|
188
|
+
params[WILDCARD_PARAM] = path.slice(position)
|
|
189
|
+
return params
|
|
190
|
+
}
|
|
191
|
+
const slash = path.indexOf('/', position)
|
|
192
|
+
const end = slash === -1 ? path.length : slash
|
|
193
|
+
const value = path.slice(position, end)
|
|
194
|
+
if (segment.kind === 'static') {
|
|
195
|
+
if (value !== segment.value) return undefined
|
|
196
|
+
} else {
|
|
197
|
+
if (value === '') return undefined
|
|
198
|
+
params[segment.name] = value
|
|
199
|
+
}
|
|
200
|
+
if (index === segments.length - 1) return end === path.length ? params : undefined
|
|
201
|
+
if (slash === -1) return undefined
|
|
202
|
+
position = slash + 1
|
|
203
|
+
}
|
|
204
|
+
return params
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Decodes a raw parameter value. Returns `undefined` when it is not valid percent-encoding. */
|
|
208
|
+
export function decodePathValue(value: string): string | undefined {
|
|
209
|
+
if (!value.includes('%')) return value
|
|
210
|
+
try {
|
|
211
|
+
return decodeURIComponent(value)
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const RANK = { static: 0, param: 1, wildcard: 2 } as const
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Orders routes from the most to the least specific (static segments before parameters before the
|
|
221
|
+
* wildcard, compared segment by segment), then by path. Registering routes in this order makes
|
|
222
|
+
* first-match routers (Express, Hono) behave like specificity-based ones (Fastify, the built-in
|
|
223
|
+
* router).
|
|
224
|
+
*/
|
|
225
|
+
export function compareRoutePaths(a: RoutePath, b: RoutePath): number {
|
|
226
|
+
const length = Math.min(a.segments.length, b.segments.length)
|
|
227
|
+
for (let index = 0; index < length; index++) {
|
|
228
|
+
const left = a.segments[index] as RouteSegment
|
|
229
|
+
const right = b.segments[index] as RouteSegment
|
|
230
|
+
const rank = RANK[left.kind] - RANK[right.kind]
|
|
231
|
+
if (rank !== 0) return rank
|
|
232
|
+
if (left.kind === 'static' && right.kind === 'static' && left.value !== right.value) {
|
|
233
|
+
return left.value < right.value ? -1 : 1
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (a.segments.length !== b.segments.length) return a.segments.length - b.segments.length
|
|
237
|
+
return compareStrings(a.path, b.path)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Ordinal string comparison (locale-independent, deterministic). */
|
|
241
|
+
export function compareStrings(a: string, b: string): number {
|
|
242
|
+
if (a === b) return 0
|
|
243
|
+
return a < b ? -1 : 1
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Fastify / find-my-way pattern (`/users/:id`, `/files/*`). */
|
|
247
|
+
export function toFastifyPath(route: RoutePath): string {
|
|
248
|
+
return toColonPath(route, '*')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Hono pattern. The wildcard becomes a regexp parameter so that `/files/*` does not match `/files`
|
|
253
|
+
* (Hono's own `*` does).
|
|
254
|
+
*/
|
|
255
|
+
export function toHonoPath(route: RoutePath): string {
|
|
256
|
+
return toColonPath(route, ':__znight_wildcard{.*}')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function toColonPath(route: RoutePath, wildcard: string): string {
|
|
260
|
+
if (route.segments.length === 0) return '/'
|
|
261
|
+
let result = ''
|
|
262
|
+
for (const segment of route.segments) {
|
|
263
|
+
if (segment.kind === 'static') result += `/${segment.value}`
|
|
264
|
+
else if (segment.kind === 'param') result += `/:${segment.name}`
|
|
265
|
+
else result += `/${wildcard}`
|
|
266
|
+
}
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Anchored regular expression source matching the raw path of a route, without capturing groups
|
|
272
|
+
* (`^\/users\/[^/]+$`). Used by the Express adapter, which matches regular expressions against the
|
|
273
|
+
* raw path.
|
|
274
|
+
*/
|
|
275
|
+
export function toRegExpSource(route: RoutePath): string {
|
|
276
|
+
if (route.segments.length === 0) return '^\\/$'
|
|
277
|
+
let source = '^'
|
|
278
|
+
for (const segment of route.segments) {
|
|
279
|
+
if (segment.kind === 'static') source += `\\/${escapeRegExp(segment.value)}`
|
|
280
|
+
else if (segment.kind === 'param') source += '\\/[^/]+'
|
|
281
|
+
else source += '\\/.*'
|
|
282
|
+
}
|
|
283
|
+
return `${source}$`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function escapeRegExp(value: string): string {
|
|
287
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
288
|
+
}
|