@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. 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
+ }