@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security headers (RFC 0001 §5.5): validated and computed once at boot, then added to every reply
|
|
3
|
+
* (except `@Res()` raw replies) when the handler, an interceptor, an exception or a hook wrapper did
|
|
4
|
+
* not set them.
|
|
5
|
+
*/
|
|
6
|
+
import { isHeaderValue } from './headers.ts'
|
|
7
|
+
import { invalidOption } from './option-error.ts'
|
|
8
|
+
|
|
9
|
+
/** HSTS settings (`strict-transport-security`). */
|
|
10
|
+
export interface HstsOptions {
|
|
11
|
+
/** `max-age`, in seconds. Default `31536000` (one year). */
|
|
12
|
+
readonly maxAge?: number
|
|
13
|
+
/** `includeSubDomains`. Default `true`. */
|
|
14
|
+
readonly includeSubDomains?: boolean
|
|
15
|
+
/** `preload` (needs `includeSubDomains` and a `maxAge` of at least one year). Default `false`. */
|
|
16
|
+
readonly preload?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The `securityHeaders` option of `HttpPlugin.configure` (merged over the defaults). */
|
|
20
|
+
export interface SecurityHeadersOptions {
|
|
21
|
+
/** `content-security-policy`. Default `"default-src 'none'; frame-ancestors 'none'"`. */
|
|
22
|
+
readonly contentSecurityPolicy?: string | false
|
|
23
|
+
/** `x-frame-options`. Default `'DENY'`. */
|
|
24
|
+
readonly frameOptions?: 'DENY' | 'SAMEORIGIN' | false
|
|
25
|
+
/** `x-content-type-options: nosniff`. Default `true`. */
|
|
26
|
+
readonly noSniff?: boolean
|
|
27
|
+
/** `referrer-policy` (a policy or a comma-separated fallback list). Default `'no-referrer'`. */
|
|
28
|
+
readonly referrerPolicy?: string | false
|
|
29
|
+
/** `strict-transport-security`. Default `false`; `true`: `max-age=31536000; includeSubDomains`. */
|
|
30
|
+
readonly hsts?: boolean | HstsOptions
|
|
31
|
+
/** `cross-origin-resource-policy`. Default `false`. */
|
|
32
|
+
readonly crossOriginResourcePolicy?: 'same-origin' | 'same-site' | 'cross-origin' | false
|
|
33
|
+
/** `cache-control` of replies that do not set one (`'no-store'` for personal data). Default `false`. */
|
|
34
|
+
readonly cacheControl?: string | false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CSP = "default-src 'none'; frame-ancestors 'none'"
|
|
38
|
+
const ONE_YEAR = 31_536_000
|
|
39
|
+
const KEYS = new Set([
|
|
40
|
+
'contentSecurityPolicy',
|
|
41
|
+
'frameOptions',
|
|
42
|
+
'noSniff',
|
|
43
|
+
'referrerPolicy',
|
|
44
|
+
'hsts',
|
|
45
|
+
'crossOriginResourcePolicy',
|
|
46
|
+
'cacheControl',
|
|
47
|
+
])
|
|
48
|
+
const REFERRER_POLICIES = new Set([
|
|
49
|
+
'no-referrer',
|
|
50
|
+
'no-referrer-when-downgrade',
|
|
51
|
+
'origin',
|
|
52
|
+
'origin-when-cross-origin',
|
|
53
|
+
'same-origin',
|
|
54
|
+
'strict-origin',
|
|
55
|
+
'strict-origin-when-cross-origin',
|
|
56
|
+
'unsafe-url',
|
|
57
|
+
])
|
|
58
|
+
const RESOURCE_POLICIES = new Set(['same-origin', 'same-site', 'cross-origin'])
|
|
59
|
+
const NO_HEADERS: Readonly<Record<string, string>> = Object.freeze({})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validates the `securityHeaders` option (ZNIGHT005) and returns the headers to add, frozen
|
|
63
|
+
* (`{}` when disabled).
|
|
64
|
+
*/
|
|
65
|
+
export function resolveSecurityHeaders(
|
|
66
|
+
value: boolean | SecurityHeadersOptions | undefined,
|
|
67
|
+
): Readonly<Record<string, string>> {
|
|
68
|
+
if (value === false) return NO_HEADERS
|
|
69
|
+
if (value !== undefined && value !== true && (value === null || typeof value !== 'object' || Array.isArray(value))) {
|
|
70
|
+
throw invalidOption('securityHeaders', 'a boolean or an object of header settings', value)
|
|
71
|
+
}
|
|
72
|
+
const options: SecurityHeadersOptions = value === undefined || value === true ? {} : value
|
|
73
|
+
for (const key of Object.keys(options)) {
|
|
74
|
+
if (!KEYS.has(key)) {
|
|
75
|
+
throw invalidOption('securityHeaders', `an object with ${[...KEYS].join(', ')} (unknown "${key}")`, options)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const headers: Record<string, string> = {}
|
|
79
|
+
const csp = options.contentSecurityPolicy ?? DEFAULT_CSP
|
|
80
|
+
if (csp !== false) headers['content-security-policy'] = text('securityHeaders.contentSecurityPolicy', csp)
|
|
81
|
+
const frame = options.frameOptions ?? 'DENY'
|
|
82
|
+
if (frame !== false) {
|
|
83
|
+
if (frame !== 'DENY' && frame !== 'SAMEORIGIN') {
|
|
84
|
+
throw invalidOption('securityHeaders.frameOptions', '"DENY", "SAMEORIGIN" or false', frame)
|
|
85
|
+
}
|
|
86
|
+
headers['x-frame-options'] = frame
|
|
87
|
+
}
|
|
88
|
+
const noSniff = options.noSniff ?? true
|
|
89
|
+
if (typeof noSniff !== 'boolean') throw invalidOption('securityHeaders.noSniff', 'a boolean', noSniff)
|
|
90
|
+
if (noSniff) headers['x-content-type-options'] = 'nosniff'
|
|
91
|
+
const referrer = options.referrerPolicy ?? 'no-referrer'
|
|
92
|
+
if (referrer !== false) headers['referrer-policy'] = referrerPolicy(referrer)
|
|
93
|
+
const hsts = strictTransportSecurity(options.hsts ?? false)
|
|
94
|
+
if (hsts !== undefined) headers['strict-transport-security'] = hsts
|
|
95
|
+
const corp = options.crossOriginResourcePolicy ?? false
|
|
96
|
+
if (corp !== false) {
|
|
97
|
+
if (typeof corp !== 'string' || !RESOURCE_POLICIES.has(corp)) {
|
|
98
|
+
throw invalidOption(
|
|
99
|
+
'securityHeaders.crossOriginResourcePolicy',
|
|
100
|
+
'"same-origin", "same-site", "cross-origin" or false',
|
|
101
|
+
corp,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
headers['cross-origin-resource-policy'] = corp
|
|
105
|
+
}
|
|
106
|
+
const cacheControl = options.cacheControl ?? false
|
|
107
|
+
if (cacheControl !== false) headers['cache-control'] = text('securityHeaders.cacheControl', cacheControl)
|
|
108
|
+
return Object.freeze(headers)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function text(name: string, value: unknown): string {
|
|
112
|
+
if (typeof value !== 'string' || value.trim() === '' || !isHeaderValue(value)) {
|
|
113
|
+
throw invalidOption(name, 'a non-empty header value (or false)', value)
|
|
114
|
+
}
|
|
115
|
+
return value
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function referrerPolicy(value: unknown): string {
|
|
119
|
+
const policies = typeof value === 'string' ? value.split(',').map(item => item.trim()) : []
|
|
120
|
+
if (policies.length === 0 || !policies.every(policy => REFERRER_POLICIES.has(policy))) {
|
|
121
|
+
throw invalidOption(
|
|
122
|
+
'securityHeaders.referrerPolicy',
|
|
123
|
+
`a referrer policy (${[...REFERRER_POLICIES].join(', ')}) or false`,
|
|
124
|
+
value,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return policies.join(', ')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function strictTransportSecurity(value: boolean | HstsOptions): string | undefined {
|
|
131
|
+
if (value === false) return undefined
|
|
132
|
+
if (value !== true && (value === null || typeof value !== 'object' || Array.isArray(value))) {
|
|
133
|
+
throw invalidOption('securityHeaders.hsts', 'a boolean or { maxAge?, includeSubDomains?, preload? }', value)
|
|
134
|
+
}
|
|
135
|
+
const options: HstsOptions = value === true ? {} : value
|
|
136
|
+
for (const key of Object.keys(options)) {
|
|
137
|
+
if (key !== 'maxAge' && key !== 'includeSubDomains' && key !== 'preload') {
|
|
138
|
+
throw invalidOption('securityHeaders.hsts', '{ maxAge?, includeSubDomains?, preload? }', options)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const maxAge = options.maxAge ?? ONE_YEAR
|
|
142
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0) {
|
|
143
|
+
throw invalidOption('securityHeaders.hsts.maxAge', 'a positive integer number of seconds', maxAge)
|
|
144
|
+
}
|
|
145
|
+
const includeSubDomains = options.includeSubDomains ?? true
|
|
146
|
+
if (typeof includeSubDomains !== 'boolean') {
|
|
147
|
+
throw invalidOption('securityHeaders.hsts.includeSubDomains', 'a boolean', includeSubDomains)
|
|
148
|
+
}
|
|
149
|
+
const preload = options.preload ?? false
|
|
150
|
+
if (typeof preload !== 'boolean') throw invalidOption('securityHeaders.hsts.preload', 'a boolean', preload)
|
|
151
|
+
if (preload && (!includeSubDomains || maxAge < ONE_YEAR)) {
|
|
152
|
+
throw invalidOption(
|
|
153
|
+
'securityHeaders.hsts',
|
|
154
|
+
'includeSubDomains and a maxAge of at least 31536000 with preload',
|
|
155
|
+
options,
|
|
156
|
+
undefined,
|
|
157
|
+
['The HSTS preload list requires includeSubDomains and max-age >= 31536000'],
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return `max-age=${maxAge}${includeSubDomains ? '; includeSubDomains' : ''}${preload ? '; preload' : ''}`
|
|
161
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { ReplyBody } from './adapter.ts'
|
|
2
|
+
import type { ResponseCookie } from './cookies.ts'
|
|
3
|
+
import type { HeaderValue, HttpErrorBody } from './exceptions.ts'
|
|
4
|
+
import type { ResolvedFile } from './files.ts'
|
|
5
|
+
import type { HttpResponse } from './response.ts'
|
|
6
|
+
import type { SseSession } from './sse.ts'
|
|
7
|
+
|
|
8
|
+
import { Readable } from 'node:stream'
|
|
9
|
+
|
|
10
|
+
import { isFileBody } from './files.ts'
|
|
11
|
+
import { isHttpResponse } from './response.ts'
|
|
12
|
+
import { isSseBody } from './sse.ts'
|
|
13
|
+
import { isBodylessStatus } from './status.ts'
|
|
14
|
+
import { discardStream, isByteStream, toByteStream } from './stream.ts'
|
|
15
|
+
import { emptyRecord } from './utils.ts'
|
|
16
|
+
|
|
17
|
+
const ENCODER = new TextEncoder()
|
|
18
|
+
|
|
19
|
+
/** Content types set by the serialiser. */
|
|
20
|
+
export const ContentType: {
|
|
21
|
+
readonly json: string
|
|
22
|
+
readonly text: string
|
|
23
|
+
readonly bytes: string
|
|
24
|
+
readonly eventStream: string
|
|
25
|
+
} = Object.freeze({
|
|
26
|
+
json: 'application/json; charset=utf-8',
|
|
27
|
+
text: 'text/plain; charset=utf-8',
|
|
28
|
+
bytes: 'application/octet-stream',
|
|
29
|
+
eventStream: 'text/event-stream; charset=utf-8',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** How a handler result is turned into a response. */
|
|
33
|
+
export interface SerializeOptions {
|
|
34
|
+
/** Request method (`HEAD` responses have no body). */
|
|
35
|
+
readonly method: string
|
|
36
|
+
/** Method of the route definition (`POST` routes default to 201). */
|
|
37
|
+
readonly routeMethod: string
|
|
38
|
+
/** `@HttpCode` status (wins over everything but a Web `Response`). */
|
|
39
|
+
readonly status: number | undefined
|
|
40
|
+
/** `@Header` entries. */
|
|
41
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
42
|
+
/** Headers set on the context (`ctx.setHeader`), applied after `@Header`. */
|
|
43
|
+
readonly context?: Readonly<Record<string, HeaderValue>> | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A serialised response (mutable headers, merged by the pipeline). */
|
|
47
|
+
export interface SerializedReply {
|
|
48
|
+
readonly status: number
|
|
49
|
+
readonly headers: Record<string, HeaderValue>
|
|
50
|
+
readonly body: ReplyBody
|
|
51
|
+
/** Cookies of the `HttpResponse` (`withCookie`), applied by the pipeline over those of the context. */
|
|
52
|
+
readonly cookies?: readonly ResponseCookie[]
|
|
53
|
+
/** The event stream of the body (`@Sse`, `HttpResponse.sse()`). */
|
|
54
|
+
readonly sse?: SseSession
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialises a handler result:
|
|
59
|
+
*
|
|
60
|
+
* - Web `Response` → sent as is (status, headers, body); `@HttpCode` / `@Header` do not apply;
|
|
61
|
+
* - `HttpResponse` → its body with its status and headers;
|
|
62
|
+
* - `undefined` → empty body, `204` (`201` for POST routes);
|
|
63
|
+
* - `string` → `text/plain`; `Uint8Array`/`ArrayBuffer`/views → `application/octet-stream`;
|
|
64
|
+
* - `ReadableStream`, Node.js `Readable`, `Blob` → streamed (`application/octet-stream` or the blob type);
|
|
65
|
+
* - anything else (objects, arrays, numbers, booleans, `null`) → JSON.
|
|
66
|
+
*
|
|
67
|
+
* Status precedence: `@HttpCode` > `HttpResponse.status` > default (`201` for POST routes,
|
|
68
|
+
* `204` without body, `200`). Header precedence: content type < `@Header` < `ctx.setHeader()` <
|
|
69
|
+
* `HttpResponse.headers` (a Web `Response` keeps its own headers).
|
|
70
|
+
*/
|
|
71
|
+
export function serializeResult(result: unknown, options: SerializeOptions): SerializedReply {
|
|
72
|
+
if (result instanceof Response) {
|
|
73
|
+
const reply = fromWebResponse(result, options.method)
|
|
74
|
+
if (options.context !== undefined) addMissingHeaders(reply.headers, options.context)
|
|
75
|
+
return reply
|
|
76
|
+
}
|
|
77
|
+
let payload = result
|
|
78
|
+
let status: number | undefined
|
|
79
|
+
let extra: Readonly<Record<string, HeaderValue>> | undefined
|
|
80
|
+
let cookies: readonly ResponseCookie[] | undefined
|
|
81
|
+
if (isHttpResponse(result)) {
|
|
82
|
+
payload = result.body
|
|
83
|
+
status = result.status
|
|
84
|
+
extra = result.headers
|
|
85
|
+
cookies = cookiesOf(result)
|
|
86
|
+
}
|
|
87
|
+
const finalStatus = options.status ?? status ?? defaultStatus(options.routeMethod, payload)
|
|
88
|
+
const headers = emptyRecord<HeaderValue>()
|
|
89
|
+
const body = encode(payload, headers)
|
|
90
|
+
if (options.headers !== undefined) assignHeaders(headers, options.headers)
|
|
91
|
+
if (options.context !== undefined) assignHeaders(headers, options.context)
|
|
92
|
+
if (extra !== undefined) assignHeaders(headers, extra)
|
|
93
|
+
return withCookies(finish(finalStatus, headers, body, options.method), cookies)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialises an event stream (`@Sse` result, `HttpResponse.sse()`): status 200 unless `@HttpCode`
|
|
98
|
+
* or `HttpResponse.status` say otherwise; `cache-control: no-cache, no-transform` and
|
|
99
|
+
* `x-accel-buffering: no` under `@Header`, `ctx.setHeader()` and the `HttpResponse` headers;
|
|
100
|
+
* `content-type: text/event-stream; charset=utf-8` last; never `content-length` or
|
|
101
|
+
* `transfer-encoding`. A `HEAD` request or a bodyless status cancels the stream before its first read.
|
|
102
|
+
*/
|
|
103
|
+
export function serializeSseReply(
|
|
104
|
+
session: SseSession,
|
|
105
|
+
response: HttpResponse | undefined,
|
|
106
|
+
options: SerializeOptions,
|
|
107
|
+
): SerializedReply {
|
|
108
|
+
const status = options.status ?? response?.status ?? 200
|
|
109
|
+
const headers = emptyRecord<HeaderValue>()
|
|
110
|
+
headers['cache-control'] = 'no-cache, no-transform'
|
|
111
|
+
headers['x-accel-buffering'] = 'no'
|
|
112
|
+
if (options.headers !== undefined) assignHeaders(headers, options.headers)
|
|
113
|
+
if (options.context !== undefined) assignHeaders(headers, options.context)
|
|
114
|
+
if (response !== undefined) assignHeaders(headers, response.headers)
|
|
115
|
+
headers['content-type'] = ContentType.eventStream
|
|
116
|
+
delete headers['content-length']
|
|
117
|
+
delete headers['transfer-encoding']
|
|
118
|
+
const reply = finish(status, headers, session.stream, options.method)
|
|
119
|
+
return withCookies(reply.body === null ? reply : { ...reply, sse: session }, response && cookiesOf(response))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The cookies of an `HttpResponse` (none for a copy of the class from an older package version). */
|
|
123
|
+
function cookiesOf(response: HttpResponse): readonly ResponseCookie[] | undefined {
|
|
124
|
+
const cookies = (response as { cookies?: readonly ResponseCookie[] }).cookies
|
|
125
|
+
return Array.isArray(cookies) && cookies.length > 0 ? cookies : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function withCookies(reply: SerializedReply, cookies: readonly ResponseCookie[] | undefined): SerializedReply {
|
|
129
|
+
return cookies === undefined ? reply : { ...reply, cookies }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Serialises a resolved file response (`HttpResponse.file()`): the file headers, then `@Header`,
|
|
134
|
+
* `ctx.setHeader()` and the `HttpResponse` headers. 206 / 304 keep their status; a 200 follows the
|
|
135
|
+
* usual precedence (`@HttpCode` > `HttpResponse.status` > 200).
|
|
136
|
+
*/
|
|
137
|
+
export function serializeFileReply(
|
|
138
|
+
file: ResolvedFile,
|
|
139
|
+
response: HttpResponse,
|
|
140
|
+
options: SerializeOptions,
|
|
141
|
+
): SerializedReply {
|
|
142
|
+
const status = file.status === 200 ? (options.status ?? response.status ?? 200) : file.status
|
|
143
|
+
const headers = emptyRecord<HeaderValue>()
|
|
144
|
+
assignHeaders(headers, file.headers)
|
|
145
|
+
if (options.headers !== undefined) assignHeaders(headers, options.headers)
|
|
146
|
+
if (options.context !== undefined) assignHeaders(headers, options.context)
|
|
147
|
+
assignHeaders(headers, response.headers)
|
|
148
|
+
return withCookies(finish(status, headers, file.body, options.method), cookiesOf(response))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `201` for POST routes, otherwise `204` without payload and `200` with one. */
|
|
152
|
+
function defaultStatus(routeMethod: string, payload: unknown): number {
|
|
153
|
+
if (routeMethod === 'POST') return 201
|
|
154
|
+
return payload === undefined ? 204 : 200
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Serialises an error body as JSON. */
|
|
158
|
+
export function serializeError(status: number, body: HttpErrorBody | unknown, method: string): SerializedReply {
|
|
159
|
+
const headers = emptyRecord<HeaderValue>()
|
|
160
|
+
const encoded = encode(body, headers)
|
|
161
|
+
return finish(status, headers, encoded, method)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Copies headers with lower-case names (later values win). */
|
|
165
|
+
export function assignHeaders(
|
|
166
|
+
target: Record<string, HeaderValue>,
|
|
167
|
+
source: Readonly<Record<string, HeaderValue>>,
|
|
168
|
+
): void {
|
|
169
|
+
for (const name of Object.keys(source)) {
|
|
170
|
+
const value = source[name]
|
|
171
|
+
if (value !== undefined) target[name.toLowerCase()] = typeof value === 'string' ? value : [...value]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Adds headers that are not already present (case-insensitive). */
|
|
176
|
+
export function addMissingHeaders(
|
|
177
|
+
target: Record<string, HeaderValue>,
|
|
178
|
+
source: Readonly<Record<string, HeaderValue>>,
|
|
179
|
+
): void {
|
|
180
|
+
for (const name of Object.keys(source)) {
|
|
181
|
+
const key = name.toLowerCase()
|
|
182
|
+
const value = source[name]
|
|
183
|
+
if (value !== undefined && target[key] === undefined) target[key] = typeof value === 'string' ? value : [...value]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function finish(
|
|
188
|
+
status: number,
|
|
189
|
+
headers: Record<string, HeaderValue>,
|
|
190
|
+
body: ReplyBody,
|
|
191
|
+
method: string,
|
|
192
|
+
): SerializedReply {
|
|
193
|
+
if (isBodylessStatus(status)) {
|
|
194
|
+
discard(body)
|
|
195
|
+
delete headers['content-type']
|
|
196
|
+
delete headers['content-length']
|
|
197
|
+
return { status, headers, body: null }
|
|
198
|
+
}
|
|
199
|
+
if (method === 'HEAD') {
|
|
200
|
+
discard(body)
|
|
201
|
+
return { status, headers, body: null }
|
|
202
|
+
}
|
|
203
|
+
return { status, headers, body }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function discard(body: ReplyBody): void {
|
|
207
|
+
if (body instanceof ReadableStream) discardStream(body)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function encode(payload: unknown, headers: Record<string, HeaderValue>): ReplyBody {
|
|
211
|
+
if (payload === undefined) return null
|
|
212
|
+
if (typeof payload === 'string') return bytes(ENCODER.encode(payload), headers, ContentType.text)
|
|
213
|
+
if (payload instanceof Uint8Array) return bytes(payload, headers, ContentType.bytes)
|
|
214
|
+
if (payload instanceof ArrayBuffer) return bytes(new Uint8Array(payload), headers, ContentType.bytes)
|
|
215
|
+
if (ArrayBuffer.isView(payload)) {
|
|
216
|
+
return bytes(new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength), headers, ContentType.bytes)
|
|
217
|
+
}
|
|
218
|
+
if (payload instanceof ReadableStream) {
|
|
219
|
+
headers['content-type'] = ContentType.bytes
|
|
220
|
+
// adapters get bytes (`ReplyBody`): string / ArrayBuffer chunks of a returned stream are converted
|
|
221
|
+
return isByteStream(payload) ? (payload as ReadableStream<Uint8Array>) : toByteStream(payload)
|
|
222
|
+
}
|
|
223
|
+
if (payload instanceof Blob) {
|
|
224
|
+
headers['content-type'] = payload.type === '' ? ContentType.bytes : payload.type
|
|
225
|
+
headers['content-length'] = String(payload.size)
|
|
226
|
+
return payload
|
|
227
|
+
}
|
|
228
|
+
if (isNodeReadable(payload)) {
|
|
229
|
+
headers['content-type'] = ContentType.bytes
|
|
230
|
+
return toByteStream(payload)
|
|
231
|
+
}
|
|
232
|
+
if (isFileBody(payload)) throw new TypeError('A file body (HttpResponse.file()) cannot be serialised here')
|
|
233
|
+
if (isSseBody(payload)) throw new TypeError('An event stream (HttpResponse.sse()) cannot be serialised here')
|
|
234
|
+
const json = JSON.stringify(payload)
|
|
235
|
+
if (json === undefined) throw new TypeError(`Cannot serialise a ${typeof payload} as a response body`)
|
|
236
|
+
return bytes(ENCODER.encode(json), headers, ContentType.json)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function bytes(data: Uint8Array, headers: Record<string, HeaderValue>, type: string): Uint8Array {
|
|
240
|
+
headers['content-type'] = type
|
|
241
|
+
headers['content-length'] = String(data.byteLength)
|
|
242
|
+
return data
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isNodeReadable(value: unknown): value is Readable {
|
|
246
|
+
return (
|
|
247
|
+
value instanceof Readable ||
|
|
248
|
+
(value !== null &&
|
|
249
|
+
typeof value === 'object' &&
|
|
250
|
+
typeof (value as { pipe?: unknown }).pipe === 'function' &&
|
|
251
|
+
typeof (value as { on?: unknown }).on === 'function' &&
|
|
252
|
+
typeof (value as { read?: unknown }).read === 'function')
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function fromWebResponse(response: Response, method: string): SerializedReply {
|
|
257
|
+
const headers = emptyRecord<HeaderValue>()
|
|
258
|
+
for (const [name, value] of response.headers) {
|
|
259
|
+
if (name !== 'set-cookie') headers[name] = value
|
|
260
|
+
}
|
|
261
|
+
const cookies = response.headers.getSetCookie()
|
|
262
|
+
if (cookies.length > 0) headers['set-cookie'] = cookies
|
|
263
|
+
return finish(response.status, headers, response.body, method)
|
|
264
|
+
}
|
package/src/size.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte sizes (`1048576`, `'512kb'`, `'10 MiB'`, `'1.5gb'`) used by body and upload limits. This
|
|
3
|
+
* module is **pure** (no imports, no I/O): the compiler extension evaluates `@BodyLimit('10mb')` and
|
|
4
|
+
* `maxSize` options with it at build time.
|
|
5
|
+
*
|
|
6
|
+
* Units are case-insensitive and binary, like the `bytes` package used by Express:
|
|
7
|
+
* `b`, `kb`/`kib`/`k` = 1024, `mb`/`mib`/`m` = 1024², `gb`/`gib`/`g` = 1024³, `tb`/`tib`/`t` = 1024⁴.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A size in bytes: a non-negative integer (`Infinity` disables the limit) or a string with a unit
|
|
12
|
+
* (`'512kb'`, `'10mb'`, `'1.5 GiB'`; `kb` = 1024 bytes).
|
|
13
|
+
*/
|
|
14
|
+
export type ByteSize = number | string
|
|
15
|
+
|
|
16
|
+
const UNITS: Readonly<Record<string, number>> = {
|
|
17
|
+
b: 1,
|
|
18
|
+
k: 1024,
|
|
19
|
+
kb: 1024,
|
|
20
|
+
kib: 1024,
|
|
21
|
+
m: 1024 ** 2,
|
|
22
|
+
mb: 1024 ** 2,
|
|
23
|
+
mib: 1024 ** 2,
|
|
24
|
+
g: 1024 ** 3,
|
|
25
|
+
gb: 1024 ** 3,
|
|
26
|
+
gib: 1024 ** 3,
|
|
27
|
+
t: 1024 ** 4,
|
|
28
|
+
tb: 1024 ** 4,
|
|
29
|
+
tib: 1024 ** 4,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIZE = /^(\d+(?:\.\d+)?|\.\d+)\s*([a-z]*)$/i
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses a {@link ByteSize}: `1024` → `1024`, `'10mb'` → `10485760`, `'1.5kb'` → `1536` (fractions
|
|
36
|
+
* are rounded down). Returns `undefined` for anything else (negative or fractional numbers,
|
|
37
|
+
* unknown units, `NaN`).
|
|
38
|
+
*/
|
|
39
|
+
export function parseByteSize(value: unknown): number | undefined {
|
|
40
|
+
if (typeof value === 'number') {
|
|
41
|
+
if (value === Number.POSITIVE_INFINITY) return value
|
|
42
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string') return undefined
|
|
45
|
+
const match = SIZE.exec(value.trim())
|
|
46
|
+
if (match === null) return undefined
|
|
47
|
+
const unit = (match[2] as string).toLowerCase()
|
|
48
|
+
const multiplier = unit === '' ? 1 : UNITS[unit]
|
|
49
|
+
if (multiplier === undefined) return undefined
|
|
50
|
+
const bytes = Math.floor(Number(match[1]) * multiplier)
|
|
51
|
+
return Number.isSafeInteger(bytes) ? bytes : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Human-readable size for messages: `5 MiB`, `1.5 KiB`, `512 bytes`, `unlimited`. */
|
|
55
|
+
export function formatByteSize(bytes: number): string {
|
|
56
|
+
if (bytes === Number.POSITIVE_INFINITY) return 'unlimited'
|
|
57
|
+
for (const [unit, size] of [
|
|
58
|
+
['TiB', 1024 ** 4],
|
|
59
|
+
['GiB', 1024 ** 3],
|
|
60
|
+
['MiB', 1024 ** 2],
|
|
61
|
+
['KiB', 1024],
|
|
62
|
+
] as const) {
|
|
63
|
+
if (bytes >= size) return `${Number((bytes / size).toFixed(2))} ${unit}`
|
|
64
|
+
}
|
|
65
|
+
return bytes === 1 ? '1 byte' : `${bytes} bytes`
|
|
66
|
+
}
|
package/src/sse-parse.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client side of Server-Sent Events: the WHATWG "interpreting an event stream" algorithm over a
|
|
3
|
+
* byte stream (`parseSseStream`), used by `injectSse()` and by tests over real sockets. Import-free.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A dispatched event. */
|
|
7
|
+
export interface SseMessage {
|
|
8
|
+
/** Event type: `message` by default. */
|
|
9
|
+
readonly event: string
|
|
10
|
+
readonly data: string
|
|
11
|
+
/** The last event id at dispatch time (what `EventSource` sends back as `Last-Event-ID`). */
|
|
12
|
+
readonly id: string
|
|
13
|
+
/** `JSON.parse(data)`. */
|
|
14
|
+
json<T = unknown>(): T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An item of an event stream: an event, a comment (heartbeats are `''`) or a reconnection delay. */
|
|
18
|
+
export type SseItem =
|
|
19
|
+
| ({ readonly type: 'event' } & SseMessage)
|
|
20
|
+
| { readonly type: 'comment'; readonly text: string }
|
|
21
|
+
| { readonly type: 'retry'; readonly retry: number }
|
|
22
|
+
|
|
23
|
+
/** Options of {@link parseSseStream}. */
|
|
24
|
+
export interface ParseSseOptions {
|
|
25
|
+
/** Stops reading (the body is cancelled) when aborted. */
|
|
26
|
+
readonly signal?: AbortSignal
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Incremental parser (internal): feed it decoded text, collect the items. Follows the HTML
|
|
31
|
+
* specification: CR, LF and CRLF line ends (a CR at the end of a chunk included), one leading space
|
|
32
|
+
* stripped from values, `data` lines buffered and the final LF removed, `id` values containing NUL
|
|
33
|
+
* ignored, `retry` accepted only as ASCII digits, the type defaulting to `message`, an event without
|
|
34
|
+
* data not dispatched.
|
|
35
|
+
*/
|
|
36
|
+
export class SseParser {
|
|
37
|
+
/** The last event id (updated when an event is dispatched, even without data). */
|
|
38
|
+
lastEventId = ''
|
|
39
|
+
#line = ''
|
|
40
|
+
#skipLf = false
|
|
41
|
+
#data = ''
|
|
42
|
+
#hasData = false
|
|
43
|
+
#type = ''
|
|
44
|
+
#idBuffer = ''
|
|
45
|
+
|
|
46
|
+
/** Parses a chunk of text; returns the items it completes. */
|
|
47
|
+
push(text: string): SseItem[] {
|
|
48
|
+
const items: SseItem[] = []
|
|
49
|
+
let start = 0
|
|
50
|
+
for (let index = 0; index < text.length; index++) {
|
|
51
|
+
const code = text.charCodeAt(index)
|
|
52
|
+
if (code === 0x0a && this.#skipLf) {
|
|
53
|
+
// the LF of a CRLF (possibly split across chunks)
|
|
54
|
+
this.#skipLf = false
|
|
55
|
+
start = index + 1
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
this.#skipLf = false
|
|
59
|
+
if (code !== 0x0a && code !== 0x0d) continue
|
|
60
|
+
this.#line += text.slice(start, index)
|
|
61
|
+
this.#processLine(this.#line, items)
|
|
62
|
+
this.#line = ''
|
|
63
|
+
start = index + 1
|
|
64
|
+
if (code === 0x0d) this.#skipLf = true
|
|
65
|
+
}
|
|
66
|
+
if (start < text.length) this.#line += text.slice(start)
|
|
67
|
+
return items
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#processLine(line: string, items: SseItem[]): void {
|
|
71
|
+
if (line === '') {
|
|
72
|
+
this.#dispatch(items)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const colon = line.indexOf(':')
|
|
76
|
+
if (colon === 0) {
|
|
77
|
+
items.push({ type: 'comment', text: line.charCodeAt(1) === 0x20 ? line.slice(2) : line.slice(1) })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
81
|
+
let value = colon === -1 ? '' : line.slice(colon + 1)
|
|
82
|
+
if (value.charCodeAt(0) === 0x20) value = value.slice(1)
|
|
83
|
+
switch (field) {
|
|
84
|
+
case 'event':
|
|
85
|
+
this.#type = value
|
|
86
|
+
break
|
|
87
|
+
case 'data':
|
|
88
|
+
this.#data += `${value}\n`
|
|
89
|
+
this.#hasData = true
|
|
90
|
+
break
|
|
91
|
+
case 'id':
|
|
92
|
+
if (!value.includes('\u0000')) this.#idBuffer = value
|
|
93
|
+
break
|
|
94
|
+
case 'retry':
|
|
95
|
+
if (/^\d+$/.test(value)) items.push({ type: 'retry', retry: Number(value) })
|
|
96
|
+
break
|
|
97
|
+
default:
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#dispatch(items: SseItem[]): void {
|
|
103
|
+
this.lastEventId = this.#idBuffer
|
|
104
|
+
if (!this.#hasData) {
|
|
105
|
+
this.#type = ''
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const data = this.#data.endsWith('\n') ? this.#data.slice(0, -1) : this.#data
|
|
109
|
+
const event = this.#type === '' ? 'message' : this.#type
|
|
110
|
+
const id = this.lastEventId
|
|
111
|
+
items.push({ type: 'event', event, data, id, json: <T = unknown>(): T => JSON.parse(data) as T })
|
|
112
|
+
this.#data = ''
|
|
113
|
+
this.#hasData = false
|
|
114
|
+
this.#type = ''
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Turns an event stream body (a `fetch()` response body, `injectStream().body`) into its items.
|
|
120
|
+
* UTF-8 is decoded with the BOM stripped; an unterminated event at the end is discarded. Calling
|
|
121
|
+
* `return()` (leaving a `for await` loop) or aborting `signal` cancels the body.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* const response = await fetch(`${http.address}/orders/o-1/events`)
|
|
125
|
+
* for await (const item of parseSseStream(response.body!)) {
|
|
126
|
+
* if (item.type === 'event') console.log(item.event, item.json())
|
|
127
|
+
* }
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function parseSseStream(
|
|
131
|
+
body: ReadableStream<Uint8Array>,
|
|
132
|
+
options: ParseSseOptions = {},
|
|
133
|
+
): AsyncIterableIterator<SseItem> {
|
|
134
|
+
return parseWith(new SseParser(), body, options)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** {@link parseSseStream} with a given parser (internal: `injectSse()` reads its `lastEventId`). */
|
|
138
|
+
export function parseWith(
|
|
139
|
+
parser: SseParser,
|
|
140
|
+
body: ReadableStream<Uint8Array>,
|
|
141
|
+
options: ParseSseOptions = {},
|
|
142
|
+
): AsyncIterableIterator<SseItem> {
|
|
143
|
+
const reader = body.getReader()
|
|
144
|
+
const decoder = new TextDecoder('utf-8')
|
|
145
|
+
const queue: SseItem[] = []
|
|
146
|
+
let ended = false
|
|
147
|
+
let cancelled: Promise<void> = Promise.resolve()
|
|
148
|
+
const stop = (): void => {
|
|
149
|
+
if (ended) return
|
|
150
|
+
ended = true
|
|
151
|
+
queue.length = 0
|
|
152
|
+
cancelled = reader.cancel().catch(() => undefined)
|
|
153
|
+
}
|
|
154
|
+
const signal = options.signal
|
|
155
|
+
if (signal !== undefined) {
|
|
156
|
+
if (signal.aborted) stop()
|
|
157
|
+
else signal.addEventListener('abort', stop, { once: true })
|
|
158
|
+
}
|
|
159
|
+
const iterator: AsyncIterableIterator<SseItem> = {
|
|
160
|
+
async next(): Promise<IteratorResult<SseItem>> {
|
|
161
|
+
for (;;) {
|
|
162
|
+
const queued = queue.shift()
|
|
163
|
+
if (queued !== undefined) return { done: false, value: queued }
|
|
164
|
+
if (ended) return { done: true, value: undefined }
|
|
165
|
+
let result: Awaited<ReturnType<typeof reader.read>>
|
|
166
|
+
try {
|
|
167
|
+
result = await reader.read()
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (ended) return { done: true, value: undefined }
|
|
170
|
+
ended = true
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
if (result.done) {
|
|
174
|
+
// the decoder may hold the start of a character; an unterminated event is discarded
|
|
175
|
+
parser.push(decoder.decode())
|
|
176
|
+
ended = true
|
|
177
|
+
signal?.removeEventListener('abort', stop)
|
|
178
|
+
return { done: true, value: undefined }
|
|
179
|
+
}
|
|
180
|
+
if (!ended) queue.push(...parser.push(decoder.decode(result.value, { stream: true })))
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
async return(): Promise<IteratorResult<SseItem>> {
|
|
184
|
+
stop()
|
|
185
|
+
signal?.removeEventListener('abort', stop)
|
|
186
|
+
await cancelled
|
|
187
|
+
return { done: true, value: undefined }
|
|
188
|
+
},
|
|
189
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<SseItem> {
|
|
190
|
+
return iterator
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
return iterator
|
|
194
|
+
}
|