@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
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A copy of the [Standard Schema v1](https://standardschema.dev) interface, flattened (no namespace,
3
+ * so the file stays erasable). Every compliant library (Zod, Valibot, ArkType, Effect Schema...)
4
+ * produces values assignable to {@link StandardSchemaV1}: no adapter and no dependency are needed.
5
+ */
6
+
7
+ /** A schema implementing the Standard Schema v1 interface. */
8
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
9
+ /** The Standard Schema properties. */
10
+ readonly '~standard': StandardSchemaV1Props<Input, Output>
11
+ }
12
+
13
+ /** The Standard Schema properties. */
14
+ export interface StandardSchemaV1Props<Input = unknown, Output = Input> {
15
+ /** The version number of the standard. */
16
+ readonly version: 1
17
+ /** The vendor name of the schema library. */
18
+ readonly vendor: string
19
+ /** Validates an unknown input value (synchronously or not). */
20
+ readonly validate: (
21
+ value: unknown,
22
+ options?: StandardSchemaV1Options | undefined,
23
+ ) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>
24
+ /** Inferred types associated with the schema. */
25
+ readonly types?: StandardSchemaV1Types<Input, Output> | undefined
26
+ }
27
+
28
+ /** Options of {@link StandardSchemaV1Props.validate}. */
29
+ export interface StandardSchemaV1Options {
30
+ /** Vendor-specific parameters. */
31
+ readonly libraryOptions?: Record<string, unknown> | undefined
32
+ }
33
+
34
+ /** Result of {@link StandardSchemaV1Props.validate}. */
35
+ export type StandardSchemaV1Result<Output> = StandardSchemaV1SuccessResult<Output> | StandardSchemaV1FailureResult
36
+
37
+ /** Successful validation: the (possibly transformed) output value. */
38
+ export interface StandardSchemaV1SuccessResult<Output> {
39
+ readonly value: Output
40
+ readonly issues?: undefined
41
+ }
42
+
43
+ /** Failed validation. */
44
+ export interface StandardSchemaV1FailureResult {
45
+ readonly issues: readonly StandardSchemaV1Issue[]
46
+ }
47
+
48
+ /** One validation issue. */
49
+ export interface StandardSchemaV1Issue {
50
+ readonly message: string
51
+ readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[] | undefined
52
+ }
53
+
54
+ /** A path segment of an issue. */
55
+ export interface StandardSchemaV1PathSegment {
56
+ readonly key: PropertyKey
57
+ }
58
+
59
+ /** Input and output types of a schema. */
60
+ export interface StandardSchemaV1Types<Input = unknown, Output = Input> {
61
+ readonly input: Input
62
+ readonly output: Output
63
+ }
64
+
65
+ /** The input type of a schema. */
66
+ export type InferSchemaInput<S extends StandardSchemaV1> = NonNullable<S['~standard']['types']>['input']
67
+
68
+ /** The output type of a schema (what handlers receive). */
69
+ export type InferSchemaOutput<S extends StandardSchemaV1> = NonNullable<S['~standard']['types']>['output']
70
+
71
+ /** Whether `value` looks like a Standard Schema v1 (has a `~standard` object with a `validate` function). */
72
+ export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
73
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
74
+ const props = (value as { '~standard'?: unknown })['~standard']
75
+ return (
76
+ props !== null &&
77
+ typeof props === 'object' &&
78
+ (props as { version?: unknown }).version === 1 &&
79
+ typeof (props as { validate?: unknown }).validate === 'function'
80
+ )
81
+ }
package/src/status.ts ADDED
@@ -0,0 +1,78 @@
1
+ /** Standard reason phrases (RFC 9110 and common extensions), used in error bodies. */
2
+ const REASONS: Readonly<Record<number, string>> = {
3
+ 100: 'Continue',
4
+ 101: 'Switching Protocols',
5
+ 102: 'Processing',
6
+ 103: 'Early Hints',
7
+ 200: 'OK',
8
+ 201: 'Created',
9
+ 202: 'Accepted',
10
+ 203: 'Non-Authoritative Information',
11
+ 204: 'No Content',
12
+ 205: 'Reset Content',
13
+ 206: 'Partial Content',
14
+ 207: 'Multi-Status',
15
+ 300: 'Multiple Choices',
16
+ 301: 'Moved Permanently',
17
+ 302: 'Found',
18
+ 303: 'See Other',
19
+ 304: 'Not Modified',
20
+ 307: 'Temporary Redirect',
21
+ 308: 'Permanent Redirect',
22
+ 400: 'Bad Request',
23
+ 401: 'Unauthorized',
24
+ 402: 'Payment Required',
25
+ 403: 'Forbidden',
26
+ 404: 'Not Found',
27
+ 405: 'Method Not Allowed',
28
+ 406: 'Not Acceptable',
29
+ 407: 'Proxy Authentication Required',
30
+ 408: 'Request Timeout',
31
+ 409: 'Conflict',
32
+ 410: 'Gone',
33
+ 411: 'Length Required',
34
+ 412: 'Precondition Failed',
35
+ 413: 'Payload Too Large',
36
+ 414: 'URI Too Long',
37
+ 415: 'Unsupported Media Type',
38
+ 416: 'Range Not Satisfiable',
39
+ 417: 'Expectation Failed',
40
+ 418: "I'm a Teapot",
41
+ 421: 'Misdirected Request',
42
+ 422: 'Unprocessable Entity',
43
+ 423: 'Locked',
44
+ 424: 'Failed Dependency',
45
+ 425: 'Too Early',
46
+ 426: 'Upgrade Required',
47
+ 428: 'Precondition Required',
48
+ 429: 'Too Many Requests',
49
+ 431: 'Request Header Fields Too Large',
50
+ 451: 'Unavailable For Legal Reasons',
51
+ 500: 'Internal Server Error',
52
+ 501: 'Not Implemented',
53
+ 502: 'Bad Gateway',
54
+ 503: 'Service Unavailable',
55
+ 504: 'Gateway Timeout',
56
+ 505: 'HTTP Version Not Supported',
57
+ 507: 'Insufficient Storage',
58
+ 508: 'Loop Detected',
59
+ 511: 'Network Authentication Required',
60
+ }
61
+
62
+ /** Reason phrase of `status` (`404` → `Not Found`), or a generic one for unknown codes. */
63
+ export function statusText(status: number): string {
64
+ const reason = REASONS[status]
65
+ if (reason !== undefined) return reason
66
+ if (status >= 500) return 'Server Error'
67
+ return status >= 400 ? 'Client Error' : 'Unknown Status'
68
+ }
69
+
70
+ /** Whether `status` is an integer in the 100–599 range. */
71
+ export function isValidStatus(status: unknown): status is number {
72
+ return typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599
73
+ }
74
+
75
+ /** Statuses whose responses never have a body. */
76
+ export function isBodylessStatus(status: number): boolean {
77
+ return status === 204 || status === 304 || (status >= 100 && status < 200)
78
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Web stream helpers shared by the pipeline and the adapters (internal): conversion of handler
3
+ * stream sources (`ReadableStream`, Node.js `Readable`, async iterables) to byte streams, tracking of
4
+ * response streams (request scope lifetime) and small constructors.
5
+ */
6
+ import { AsyncLocalStorage } from 'node:async_hooks'
7
+
8
+ /**
9
+ * A stream source accepted by `HttpResponse.stream()`: a Web `ReadableStream`, a Node.js `Readable`
10
+ * or any async iterable (async generator...) of `Uint8Array` / `string` chunks (strings are encoded
11
+ * as UTF-8).
12
+ */
13
+ export type StreamSource = ReadableStream<Uint8Array | string> | AsyncIterable<Uint8Array | string>
14
+
15
+ /** Runs a function in a captured async context (`AsyncLocalStorage.snapshot()`). */
16
+ export type ContextRunner = <R>(fn: () => R) => R
17
+
18
+ const ENCODER = new TextEncoder()
19
+
20
+ /** The current async context, to run stream reads in it later (the request scope of a response). */
21
+ export function captureContext(): ContextRunner {
22
+ const snapshot = (AsyncLocalStorage as { snapshot?: () => ContextRunner }).snapshot
23
+ return typeof snapshot === 'function' ? snapshot() : run => run()
24
+ }
25
+
26
+ /** Whether `value` is a stream source (Web stream, Node.js readable or async iterable). */
27
+ export function isStreamSource(value: unknown): value is StreamSource {
28
+ return (
29
+ value instanceof ReadableStream ||
30
+ (value !== null &&
31
+ typeof value === 'object' &&
32
+ typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function')
33
+ )
34
+ }
35
+
36
+ /** Byte streams created by {@link toByteStream} (already normalised: not wrapped again). */
37
+ const BYTE_STREAMS = new WeakSet<ReadableStream<Uint8Array>>()
38
+
39
+ /** Whether `stream` was created by {@link toByteStream}. */
40
+ export function isByteStream(stream: ReadableStream<unknown>): boolean {
41
+ return BYTE_STREAMS.has(stream as ReadableStream<Uint8Array>)
42
+ }
43
+
44
+ /**
45
+ * Converts a stream source to a pull-based byte stream: the source is read only when the consumer
46
+ * reads (backpressure), strings are encoded as UTF-8, `ArrayBuffer`s and views are converted, empty
47
+ * chunks are skipped and cancelling the stream cancels the source (`iterator.return()`, which
48
+ * destroys a Node.js `Readable`; a `Readable` that was never read is destroyed directly).
49
+ *
50
+ * @throws TypeError (as a stream error) for chunks that are neither bytes nor strings.
51
+ */
52
+ export function toByteStream(source: StreamSource): ReadableStream<Uint8Array> {
53
+ let iterator: AsyncIterator<unknown> | undefined
54
+ let reader: ChunkReader | undefined
55
+ const next = (): Promise<IteratorResult<unknown>> => {
56
+ if (source instanceof ReadableStream) {
57
+ const current: ChunkReader = reader ?? source.getReader()
58
+ reader = current
59
+ return current.read() as Promise<IteratorResult<unknown>>
60
+ }
61
+ iterator ??= source[Symbol.asyncIterator]()
62
+ return iterator.next()
63
+ }
64
+ const stream = new ReadableStream<Uint8Array>(
65
+ {
66
+ async pull(controller) {
67
+ for (;;) {
68
+ const result = await next()
69
+ if (result.done === true) {
70
+ controller.close()
71
+ return
72
+ }
73
+ const chunk = toChunk(result.value)
74
+ if (chunk.byteLength > 0) {
75
+ controller.enqueue(chunk)
76
+ return
77
+ }
78
+ }
79
+ },
80
+ async cancel(reason) {
81
+ if (reader !== undefined) await reader.cancel(reason)
82
+ else if (source instanceof ReadableStream) await source.cancel(reason)
83
+ else if (iterator === undefined) {
84
+ // never read: an async generator has nothing to clean up, a Node.js `Readable` holds its resource
85
+ const destroyable = source as { destroy?: unknown }
86
+ if (typeof destroyable.destroy === 'function') destroyable.destroy()
87
+ } else await iterator.return?.(reason)
88
+ },
89
+ },
90
+ { highWaterMark: 0 },
91
+ )
92
+ BYTE_STREAMS.add(stream)
93
+ return stream
94
+ }
95
+
96
+ /** The part of a stream reader used here (Node.js and Bun declare different reader types). */
97
+ interface ChunkReader {
98
+ read(): Promise<{ readonly done?: boolean; readonly value?: unknown }>
99
+ cancel(reason?: unknown): Promise<void>
100
+ }
101
+
102
+ function toChunk(value: unknown): Uint8Array {
103
+ if (value instanceof Uint8Array) return value
104
+ if (typeof value === 'string') return ENCODER.encode(value)
105
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
106
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
107
+ throw new TypeError(
108
+ `Invalid stream chunk: expected a Uint8Array or a string, received ${value === null ? 'null' : typeof value}`,
109
+ )
110
+ }
111
+
112
+ /** The part of a byte stream reader used by {@link trackStream}. */
113
+ interface ByteReader {
114
+ read(): Promise<{ readonly done?: boolean; readonly value?: Uint8Array | undefined }>
115
+ cancel(reason?: unknown): Promise<void>
116
+ }
117
+
118
+ /** A response stream whose completion is observed. */
119
+ export interface TrackedStream {
120
+ readonly stream: ReadableStream<Uint8Array>
121
+ /**
122
+ * Resolves once the source was fully read (`undefined`), cancelled by the consumer (`undefined`:
123
+ * client disconnected, HEAD...) or failed (the error). Never rejects.
124
+ */
125
+ readonly done: Promise<unknown>
126
+ }
127
+
128
+ /**
129
+ * Wraps a response body so that the pipeline knows when it has been fully sent (or abandoned) and
130
+ * reads the source in `run` (the async context of the request scope, so that stream producers can
131
+ * use request-scoped providers). The wrapper is pull-based (no read-ahead): backpressure is the
132
+ * consumer's. A `Blob` is read through `blob.stream()`, opened on the first read only. `onCancel`
133
+ * runs when the consumer cancels the stream (the client left), before the source is cancelled.
134
+ */
135
+ export function trackStream(
136
+ body: ReadableStream<Uint8Array> | Blob,
137
+ run: ContextRunner,
138
+ onCancel?: () => void,
139
+ ): TrackedStream {
140
+ let reader: ByteReader | undefined = body instanceof Blob ? undefined : body.getReader()
141
+ let settle: (error: unknown) => void = () => undefined
142
+ const done = new Promise<unknown>(resolve => {
143
+ settle = resolve
144
+ })
145
+ let finished = false
146
+ const finish = (error?: unknown): void => {
147
+ if (finished) return
148
+ finished = true
149
+ settle(error)
150
+ }
151
+ const stream = new ReadableStream<Uint8Array>(
152
+ {
153
+ async pull(controller) {
154
+ let result: Awaited<ReturnType<ByteReader['read']>>
155
+ try {
156
+ result = await run(() => {
157
+ reader ??= (body as Blob).stream().getReader()
158
+ return reader.read()
159
+ })
160
+ } catch (error) {
161
+ finish(error)
162
+ throw error
163
+ }
164
+ if (result.done === true) {
165
+ controller.close()
166
+ finish()
167
+ return
168
+ }
169
+ controller.enqueue(result.value as Uint8Array)
170
+ },
171
+ async cancel(reason) {
172
+ onCancel?.()
173
+ try {
174
+ const current = reader
175
+ if (current !== undefined) await run(() => current.cancel(reason))
176
+ } catch {
177
+ // the source failed while being cancelled: nothing left to send anyway
178
+ } finally {
179
+ finish()
180
+ }
181
+ },
182
+ },
183
+ { highWaterMark: 0 },
184
+ )
185
+ return { stream, done }
186
+ }
187
+
188
+ /** A stream failing with `error` on first read. */
189
+ export function failedStream(error: unknown): ReadableStream<Uint8Array> {
190
+ return new ReadableStream<Uint8Array>({
191
+ start(controller) {
192
+ controller.error(error)
193
+ },
194
+ })
195
+ }
196
+
197
+ /** Cancels a stream, ignoring failures (unconsumed response bodies). */
198
+ export function discardStream(stream: ReadableStream<Uint8Array> | null | undefined): void {
199
+ if (stream instanceof ReadableStream && !stream.locked) stream.cancel().catch(() => undefined)
200
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { TypedToken } from '@znight/core'
2
+ import type { HttpContext, RequestId } from './context.ts'
3
+ import type { HttpPlugin } from './plugin.ts'
4
+
5
+ /**
6
+ * Tokens of the values `@znight/http` supplies to the container. `HttpContext` and `RequestId` are
7
+ * request-scoped externals (declared in the package manifest): inject them in request-scoped
8
+ * providers or through `Lazy<HttpContext>`.
9
+ */
10
+ export const HttpTokens: {
11
+ /** The {@link HttpContext} of the current request (request scope). */
12
+ readonly Context: TypedToken<HttpContext>
13
+ /** The id of the current request (request scope). */
14
+ readonly RequestId: TypedToken<RequestId>
15
+ /** The {@link HttpPlugin} instance (singleton). */
16
+ readonly Plugin: TypedToken<HttpPlugin>
17
+ } = Object.freeze({
18
+ Context: '@znight/http#HttpContext',
19
+ RequestId: '@znight/http#RequestId',
20
+ Plugin: '@znight/http#HttpPlugin',
21
+ })
package/src/uploads.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * File uploads: options of `@UploadedFile` / `@UploadedFiles`, the plugin-wide limits, the
3
+ * `uploads` helpers used by generated `invoke` functions, and the body plan of a route (body limit
4
+ * and multipart policy).
5
+ */
6
+ import type { HttpContext } from './context.ts'
7
+ import type { HttpRouteDefinition, HttpUploadDefinition } from './metadata.ts'
8
+ import type { FileRule, MultipartPolicy } from './multipart.ts'
9
+ import type { ByteSize } from './size.ts'
10
+
11
+ import { BadRequestException, issue } from './exceptions.ts'
12
+
13
+ /** Options of `@UploadedFile` (static: evaluated at build time). */
14
+ export interface UploadedFileOptions {
15
+ /**
16
+ * Answer 400 when no file is sent. Default: `true`, unless the parameter is optional
17
+ * (`file?: File`, `File | undefined`).
18
+ */
19
+ readonly required?: boolean
20
+ /** Maximum size of the file (`5_000_000`, `'5mb'`; `kb` = 1024 bytes). Default: plugin `uploads.maxFileSize`. */
21
+ readonly maxSize?: ByteSize
22
+ /**
23
+ * Accepted media types, as declared by the client: `'image/png'`, `'image/*'`, `'*\/*'`. Other
24
+ * types answer 415. Default: any.
25
+ */
26
+ readonly mimeTypes?: readonly string[]
27
+ }
28
+
29
+ /** Options of `@UploadedFiles` (static: evaluated at build time). */
30
+ export interface UploadedFilesOptions extends UploadedFileOptions {
31
+ /** Maximum number of files (413 above). Default: plugin `uploads.maxFiles`. */
32
+ readonly maxFiles?: number
33
+ }
34
+
35
+ /** Plugin-wide upload limits (`HttpPlugin.configure({ uploads })`). */
36
+ export interface UploadLimits {
37
+ /** Default maximum size of one file (`maxSize` of upload parameters). Default: `'10mb'`. */
38
+ readonly maxFileSize?: ByteSize
39
+ /** Default maximum number of files of `@UploadedFiles` (and of multipart bodies of other routes). Default: `10`. */
40
+ readonly maxFiles?: number
41
+ /** Maximum number of parts (text fields and files) of a multipart body (413 above). Default: `1000`. */
42
+ readonly maxParts?: number
43
+ /** Maximum size of one text field of a multipart body (413 above). Default: `'1mb'`. */
44
+ readonly maxFieldSize?: ByteSize
45
+ }
46
+
47
+ /** {@link UploadLimits} with defaults applied, in bytes. */
48
+ export interface ResolvedUploadLimits {
49
+ readonly maxFileSize: number
50
+ readonly maxFiles: number
51
+ readonly maxParts: number
52
+ readonly maxFieldSize: number
53
+ }
54
+
55
+ /** Default upload limits. */
56
+ export const DEFAULT_UPLOAD_LIMITS: ResolvedUploadLimits = Object.freeze({
57
+ maxFileSize: 10 * 1024 * 1024,
58
+ maxFiles: 10,
59
+ maxParts: 1000,
60
+ maxFieldSize: 1024 * 1024,
61
+ })
62
+
63
+ /** How the body of a route is read (internal). */
64
+ export interface BodyPlan {
65
+ /** Maximum body size in bytes (`Infinity`: no limit). */
66
+ readonly limit: number
67
+ /** Rules applied to `multipart/form-data` bodies. */
68
+ readonly multipart: MultipartPolicy
69
+ /** The route declares upload parameters: non-multipart bodies answer 415. */
70
+ readonly upload: boolean
71
+ }
72
+
73
+ /**
74
+ * The body plan of a route: `@BodyLimit` (`route.bodyLimit`) or, for upload routes, the sum of the
75
+ * file limits plus the plugin `bodyLimit` (room for text fields and multipart framing), else the
76
+ * plugin `bodyLimit`.
77
+ */
78
+ export function bodyPlan(
79
+ route: Pick<HttpRouteDefinition, 'bodyLimit' | 'uploads'>,
80
+ bodyLimit: number,
81
+ limits: ResolvedUploadLimits,
82
+ ): BodyPlan {
83
+ const definitions = route.uploads ?? []
84
+ const upload = definitions.length > 0
85
+ const rules: FileRule[] = upload
86
+ ? definitions.map(definition => fileRule(definition, limits))
87
+ : [
88
+ {
89
+ field: undefined,
90
+ multiple: true,
91
+ maxSize: limits.maxFileSize,
92
+ maxFiles: limits.maxFiles,
93
+ mimeTypes: undefined,
94
+ },
95
+ ]
96
+ let limit = route.bodyLimit
97
+ if (limit === undefined) {
98
+ limit = bodyLimit
99
+ if (upload) for (const rule of rules) limit += rule.maxSize * rule.maxFiles
100
+ }
101
+ return { limit, upload, multipart: { rules, maxParts: limits.maxParts, maxFieldSize: limits.maxFieldSize } }
102
+ }
103
+
104
+ function fileRule(definition: HttpUploadDefinition, limits: ResolvedUploadLimits): FileRule {
105
+ const multiple = definition.multiple === true
106
+ return {
107
+ field: definition.field,
108
+ multiple,
109
+ maxSize: definition.maxSize ?? limits.maxFileSize,
110
+ maxFiles: multiple ? (definition.maxFiles ?? limits.maxFiles) : 1,
111
+ mimeTypes: definition.mimeTypes,
112
+ }
113
+ }
114
+
115
+ // -------------------------------------------------------------------------------------------------
116
+ // Helpers of the generated code
117
+ // -------------------------------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Reads uploaded files from the context (`ctx.files`) in generated `invoke` functions
121
+ * (`@UploadedFile('avatar') avatar: File` → `uploads.file(ctx, 'avatar')`). `field` `undefined`
122
+ * means any field. Limits were enforced while the body was read; these helpers check presence.
123
+ */
124
+ export interface Uploads {
125
+ /** The file of `field`; 400 `Missing file "field"` when absent. */
126
+ file(ctx: HttpContext, field: string | undefined): File
127
+ /** The file of `field`, `undefined` when absent. */
128
+ optionalFile(ctx: HttpContext, field: string | undefined): File | undefined
129
+ /** The files of `field` (at least one); 400 `Missing files "field"` when none. */
130
+ files(ctx: HttpContext, field: string | undefined): File[]
131
+ /** The files of `field`, possibly none. */
132
+ optionalFiles(ctx: HttpContext, field: string | undefined): File[]
133
+ }
134
+
135
+ const NO_FILES: readonly File[] = Object.freeze([])
136
+
137
+ function filesOf(ctx: HttpContext, field: string | undefined): readonly File[] {
138
+ const files = ctx.files
139
+ if (field !== undefined) return Object.hasOwn(files, field) ? (files[field] ?? NO_FILES) : NO_FILES
140
+ const all: File[] = []
141
+ for (const name of Object.keys(files)) all.push(...(files[name] ?? NO_FILES))
142
+ return all
143
+ }
144
+
145
+ function missing(ctx: HttpContext, field: string | undefined, multiple: boolean): BadRequestException {
146
+ const what = multiple ? 'files' : 'file'
147
+ if (field === undefined) return new BadRequestException(`Missing ${what}`)
148
+ const body = ctx.body
149
+ if (body !== null && typeof body === 'object' && Object.hasOwn(body, field)) {
150
+ return new BadRequestException(
151
+ `Expected a file in field ${JSON.stringify(field)}, received a text value`,
152
+ issue(field, 'Expected a file, received a text value'),
153
+ )
154
+ }
155
+ return new BadRequestException(`Missing ${what} ${JSON.stringify(field)}`, issue(field, `Missing ${what}`))
156
+ }
157
+
158
+ /** The helpers of the generated code (`import { uploads } from '@znight/http'`). */
159
+ export const uploads: Uploads = Object.freeze({
160
+ file(ctx: HttpContext, field: string | undefined): File {
161
+ const [file] = filesOf(ctx, field)
162
+ if (file === undefined) throw missing(ctx, field, false)
163
+ return file
164
+ },
165
+ optionalFile(ctx: HttpContext, field: string | undefined): File | undefined {
166
+ return filesOf(ctx, field)[0]
167
+ },
168
+ files(ctx: HttpContext, field: string | undefined): File[] {
169
+ const files = filesOf(ctx, field)
170
+ if (files.length === 0) throw missing(ctx, field, true)
171
+ return [...files]
172
+ },
173
+ optionalFiles(ctx: HttpContext, field: string | undefined): File[] {
174
+ return [...filesOf(ctx, field)]
175
+ },
176
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** Internal helpers (not part of the public API). */
2
+
3
+ /** Whether `value` is a promise-like object (has a callable `then`). */
4
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
5
+ return (
6
+ value !== null &&
7
+ (typeof value === 'object' || typeof value === 'function') &&
8
+ typeof (value as { then?: unknown }).then === 'function'
9
+ )
10
+ }
11
+
12
+ /** Message of any thrown value. */
13
+ export function errorMessage(error: unknown): string {
14
+ if (error instanceof Error) return error.message
15
+ if (typeof error === 'string') return error
16
+ try {
17
+ return String(error)
18
+ } catch {
19
+ return Object.prototype.toString.call(error)
20
+ }
21
+ }
22
+
23
+ /** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */
24
+ export function emptyRecord<V>(): Record<string, V> {
25
+ return Object.create(null) as Record<string, V>
26
+ }
27
+
28
+ /** Whether the code runs on Bun. */
29
+ export function isBun(): boolean {
30
+ return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
31
+ }
@@ -0,0 +1,65 @@
1
+ import { ValidationException } from './exceptions.ts'
2
+ import {
3
+ type InferSchemaOutput,
4
+ isStandardSchema,
5
+ type StandardSchemaV1,
6
+ type StandardSchemaV1Issue,
7
+ type StandardSchemaV1Result,
8
+ } from './standard-schema.ts'
9
+ import { isThenable } from './utils.ts'
10
+
11
+ /**
12
+ * Validates `value` with a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType...)
13
+ * and returns the schema **output** (defaults applied, transforms run, unknown keys stripped when
14
+ * the schema strips them).
15
+ *
16
+ * Returns synchronously when the schema validates synchronously, a promise otherwise (async
17
+ * refinements): generated code always `await`s it.
18
+ *
19
+ * @param location what is validated, used in the error message: `body`, `query`, `query "page"`,
20
+ * `param "id"`, `headers`, `cookie "sid"`, `cookies`...
21
+ * @throws ValidationException (400, `details: [{ path, message }]`) when the value is invalid. For
22
+ * cookies (`cookie "name"`, `cookies`) the issue messages are replaced with `Invalid value`: some
23
+ * libraries quote the received value in them (Valibot, ArkType), and cookies often carry tokens.
24
+ * @throws TypeError when `schema` is not a Standard Schema v1 (a programming error → 500).
25
+ */
26
+ export function validate<S extends StandardSchemaV1>(
27
+ schema: S,
28
+ value: unknown,
29
+ location: string,
30
+ ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>> {
31
+ if (!isStandardSchema(schema)) {
32
+ throw new TypeError(`The schema of ${location} is not a Standard Schema v1 (missing "~standard.validate")`)
33
+ }
34
+ const result = schema['~standard'].validate(value) as
35
+ | StandardSchemaV1Result<InferSchemaOutput<S>>
36
+ | Promise<StandardSchemaV1Result<InferSchemaOutput<S>>>
37
+ if (isThenable(result)) return Promise.resolve(result).then(settled => unwrap(settled, location))
38
+ return unwrap(result, location)
39
+ }
40
+
41
+ function unwrap<T>(result: StandardSchemaV1Result<T>, location: string): T {
42
+ if (result.issues !== undefined) {
43
+ throw new ValidationException(location, isCookieLocation(location) ? result.issues.map(redact) : result.issues)
44
+ }
45
+ return result.value
46
+ }
47
+
48
+ /** The message of a redacted issue (cookie values are never echoed). */
49
+ const REDACTED = 'Invalid value'
50
+
51
+ function isCookieLocation(location: string): boolean {
52
+ return location === 'cookies' || location.startsWith('cookie ')
53
+ }
54
+
55
+ /**
56
+ * The issue with the keys of its path only: its message may quote the value (`Received "eyJ..."`),
57
+ * and so may the path segments of some libraries (Valibot keeps `input` and `value` there).
58
+ */
59
+ function redact(issue: StandardSchemaV1Issue): StandardSchemaV1Issue {
60
+ if (issue.path === undefined) return { message: REDACTED }
61
+ const path = issue.path.map(segment =>
62
+ typeof segment === 'object' && segment !== null ? { key: segment.key } : segment,
63
+ )
64
+ return { message: REDACTED, path }
65
+ }