@znight/http 0.0.0-stage → 0.1.0
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
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|