@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,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
+ }
@@ -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
+ }
@@ -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
+ }