@boo-labs/server 0.0.0-stage → 1.0.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.
Files changed (114) hide show
  1. package/README.md +229 -2
  2. package/index.mjs +23 -0
  3. package/package.json +71 -4
  4. package/source/application/Kernel/index.mjs +1137 -0
  5. package/source/application/Server/index.mjs +1201 -0
  6. package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
  7. package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
  8. package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
  9. package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
  10. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
  11. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
  12. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
  13. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
  14. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
  15. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
  16. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
  17. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
  18. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
  19. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
  20. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
  21. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
  22. package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
  23. package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
  24. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
  25. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
  26. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
  27. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
  28. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
  29. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
  30. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
  31. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
  32. package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
  33. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
  34. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
  35. package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
  36. package/source/application/resolvers/TransportResolver/index.mjs +119 -0
  37. package/source/application/use-cases/CloseServer/index.mjs +85 -0
  38. package/source/application/use-cases/ListenServer/index.mjs +98 -0
  39. package/source/core/EventSystem/index.mjs +110 -0
  40. package/source/core/Exchange/ExchangeContext.mjs +291 -0
  41. package/source/core/Exchange/Telemetry.mjs +193 -0
  42. package/source/core/Exchange/index.mjs +604 -0
  43. package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
  44. package/source/core/IPCMain/IPCMain.mjs +507 -0
  45. package/source/core/IPCMain/IPCProtocol.mjs +441 -0
  46. package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
  47. package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
  48. package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
  49. package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
  50. package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
  51. package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
  52. package/source/core/Request/RequestHeaders.mjs +177 -0
  53. package/source/core/Request/index.mjs +805 -0
  54. package/source/core/Response/ResponseHeaders.mjs +402 -0
  55. package/source/core/Response/index.mjs +1370 -0
  56. package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
  57. package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
  58. package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
  59. package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
  60. package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
  61. package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
  62. package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
  63. package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
  64. package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
  65. package/source/core/routing/ExchangeLayer/index.mjs +254 -0
  66. package/source/core/routing/Pipeline/index.mjs +157 -0
  67. package/source/core/routing/Router/Route.mjs +184 -0
  68. package/source/core/routing/Router/index.mjs +523 -0
  69. package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
  70. package/source/core/routing/RouterRegister/index.mjs +150 -0
  71. package/source/core/routing/RouterSystem/index.mjs +225 -0
  72. package/source/core/value-objects/Address/index.mjs +281 -0
  73. package/source/core/value-objects/Endpoint/index.mjs +293 -0
  74. package/source/index.mjs +62 -0
  75. package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
  76. package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
  77. package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
  78. package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
  79. package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
  80. package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
  81. package/source/shared/Identifier/index.mjs +671 -0
  82. package/source/shared/constants/symbols/brands/index.mjs +45 -0
  83. package/source/shared/domain/UUIDV7/index.mjs +346 -0
  84. package/source/shared/errors/BaseError/index.mjs +157 -0
  85. package/source/shared/errors/IPCError/index.mjs +97 -0
  86. package/source/shared/errors/MiddlewareError/index.mjs +106 -0
  87. package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
  88. package/source/shared/errors/RequestError/index.mjs +116 -0
  89. package/source/shared/errors/ResponseError/index.mjs +140 -0
  90. package/source/shared/errors/RouterError/index.mjs +90 -0
  91. package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
  92. package/source/shared/errors/ServerError/index.mjs +127 -0
  93. package/source/shared/errors/WebSocketError/index.mjs +58 -0
  94. package/source/shared/outcome/ErrorSchema.mjs +361 -0
  95. package/source/shared/outcome/Result/index.mjs +239 -0
  96. package/source/shared/outcome/Validation/index.mjs +208 -0
  97. package/source/shared/validations/Middleware/index.mjs +160 -0
  98. package/source/shared/validations/PathnamePattern/index.mjs +256 -0
  99. package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
  100. package/source/shared/validations/Server/Router/index.mjs +68 -0
  101. package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
  102. package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
  103. package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
  104. package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
  105. package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
  106. package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
  107. package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
  108. package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
  109. package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
  110. package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
  111. package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
  112. package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
  113. package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
  114. package/types/index.d.ts +719 -0
@@ -0,0 +1,1370 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the Response abstraction.
4
+ *
5
+ * @description Header values must remain compatible with the HTTP field-value ABNF.
6
+ *
7
+ * This class does not perform RFC 5987 encoding. Callers that need to emit
8
+ * non-ASCII content (for example a UTF-8 filename in `Content-Disposition`)
9
+ * are responsible for encoding the value themselves before passing it to
10
+ * this class.
11
+ *
12
+ * @module Response
13
+ */
14
+
15
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
+ // » IMPORT NATIVE NODEJS MODULES
17
+ import { pipeline } from 'node:stream/promises';
18
+ import { createHash } from 'node:crypto';
19
+ import { Readable } from 'node:stream';
20
+
21
+ // » IMPORT LOCAL MODULES
22
+ import ResponseError from '#shared/errors/ResponseError';
23
+ import ResponseHeaders from './ResponseHeaders.mjs';
24
+
25
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
+ /**
27
+ * Represents a native Node.js HTTP/1.1 server response.
28
+ *
29
+ * @typedef {import('node:http').ServerResponse} HttpServerResponse
30
+ */
31
+
32
+ /**
33
+ * Represents a native Node.js HTTP/2 server response.
34
+ *
35
+ * @typedef {import('node:http2').Http2ServerResponse} Http2ServerResponse
36
+ */
37
+
38
+ /**
39
+ * Represents a native Node.js readable stream.
40
+ *
41
+ * @typedef {import('node:stream').Readable} NodeReadable
42
+ */
43
+
44
+ /**
45
+ * Represents a native Node.js transform stream.
46
+ *
47
+ * @typedef {import('node:stream').Transform} NodeTransform
48
+ */
49
+
50
+ /**
51
+ * Represents a native Node.js HTTP/1.1 or HTTP/2 server response.
52
+ *
53
+ * @typedef {HttpServerResponse|Http2ServerResponse} ServerResponse
54
+ */
55
+
56
+ /**
57
+ * Represents the current state of the response body.
58
+ *
59
+ * The union is discriminated by the `kind` field, which allows the type
60
+ * system to narrow the `value` field to the appropriate type for each state.
61
+ *
62
+ * @typedef {{ kind: 'none', value: null } |{ kind: 'buffer', value: string|Uint8Array }|{ kind: 'stream', value: NodeReadable }} ResponseBody
63
+ */
64
+
65
+ /**
66
+ * Identifies the kind of body currently described by a response.
67
+ *
68
+ * @typedef {'none'|'buffer'|'stream'} BodyKind
69
+ */
70
+
71
+ /**
72
+ * Handles response processing immediately before it is committed.
73
+ *
74
+ * @typedef {(response: Response) => void} CommitHook
75
+ */
76
+
77
+ /**
78
+ * Handles errors produced while committing a response.
79
+ *
80
+ * @typedef {(error: Error) => void} CommitErrorHook
81
+ */
82
+
83
+ /**
84
+ * Deferred source and transform used when the response body is wrapped.
85
+ *
86
+ * The pair is treated as a single unit of state: either no wrap is pending
87
+ * (`null`) or both halves are present. There is no partially built state.
88
+ *
89
+ * @typedef {object} BodyWrap
90
+ * @property {NodeReadable} source - Readable piped into the transform.
91
+ * @property {NodeTransform} transform - Transform applied to the body.
92
+ */
93
+
94
+ /**
95
+ * Defines the lifecycle hooks and options supported by a response.
96
+ *
97
+ * @typedef {object} ResponseOptions
98
+ * @property {CommitHook} [onCommit] - The hook invoked when response commitment begins. Errors thrown by this hook are forwarded to `onCommitError` and do not prevent the response from being sent.
99
+ * @property {CommitErrorHook} [onCommitError] - The hook invoked when response commitment produces an error.
100
+ * @property {boolean} [etag=false] - Whether to generate a weak ETag for buffered bodies.
101
+ * @property {string} [defaultCharset] - Charset appended to eligible Content-Type headers when none is set.
102
+ */
103
+
104
+ // ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
105
+ /**
106
+ * Maps HTTP status codes to their standard reason phrases.
107
+ *
108
+ * The map represents the set of final status codes supported by this
109
+ * abstraction. Informational (`1xx`) codes and reserved/unused codes such
110
+ * as `306` are intentionally excluded.
111
+ *
112
+ * @type {Map<number, string>}
113
+ */
114
+ const HTTP_STATUS_CODES = new Map([
115
+ [200, 'OK'],
116
+ [201, 'Created'],
117
+ [202, 'Accepted'],
118
+ [203, 'Non-Authoritative Information'],
119
+ [204, 'No Content'],
120
+ [205, 'Reset Content'],
121
+ [206, 'Partial Content'],
122
+ [207, 'Multi-Status'],
123
+ [208, 'Already Reported'],
124
+ [226, 'IM Used'],
125
+ [300, 'Multiple Choices'],
126
+ [301, 'Moved Permanently'],
127
+ [302, 'Found'],
128
+ [303, 'See Other'],
129
+ [304, 'Not Modified'],
130
+ [305, 'Use Proxy'],
131
+ [307, 'Temporary Redirect'],
132
+ [308, 'Permanent Redirect'],
133
+ [400, 'Bad Request'],
134
+ [401, 'Unauthorized'],
135
+ [402, 'Payment Required'],
136
+ [403, 'Forbidden'],
137
+ [404, 'Not Found'],
138
+ [405, 'Method Not Allowed'],
139
+ [406, 'Not Acceptable'],
140
+ [407, 'Proxy Authentication Required'],
141
+ [408, 'Request Timeout'],
142
+ [409, 'Conflict'],
143
+ [410, 'Gone'],
144
+ [411, 'Length Required'],
145
+ [412, 'Precondition Failed'],
146
+ [413, 'Payload Too Large'],
147
+ [414, 'URI Too Long'],
148
+ [415, 'Unsupported Media Type'],
149
+ [416, 'Range Not Satisfiable'],
150
+ [417, 'Expectation Failed'],
151
+ [421, 'Misdirected Request'],
152
+ [422, 'Unprocessable Entity'],
153
+ [423, 'Locked'],
154
+ [424, 'Failed Dependency'],
155
+ [425, 'Too Early'],
156
+ [426, 'Upgrade Required'],
157
+ [428, 'Precondition Required'],
158
+ [429, 'Too Many Requests'],
159
+ [431, 'Request Header Fields Too Large'],
160
+ [451, 'Unavailable For Legal Reasons'],
161
+ [500, 'Internal Server Error'],
162
+ [501, 'Not Implemented'],
163
+ [502, 'Bad Gateway'],
164
+ [503, 'Service Unavailable'],
165
+ [504, 'Gateway Timeout'],
166
+ [505, 'HTTP Version Not Supported'],
167
+ [506, 'Variant Also Negotiates'],
168
+ [507, 'Insufficient Storage'],
169
+ [508, 'Loop Detected'],
170
+ [510, 'Not Extended'],
171
+ [511, 'Network Authentication Required'],
172
+ ]);
173
+
174
+ /**
175
+ * Identifies the HTTP status codes supported by `Response.redirect()`.
176
+ *
177
+ * @type {Set<number>}
178
+ */
179
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
180
+
181
+ /**
182
+ * Defines the default HTTP status code for a new response.
183
+ *
184
+ * @type {number}
185
+ */
186
+ const DEFAULT_STATUS_CODE = 200;
187
+
188
+ /**
189
+ * Identifies status codes that must not contain a response body.
190
+ *
191
+ * @type {Set<number>}
192
+ */
193
+ const EMPTY_BODY_STATUS_CODES = new Set([204, 205, 304]);
194
+
195
+ /**
196
+ * Detects an existing `charset` parameter in a `Content-Type` header value.
197
+ *
198
+ * @type {RegExp}
199
+ */
200
+ const CHARSET_PARAMETER_PATTERN = /;\s*charset\s*=/i;
201
+
202
+ /**
203
+ * Media types eligible for default charset application.
204
+ *
205
+ * @type {Set<string>}
206
+ */
207
+ const CHARSET_ELIGIBLE_MEDIA_TYPES = new Set([
208
+ 'application/json',
209
+ 'application/xml',
210
+ 'application/javascript',
211
+ ]);
212
+
213
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
214
+ /**
215
+ * Represents an outgoing HTTP response.
216
+ *
217
+ * @classdesc A response is built progressively by configuring its status,
218
+ * headers, and body. Body description methods do not send anything to the client.
219
+ * Only `end()` materializes the configured response and sends it through
220
+ * the underlying server response.
221
+ *
222
+ * A response can describe at most one body. Once a body has been described,
223
+ * another body description method throws `BODY_ALREADY_DESCRIBED`.
224
+ *
225
+ * Once a body has been described and a `Content-Type` header has been
226
+ * established, that header becomes immutable for the rest of the response
227
+ * lifecycle. This prevents a body description helper from being followed
228
+ * by an incompatible content type.
229
+ *
230
+ * Switching to an empty-body status code (204, 205, 304) after a body has
231
+ * already been described is not allowed and throws `BODY_NOT_ALLOWED`.
232
+ *
233
+ * Informational status codes (`1xx`) are not supported by this abstraction.
234
+ * They are protocol-level messages that must be produced by dedicated
235
+ * native APIs (`writeContinue()`, `writeEarlyHints()`) or by the transport
236
+ * upgrade flow, not by a handler response.
237
+ */
238
+ class Response {
239
+ /**
240
+ * The current HTTP status code.
241
+ *
242
+ * @type {number}
243
+ */
244
+ #statusCode = DEFAULT_STATUS_CODE;
245
+
246
+ /**
247
+ * The response headers.
248
+ *
249
+ * @type {ResponseHeaders}
250
+ */
251
+ #headers = new ResponseHeaders();
252
+
253
+ /**
254
+ * The current body state, discriminated by `kind`.
255
+ *
256
+ * @type {ResponseBody}
257
+ */
258
+ #body = { kind: 'none', value: null };
259
+
260
+ /**
261
+ * Whether the response has been committed.
262
+ *
263
+ * @type {boolean}
264
+ */
265
+ #committed = false;
266
+
267
+ /**
268
+ * The underlying server response.
269
+ *
270
+ * @type {ServerResponse}
271
+ */
272
+ #serverResponse;
273
+
274
+ /**
275
+ * The hook invoked when response commitment begins.
276
+ *
277
+ * @type {CommitHook|null}
278
+ */
279
+ #onCommit = null;
280
+
281
+ /**
282
+ * The hook invoked when response commitment produces an error.
283
+ *
284
+ * @type {CommitErrorHook|null}
285
+ */
286
+ #onCommitError = null;
287
+
288
+ /**
289
+ * Whether to generate a weak ETag for buffered bodies.
290
+ *
291
+ * @type {boolean}
292
+ */
293
+ #etag = false;
294
+
295
+ /**
296
+ * Charset appended to eligible `Content-Type` headers when none is set.
297
+ *
298
+ * @type {string|null}
299
+ */
300
+ #defaultCharset = null;
301
+
302
+ /**
303
+ * Deferred source and transform used when the response body is wrapped.
304
+ *
305
+ * `null` when no wrap is pending. Set only by `#wrapBody`; never exposed
306
+ * publicly. Holds both halves of the pipeline stage as a single unit so
307
+ * they cannot desynchronize.
308
+ *
309
+ * @type {BodyWrap|null}
310
+ */
311
+ #wrap = null;
312
+
313
+ /**
314
+ * Creates a response bound to a native Node.js server response.
315
+ *
316
+ * The optional commit hooks allow the surrounding `Exchange` to inject
317
+ * behavior immediately before materialization and observe errors produced
318
+ * during that process without coupling `Response` to that behavior.
319
+ *
320
+ * @param {ServerResponse} serverResponse - The native Node.js server response.
321
+ * @param {ResponseOptions} [options] - The response lifecycle hooks and options.
322
+ * @example
323
+ * ```js
324
+ * const response = new Response(serverResponse, {
325
+ * onCommit: (response) => telemetry.apply(response),
326
+ * onCommitError: (error) => telemetry.recordError(error),
327
+ * etag: true,
328
+ * defaultCharset: 'utf-8',
329
+ * });
330
+ * ```
331
+ */
332
+ constructor(serverResponse, options = {}) {
333
+ this.#serverResponse = serverResponse;
334
+ this.#onCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
335
+ this.#onCommitError =
336
+ typeof options.onCommitError === 'function' ? options.onCommitError : null;
337
+ this.#etag = options.etag === true;
338
+ this.#defaultCharset =
339
+ typeof options.defaultCharset === 'string' && options.defaultCharset.length > 0
340
+ ? options.defaultCharset
341
+ : null;
342
+ }
343
+
344
+ /**
345
+ * The HTTP status code configured for the response.
346
+ *
347
+ * @type {number}
348
+ */
349
+ get statusCode() {
350
+ return this.#statusCode;
351
+ }
352
+
353
+ /**
354
+ * The standard reason phrase associated with the configured status code.
355
+ *
356
+ * Returns an empty string when the status code has no mapped reason phrase.
357
+ *
358
+ * @type {string}
359
+ */
360
+ get statusText() {
361
+ return HTTP_STATUS_CODES.get(this.#statusCode) ?? '';
362
+ }
363
+
364
+ /**
365
+ * Whether the response has already entered the committed state.
366
+ *
367
+ * A response is considered committed once `end()` has begun materializing
368
+ * it. This means the response can no longer be modified through the public
369
+ * API. It does not mean that the client has received the response; the
370
+ * transport may still be writing bytes to the socket when this getter
371
+ * returns `true`.
372
+ *
373
+ * @type {boolean}
374
+ */
375
+ get isCommitted() {
376
+ return this.#committed;
377
+ }
378
+
379
+ /**
380
+ * Describes the current body state of the response.
381
+ *
382
+ * - `'none'`: no body has been described.
383
+ * - `'buffer'`: a buffered body has been described.
384
+ * - `'stream'`: a stream body has been described.
385
+ *
386
+ * @type {BodyKind}
387
+ */
388
+ get bodyKind() {
389
+ return this.#body.kind;
390
+ }
391
+
392
+ /**
393
+ * Byte length of the described buffered body, or `undefined` when the
394
+ * body is absent or is a stream.
395
+ *
396
+ * After `wrapBody()`, the body is a stream and this getter returns
397
+ * `undefined`.
398
+ *
399
+ * @type {number|undefined}
400
+ */
401
+ get bodyLength() {
402
+ if (this.#body.kind !== 'buffer') {
403
+ return undefined;
404
+ }
405
+
406
+ const value = this.#body.value;
407
+ return value instanceof Uint8Array ? value.byteLength : Buffer.byteLength(value);
408
+ }
409
+
410
+ /**
411
+ * Sets the HTTP status code.
412
+ *
413
+ * The status code must be a supported final HTTP status code. Informational
414
+ * status codes (`1xx`) are not supported by this abstraction. The status
415
+ * cannot be changed after the response has been committed.
416
+ *
417
+ * Switching to an empty-body status code (204, 205, 304) after a body has
418
+ * already been described is not allowed.
419
+ *
420
+ * @param {number} code - The HTTP status code.
421
+ * @returns {this} The response instance.
422
+ * @throws {ResponseError} If the response has already been committed.
423
+ * @throws {ResponseError} If the status code is invalid or unsupported.
424
+ * @throws {ResponseError} If an empty-body status is set after a body was described.
425
+ * @example
426
+ * ```js
427
+ * response.status(201);
428
+ * ```
429
+ */
430
+ status(code) {
431
+ this.#assertNotCommitted();
432
+
433
+ if (!Number.isInteger(code) || !HTTP_STATUS_CODES.has(code)) {
434
+ throw new ResponseError('INVALID_STATUS_CODE', {
435
+ values: { statusCode: code },
436
+ });
437
+ }
438
+
439
+ if (EMPTY_BODY_STATUS_CODES.has(code) && this.#body.kind !== 'none') {
440
+ throw new ResponseError('BODY_NOT_ALLOWED', {
441
+ values: { statusCode: code },
442
+ });
443
+ }
444
+
445
+ this.#statusCode = code;
446
+ return this;
447
+ }
448
+
449
+ /**
450
+ * Sets a response header.
451
+ *
452
+ * Header names are normalized to lowercase internally. Calling this method
453
+ * replaces any existing value for the same header name.
454
+ *
455
+ * Once a body has been described and a `Content-Type` header has been
456
+ * established, that header cannot be replaced.
457
+ *
458
+ * Header values must be compatible with the HTTP field-value syntax.
459
+ * Non-ASCII encoding (RFC 5987) is the caller's responsibility.
460
+ *
461
+ * @param {string} name - The HTTP header name.
462
+ * @param {string|number|(string|number)[]} value - The HTTP header value.
463
+ * @returns {this} The response instance.
464
+ * @throws {ResponseError} If the response has already been committed.
465
+ * @throws {ResponseError} If the header name or value is invalid.
466
+ * @throws {ResponseError} If `Content-Type` is already locked.
467
+ * @example
468
+ * ```js
469
+ * response.setHeader('Content-Type', 'application/json');
470
+ * ```
471
+ */
472
+ setHeader(name, value) {
473
+ this.#assertNotCommitted();
474
+ this.#headers.set(name, value);
475
+ return this;
476
+ }
477
+
478
+ /**
479
+ * Sets multiple response headers.
480
+ *
481
+ * Existing values are replaced when the supplied object contains the same
482
+ * header name.
483
+ *
484
+ * @param {Record<string, string|number|(string|number)[]>} headers - The response headers to set.
485
+ * @returns {this} The response instance.
486
+ * @throws {ResponseError} If the response has already been committed.
487
+ * @throws {ResponseError} If the headers value is not an object.
488
+ * @throws {ResponseError} If any header name or value is invalid.
489
+ * @throws {ResponseError} If `Content-Type` is already locked.
490
+ * @example
491
+ * ```js
492
+ * response.setHeaders({
493
+ * 'Content-Type': 'application/json',
494
+ * 'Cache-Control': 'no-store',
495
+ * });
496
+ * ```
497
+ */
498
+ setHeaders(headers) {
499
+ this.#assertNotCommitted();
500
+ this.#headers.setMany(headers);
501
+ return this;
502
+ }
503
+
504
+ /**
505
+ * Appends a value to an existing header or creates the header when it does
506
+ * not exist.
507
+ *
508
+ * Existing scalar values are converted to an array when another value is
509
+ * appended.
510
+ *
511
+ * @param {string} name - The HTTP header name.
512
+ * @param {string|number} value - The value to append.
513
+ * @returns {this} The response instance.
514
+ * @throws {ResponseError} If the response has already been committed.
515
+ * @throws {ResponseError} If the header name or value is invalid.
516
+ * @throws {ResponseError} If `Content-Type` is already locked.
517
+ * @example
518
+ * ```js
519
+ * response
520
+ * .appendHeader('Set-Cookie', 'session=abc')
521
+ * .appendHeader('Set-Cookie', 'theme=dark');
522
+ * ```
523
+ */
524
+ appendHeader(name, value) {
525
+ this.#assertNotCommitted();
526
+ this.#headers.append(name, value);
527
+ return this;
528
+ }
529
+
530
+ /**
531
+ * Returns the value of a response header.
532
+ *
533
+ * When the header value is an array, a defensive copy is returned so that
534
+ * the caller cannot mutate the internal response state through the result.
535
+ *
536
+ * @param {string} name - The HTTP header name.
537
+ * @returns {string|number|(string|number)[]|undefined} The header value, or `undefined` when the header does not exist.
538
+ * @example
539
+ * ```js
540
+ * const contentType = response.getHeader('Content-Type');
541
+ * ```
542
+ */
543
+ getHeader(name) {
544
+ return this.#headers.get(name);
545
+ }
546
+
547
+ /**
548
+ * Checks whether a response header exists.
549
+ *
550
+ * @param {string} name - The HTTP header name.
551
+ * @returns {boolean} `true` when the header exists; otherwise, `false`.
552
+ * @example
553
+ * ```js
554
+ * if (response.hasHeader('ETag')) {
555
+ * // The response already defines an ETag.
556
+ * }
557
+ * ```
558
+ */
559
+ hasHeader(name) {
560
+ return this.#headers.has(name);
561
+ }
562
+
563
+ /**
564
+ * Removes a response header.
565
+ *
566
+ * Removing a header that does not exist has no effect.
567
+ *
568
+ * @param {string} name - The HTTP header name.
569
+ * @returns {this} The response instance.
570
+ * @throws {ResponseError} If the response has already been committed.
571
+ * @throws {ResponseError} If `Content-Type` is already locked.
572
+ * @example
573
+ * ```js
574
+ * response.removeHeader('X-Debug');
575
+ * ```
576
+ */
577
+ removeHeader(name) {
578
+ this.#assertNotCommitted();
579
+ this.#headers.remove(name);
580
+ return this;
581
+ }
582
+
583
+ /**
584
+ * Returns a frozen copy of the configured response headers.
585
+ *
586
+ * @returns {Readonly<Record<string, string|number|(string|number)[]>>} A frozen copy of the configured headers.
587
+ * @example
588
+ * ```js
589
+ * const headers = response.getHeaders();
590
+ * ```
591
+ */
592
+ getHeaders() {
593
+ return Object.freeze(this.#headers.toObject());
594
+ }
595
+
596
+ /**
597
+ * Describes a JSON response body.
598
+ *
599
+ * This method does not send the response. It serializes the supplied value,
600
+ * sets a default JSON content type when one has not already been configured,
601
+ * and stores the serialized body until `end()` is called.
602
+ *
603
+ * The method throws `INVALID_JSON` when JSON serialization does not produce
604
+ * a JSON string or when serialization itself fails. `null` is a valid JSON
605
+ * value and is preserved.
606
+ *
607
+ * @param {*} data - The value to serialize as JSON.
608
+ * @returns {this} The response instance.
609
+ * @throws {ResponseError} If the response has already been committed.
610
+ * @throws {ResponseError} If another body has already been described.
611
+ * @throws {ResponseError} If the current status does not allow a body.
612
+ * @throws {ResponseError} If the value cannot be serialized as JSON.
613
+ * @example
614
+ * ```js
615
+ * response
616
+ * .status(201)
617
+ * .json({ id: 42, name: 'Alice' })
618
+ * .end();
619
+ * ```
620
+ */
621
+ json(data) {
622
+ this.#assertNotCommitted();
623
+ this.#assertBodyNotDescribed();
624
+ this.#assertBodyAllowed();
625
+
626
+ const body = this.#serializeJson(data);
627
+
628
+ this.#headers.ensureContentType('application/json');
629
+ this.#body = { kind: 'buffer', value: body };
630
+ this.#headers.lockContentType();
631
+ return this;
632
+ }
633
+
634
+ /**
635
+ * Describes an HTML response body.
636
+ *
637
+ * This method does not send the response. It sets a default HTML content
638
+ * type when one has not already been configured.
639
+ *
640
+ * @param {string} content - The HTML content.
641
+ * @returns {this} The response instance.
642
+ * @throws {ResponseError} If the response has already been committed.
643
+ * @throws {ResponseError} If another body has already been described.
644
+ * @throws {ResponseError} If the current status does not allow a body.
645
+ * @throws {ResponseError} If the content is not a string.
646
+ * @example
647
+ * ```js
648
+ * response
649
+ * .status(200)
650
+ * .html('<h1>Hello</h1>')
651
+ * .end();
652
+ * ```
653
+ */
654
+ html(content) {
655
+ this.#assertNotCommitted();
656
+ this.#assertBodyNotDescribed();
657
+ this.#assertBodyAllowed();
658
+ this.#assertStringBody(content);
659
+
660
+ this.#headers.ensureContentType('text/html');
661
+ this.#body = { kind: 'buffer', value: content };
662
+ this.#headers.lockContentType();
663
+ return this;
664
+ }
665
+
666
+ /**
667
+ * Describes a plain-text response body.
668
+ *
669
+ * This method does not send the response. It sets a default plain-text
670
+ * content type when one has not already been configured.
671
+ *
672
+ * @param {string} content - The plain-text content.
673
+ * @returns {this} The response instance.
674
+ * @throws {ResponseError} If the response has already been committed.
675
+ * @throws {ResponseError} If another body has already been described.
676
+ * @throws {ResponseError} If the current status does not allow a body.
677
+ * @throws {ResponseError} If the content is not a string.
678
+ * @example
679
+ * ```js
680
+ * response
681
+ * .text('Hello, world!')
682
+ * .end();
683
+ * ```
684
+ */
685
+ text(content) {
686
+ this.#assertNotCommitted();
687
+ this.#assertBodyNotDescribed();
688
+ this.#assertBodyAllowed();
689
+ this.#assertStringBody(content);
690
+
691
+ this.#headers.ensureContentType('text/plain');
692
+ this.#body = { kind: 'buffer', value: content };
693
+ this.#headers.lockContentType();
694
+ return this;
695
+ }
696
+
697
+ /**
698
+ * Describes an arbitrary string or binary response body.
699
+ *
700
+ * This method does not send the response and does not assign a default
701
+ * content type.
702
+ *
703
+ * @param {string|Uint8Array} body - The response body. Defaults to an empty string.
704
+ * @returns {this} The response instance.
705
+ * @throws {ResponseError} If the response has already been committed.
706
+ * @throws {ResponseError} If another body has already been described.
707
+ * @throws {ResponseError} If the current status does not allow a body.
708
+ * @throws {ResponseError} If the body is not a string or `Uint8Array`.
709
+ * @example
710
+ * ```js
711
+ * response
712
+ * .setHeader('Content-Type', 'application/octet-stream')
713
+ * .send(buffer)
714
+ * .end();
715
+ * ```
716
+ */
717
+ send(body = '') {
718
+ this.#assertNotCommitted();
719
+ this.#assertBodyNotDescribed();
720
+ this.#assertBodyAllowed();
721
+ this.#assertBufferedBody(body);
722
+
723
+ this.#body = { kind: 'buffer', value: body };
724
+ this.#headers.lockContentType();
725
+ return this;
726
+ }
727
+
728
+ /**
729
+ * Describes a readable stream as the response body.
730
+ *
731
+ * This method does not send the response. The stream is connected to the
732
+ * underlying server response only when `end()` is called.
733
+ *
734
+ * @param {NodeReadable} stream - The readable stream.
735
+ * @param {string} [contentType] - The content type to use when none is already configured.
736
+ * @returns {this} The response instance.
737
+ * @throws {ResponseError} If the response has already been committed.
738
+ * @throws {ResponseError} If another body has already been described.
739
+ * @throws {ResponseError} If the current status does not allow a body.
740
+ * @throws {ResponseError} If the stream is not a readable stream.
741
+ * @throws {ResponseError} If `contentType` is provided and is not a string.
742
+ * @example
743
+ * ```js
744
+ * response
745
+ * .stream(fileStream, 'application/pdf')
746
+ * .end();
747
+ * ```
748
+ */
749
+ stream(stream, contentType) {
750
+ this.#assertNotCommitted();
751
+ this.#assertBodyNotDescribed();
752
+ this.#assertBodyAllowed();
753
+
754
+ if (
755
+ stream === null ||
756
+ typeof stream !== 'object' ||
757
+ typeof stream.pipe !== 'function' ||
758
+ typeof stream.on !== 'function' ||
759
+ stream.readable === false
760
+ ) {
761
+ throw new ResponseError('INVALID_STREAM');
762
+ }
763
+
764
+ if (contentType !== undefined) {
765
+ this.#headers.ensureContentType(contentType);
766
+ }
767
+
768
+ this.#body = { kind: 'stream', value: stream };
769
+ this.#headers.lockContentType();
770
+ return this;
771
+ }
772
+
773
+ /**
774
+ * Describes an HTTP redirect response.
775
+ *
776
+ * The redirect status must be one of the supported redirect status codes:
777
+ * `301`, `302`, `303`, `307`, or `308`. The method does not assign a content
778
+ * type because the redirect does not require a response body.
779
+ *
780
+ * Internally, the response is marked as having a buffered body to prevent
781
+ * another body description from being added after the redirect is configured.
782
+ *
783
+ * @param {string} url - The redirect target.
784
+ * @param {number} statusCode - The redirect status code. Defaults to `302`.
785
+ * @returns {this} The response instance.
786
+ * @throws {ResponseError} If the response has already been committed.
787
+ * @throws {ResponseError} If another body has already been described.
788
+ * @throws {ResponseError} If the URL is empty or not a string.
789
+ * @throws {ResponseError} If the status code is not a supported redirect status.
790
+ * @example
791
+ * ```js
792
+ * response
793
+ * .redirect('/login')
794
+ * .end();
795
+ * ```
796
+ *
797
+ * @example
798
+ * ```js
799
+ * response
800
+ * .redirect('/new-location', 301)
801
+ * .end();
802
+ * ```
803
+ */
804
+ redirect(url, statusCode = 302) {
805
+ this.#assertNotCommitted();
806
+ this.#assertBodyNotDescribed();
807
+
808
+ if (typeof url !== 'string' || url.length === 0) {
809
+ throw new ResponseError('INVALID_REDIRECT_URL');
810
+ }
811
+
812
+ if (!REDIRECT_STATUS_CODES.has(statusCode)) {
813
+ throw new ResponseError('INVALID_REDIRECT_STATUS', {
814
+ values: { statusCode },
815
+ });
816
+ }
817
+
818
+ this.status(statusCode);
819
+ this.setHeader('Location', url);
820
+
821
+ this.#body = { kind: 'buffer', value: '' };
822
+ this.#headers.lockContentType();
823
+ return this;
824
+ }
825
+
826
+ /**
827
+ * Adds one or more field names to the `Vary` response header.
828
+ *
829
+ * Duplicate values are ignored. Field names are normalized to lowercase.
830
+ *
831
+ * @param {...string} fields - Header field names to add to `Vary`.
832
+ * @returns {this} The response instance.
833
+ * @throws {ResponseError} If the response has already been committed.
834
+ * @throws {TypeError} If any field is not a non-empty string.
835
+ * @example
836
+ * ```js
837
+ * response.vary('Accept', 'Accept-Encoding');
838
+ * response.vary('Origin');
839
+ * ```
840
+ */
841
+ vary(...fields) {
842
+ this.#assertNotCommitted();
843
+
844
+ if (fields.length === 0) {
845
+ return this;
846
+ }
847
+
848
+ const existing = this.#headers.get('vary');
849
+ const values = new Set();
850
+
851
+ if (typeof existing === 'string') {
852
+ for (const part of existing.split(',')) {
853
+ const trimmed = part.trim().toLowerCase();
854
+ if (trimmed) values.add(trimmed);
855
+ }
856
+ } else if (Array.isArray(existing)) {
857
+ for (const item of existing) {
858
+ const trimmed = String(item).trim().toLowerCase();
859
+ if (trimmed) values.add(trimmed);
860
+ }
861
+ }
862
+
863
+ for (const field of fields) {
864
+ if (typeof field !== 'string' || field.trim().length === 0) {
865
+ throw new TypeError('Vary field must be a non-empty string');
866
+ }
867
+ values.add(field.trim().toLowerCase());
868
+ }
869
+
870
+ return this.setHeader('Vary', [...values].join(', '));
871
+ }
872
+
873
+ /**
874
+ * Configures a public cache lifetime using `Cache-Control`.
875
+ *
876
+ * @param {number} seconds - The maximum cache lifetime in seconds.
877
+ * @returns {this} The response instance.
878
+ * @throws {ResponseError} If the response has already been committed.
879
+ * @throws {ResponseError} If the duration is not a non-negative integer.
880
+ * @example
881
+ * ```js
882
+ * response
883
+ * .cache(3600)
884
+ * .json({ message: 'cached' })
885
+ * .end();
886
+ * ```
887
+ */
888
+ cache(seconds) {
889
+ this.#assertNotCommitted();
890
+
891
+ if (!Number.isInteger(seconds) || seconds < 0) {
892
+ throw new ResponseError('INVALID_CACHE_DURATION');
893
+ }
894
+
895
+ return this.setHeader('Cache-Control', `public, max-age=${seconds}`);
896
+ }
897
+
898
+ /**
899
+ * Disables client and intermediary caching for the response.
900
+ *
901
+ * @returns {this} The response instance.
902
+ * @throws {ResponseError} If the response has already been committed.
903
+ * @example
904
+ * ```js
905
+ * response
906
+ * .noCache()
907
+ * .json({ message: 'private data' })
908
+ * .end();
909
+ * ```
910
+ */
911
+ noCache() {
912
+ this.#assertNotCommitted();
913
+
914
+ return this.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
915
+ .setHeader('Pragma', 'no-cache')
916
+ .setHeader('Expires', '0');
917
+ }
918
+
919
+ /**
920
+ * Materializes and sends the configured response.
921
+ *
922
+ * The method is idempotent. The first invocation runs the optional commit
923
+ * hook, materializes the configured response, and marks it as committed.
924
+ * Subsequent invocations return the same response without sending again.
925
+ *
926
+ * Errors produced by the commit hook, by the underlying transport calls,
927
+ * or by a response stream are forwarded to the optional `onCommitError`
928
+ * hook. Those errors are considered handled by that hook; `Response` does
929
+ * not retry or otherwise recover the operation.
930
+ *
931
+ * Note: if a stream body fails after headers have been sent, the response
932
+ * to the client is already in progress and cannot be reverted to a `500`
933
+ * status. The error is forwarded to `onCommitError` for observability only.
934
+ *
935
+ * @returns {this} The response instance.
936
+ * @example
937
+ * ```js
938
+ * response
939
+ * .status(200)
940
+ * .json({ message: 'ok' })
941
+ * .end();
942
+ * ```
943
+ *
944
+ * @example
945
+ * ```js
946
+ * response.end();
947
+ * response.end();
948
+ * ```
949
+ */
950
+ end() {
951
+ if (this.#committed) {
952
+ return this;
953
+ }
954
+
955
+ if (this.#onCommit) {
956
+ try {
957
+ this.#onCommit(this);
958
+ } catch (error) {
959
+ this.#handleCommitError(this.#normalizeError(error));
960
+ }
961
+ }
962
+
963
+ try {
964
+ if (EMPTY_BODY_STATUS_CODES.has(this.#statusCode)) {
965
+ if (this.#statusCode === 205) {
966
+ this.#headers.set('content-length', '0');
967
+ } else {
968
+ this.#headers.remove('content-length');
969
+ }
970
+
971
+ this.#writeHead();
972
+ this.#committed = true;
973
+ this.#serverResponse.end();
974
+ return this;
975
+ }
976
+
977
+ if (this.#body.kind === 'stream') {
978
+ const stream = this.#body.value;
979
+
980
+ this.#writeHead();
981
+ this.#committed = true;
982
+
983
+ /**
984
+ * Handles errors produced by the response pipeline.
985
+ *
986
+ * @param {unknown} error - Error produced by the response pipeline.
987
+ */
988
+ const onError = error => {
989
+ this.#handleCommitError(this.#normalizeError(error));
990
+ };
991
+
992
+ if (this.#wrap !== null) {
993
+ pipeline(this.#wrap.source, this.#wrap.transform, this.#serverResponse).catch(onError);
994
+ } else {
995
+ pipeline(stream, this.#serverResponse).catch(onError);
996
+ }
997
+
998
+ return this;
999
+ }
1000
+
1001
+ const body = this.#body.value ?? '';
1002
+ const contentLength = body instanceof Uint8Array ? body.byteLength : Buffer.byteLength(body);
1003
+
1004
+ if (this.#body.kind !== 'none') {
1005
+ this.#headers.set('content-length', String(contentLength));
1006
+ } else if (!this.#headers.has('content-length')) {
1007
+ this.#headers.set('content-length', '0');
1008
+ }
1009
+
1010
+ if (this.#etag && !this.#headers.has('etag')) {
1011
+ this.#headers.set('etag', this.#computeEtag(body));
1012
+ }
1013
+
1014
+ this.#writeHead();
1015
+ this.#committed = true;
1016
+ this.#serverResponse.end(body);
1017
+ return this;
1018
+ } catch (error) {
1019
+ this.#handleCommitError(this.#normalizeError(error));
1020
+ return this;
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Writes the status line and headers to the underlying server response.
1026
+ *
1027
+ * HTTP/1.1 and HTTP/2 expose `writeHead` with incompatible TypeScript
1028
+ * signatures. Because the internal response can be either one, this method
1029
+ * narrows the type at runtime before calling `writeHead`, allowing
1030
+ * TypeScript to resolve a single signature per branch without relying on
1031
+ * a type assertion.
1032
+ *
1033
+ * The default charset is applied here, on the plain header object, so that
1034
+ * the locked `Content-Type` in `ResponseHeaders` is not mutated.
1035
+ */
1036
+ #writeHead() {
1037
+ const response = this.#serverResponse;
1038
+ const statusCode = this.#statusCode;
1039
+
1040
+ /** @type {import('node:http').OutgoingHttpHeaders} */
1041
+ const headers = Object.create(null);
1042
+ const plainHeaders = this.#headers.toObject();
1043
+
1044
+ for (const [name, value] of Object.entries(plainHeaders)) {
1045
+ headers[name] = Array.isArray(value) ? value.map(String) : value;
1046
+ }
1047
+
1048
+ if (this.#defaultCharset && typeof headers['content-type'] === 'string') {
1049
+ headers['content-type'] = this.#applyDefaultCharset(headers['content-type']);
1050
+ }
1051
+
1052
+ if ('stream' in response) {
1053
+ // Http2ServerResponse.
1054
+ response.writeHead(statusCode, headers);
1055
+ } else {
1056
+ // HttpServerResponse.
1057
+ response.writeHead(statusCode, headers);
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Appends the configured default charset to a `Content-Type` value when
1063
+ * the media type is eligible and no charset parameter is already present.
1064
+ *
1065
+ * @param {string} contentType - The current `Content-Type` value.
1066
+ * @returns {string} The normalized `Content-Type` value.
1067
+ */
1068
+ #applyDefaultCharset(contentType) {
1069
+ if (CHARSET_PARAMETER_PATTERN.test(contentType)) {
1070
+ return contentType;
1071
+ }
1072
+
1073
+ const [mediaType] = contentType.split(';', 1);
1074
+ const normalized = mediaType.trim().toLowerCase();
1075
+
1076
+ const applies =
1077
+ normalized.startsWith('text/') ||
1078
+ CHARSET_ELIGIBLE_MEDIA_TYPES.has(normalized) ||
1079
+ normalized.endsWith('+json') ||
1080
+ normalized.endsWith('+xml');
1081
+
1082
+ if (!applies) {
1083
+ return contentType;
1084
+ }
1085
+
1086
+ return `${contentType.trim()}; charset=${this.#defaultCharset}`;
1087
+ }
1088
+
1089
+ /**
1090
+ * Computes a weak ETag for a buffered body.
1091
+ *
1092
+ * @param {string|Uint8Array} body - The buffered body.
1093
+ * @returns {string} The weak ETag value.
1094
+ */
1095
+ #computeEtag(body) {
1096
+ const buffer = body instanceof Uint8Array ? body : Buffer.from(body);
1097
+ const hash = createHash('sha256').update(buffer).digest('base64url');
1098
+ return `W/"${hash}"`;
1099
+ }
1100
+
1101
+ /**
1102
+ * Normalizes an unknown thrown value into an `Error` instance.
1103
+ *
1104
+ * JavaScript allows throwing any value. TypeScript types `catch` clauses
1105
+ * as `unknown`. This helper preserves existing `Error` instances and wraps
1106
+ * everything else in a new `Error` so downstream code always receives an
1107
+ * `Error`.
1108
+ *
1109
+ * @param {unknown} error - The value thrown or rejected.
1110
+ * @returns {Error} The normalized error instance.
1111
+ */
1112
+ #normalizeError(error) {
1113
+ return error instanceof Error ? error : new Error(String(error));
1114
+ }
1115
+
1116
+ /**
1117
+ * Serializes a value as JSON.
1118
+ *
1119
+ * @param {*} data - The value to serialize.
1120
+ * @returns {string} The serialized JSON string.
1121
+ * @throws {ResponseError} If the value cannot be serialized as JSON.
1122
+ */
1123
+ #serializeJson(data) {
1124
+ const body = this.#stringify(data);
1125
+
1126
+ if (body === undefined) {
1127
+ throw new ResponseError('INVALID_JSON', {
1128
+ values: { reason: 'value is not serializable to JSON' },
1129
+ });
1130
+ }
1131
+
1132
+ return body;
1133
+ }
1134
+
1135
+ /**
1136
+ * Serializes a value using the native JSON serializer.
1137
+ *
1138
+ * @param {*} data - The value to serialize.
1139
+ * @returns {string|undefined} The serialized JSON string, or `undefined` when the value is not serializable as a JSON value.
1140
+ * @throws {ResponseError} If JSON serialization fails.
1141
+ */
1142
+ #stringify(data) {
1143
+ try {
1144
+ return JSON.stringify(data);
1145
+ } catch (error) {
1146
+ throw new ResponseError('INVALID_JSON', {
1147
+ cause: this.#normalizeError(error),
1148
+ });
1149
+ }
1150
+ }
1151
+
1152
+ /**
1153
+ * Validates that a value is a string.
1154
+ *
1155
+ * @param {unknown} content - The value to validate.
1156
+ * @throws {ResponseError} If the value is not a string.
1157
+ */
1158
+ #assertStringBody(content) {
1159
+ if (typeof content !== 'string') {
1160
+ throw new ResponseError('INVALID_BODY', {
1161
+ values: {
1162
+ expected: 'string',
1163
+ received: content === null ? 'null' : typeof content,
1164
+ },
1165
+ });
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Validates that a value is a supported buffered body.
1171
+ *
1172
+ * Accepted types:
1173
+ * - `string`.
1174
+ * - `Uint8Array` (includes Node.js `Buffer`).
1175
+ *
1176
+ * @param {unknown} body - The response body to validate.
1177
+ * @throws {ResponseError} If the body type is not supported.
1178
+ */
1179
+ #assertBufferedBody(body) {
1180
+ const isString = typeof body === 'string';
1181
+ const isBuffer = body instanceof Uint8Array;
1182
+
1183
+ if (!isString && !isBuffer) {
1184
+ throw new ResponseError('INVALID_BODY', {
1185
+ values: {
1186
+ received: body === null ? 'null' : typeof body,
1187
+ },
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Forwards a commit error to the configured error hook.
1194
+ *
1195
+ * Errors thrown by the error hook itself are intentionally ignored because
1196
+ * the error hook is an observability boundary and must not create another
1197
+ * response failure.
1198
+ *
1199
+ * @param {Error} error - The error produced during response commitment.
1200
+ */
1201
+ #handleCommitError(error) {
1202
+ if (this.#onCommitError) {
1203
+ try {
1204
+ this.#onCommitError(error);
1205
+ } catch {
1206
+ // The error hook must never interrupt response processing.
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * Ensures that the response has not already been committed.
1213
+ *
1214
+ * @throws {ResponseError} If the response has already been committed.
1215
+ */
1216
+ #assertNotCommitted() {
1217
+ if (this.#committed) {
1218
+ throw new ResponseError('RESPONSE_ALREADY_SENT');
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Ensures that no body has already been described.
1224
+ *
1225
+ * @throws {ResponseError} If a body has already been described.
1226
+ */
1227
+ #assertBodyNotDescribed() {
1228
+ if (this.#body.kind !== 'none') {
1229
+ throw new ResponseError('BODY_ALREADY_DESCRIBED');
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Ensures that the current status code permits a response body.
1235
+ *
1236
+ * @throws {ResponseError} If the current status code does not allow a body.
1237
+ */
1238
+ #assertBodyAllowed() {
1239
+ if (EMPTY_BODY_STATUS_CODES.has(this.#statusCode)) {
1240
+ throw new ResponseError('BODY_NOT_ALLOWED', {
1241
+ values: { statusCode: this.#statusCode },
1242
+ });
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Restores the response to its initial uncommitted state.
1248
+ *
1249
+ * @returns {void}
1250
+ * @throws {ResponseError} If the response has already been committed.
1251
+ */
1252
+ #discardUncommittedState() {
1253
+ if (this.#committed) {
1254
+ throw new ResponseError('RESPONSE_ALREADY_SENT');
1255
+ }
1256
+
1257
+ this.#statusCode = DEFAULT_STATUS_CODE;
1258
+ this.#headers = new ResponseHeaders();
1259
+ this.#body = { kind: 'none', value: null };
1260
+ this.#wrap = null;
1261
+ }
1262
+
1263
+ /**
1264
+ * Prepares the current body to be piped through a Transform stream when
1265
+ * `end()` materializes the response.
1266
+ *
1267
+ * Does not start data flow. Buffer bodies become a `Readable` source;
1268
+ * stream bodies reuse the existing readable as source. The internal body
1269
+ * state becomes `{ kind: 'stream', value: transformStream }` and
1270
+ * `Content-Length` is removed.
1271
+ *
1272
+ * May be called at most once. Only while the response is not committed
1273
+ * and a body has already been described.
1274
+ *
1275
+ * @param {NodeTransform} transformStream - Transform suitable as an intermediate stage of `pipeline()`.
1276
+ * @throws {ResponseError} If the response has already been committed.
1277
+ * @throws {ResponseError} If no body has been described.
1278
+ * @throws {ResponseError} If the body has already been wrapped.
1279
+ * @throws {ResponseError} If `transformStream` is not a usable Transform.
1280
+ */
1281
+ #wrapBody(transformStream) {
1282
+ this.#assertNotCommitted();
1283
+
1284
+ if (this.#wrap !== null) {
1285
+ throw new ResponseError('BODY_ALREADY_WRAPPED');
1286
+ }
1287
+
1288
+ if (this.#body.kind === 'none') {
1289
+ throw new ResponseError('BODY_NOT_DESCRIBED');
1290
+ }
1291
+
1292
+ if (
1293
+ transformStream === null ||
1294
+ typeof transformStream !== 'object' ||
1295
+ typeof transformStream.pipe !== 'function' ||
1296
+ typeof transformStream.on !== 'function' ||
1297
+ transformStream.readable !== true ||
1298
+ transformStream.writable !== true
1299
+ ) {
1300
+ throw new ResponseError('INVALID_STREAM');
1301
+ }
1302
+
1303
+ const source =
1304
+ this.#body.kind === 'buffer'
1305
+ ? Readable.from([
1306
+ this.#body.value instanceof Uint8Array
1307
+ ? this.#body.value
1308
+ : Buffer.from(this.#body.value),
1309
+ ])
1310
+ : this.#body.value;
1311
+
1312
+ this.#wrap = { source, transform: transformStream };
1313
+ this.#body = { kind: 'stream', value: transformStream };
1314
+ this.#headers.remove('content-length');
1315
+ }
1316
+
1317
+ /**
1318
+ * Wraps the response body with a Transform stream.
1319
+ *
1320
+ * Runtime-internal entry point. Same pattern as `discardUncommittedState`.
1321
+ * Not part of the public instance API of Response.
1322
+ *
1323
+ * @param {Response} response - Response whose body is wrapped.
1324
+ * @param {NodeTransform} transformStream - Transform stream used as the intermediate pipeline stage.
1325
+ * @throws {TypeError} If `response` is not a `Response` instance.
1326
+ * @throws {ResponseError} If the response cannot be wrapped (see `#wrapBody`).
1327
+ * @example
1328
+ * ```js
1329
+ * import { createGzip } from 'node:zlib';
1330
+ * Response.wrapBody(response, createGzip());
1331
+ * ```
1332
+ */
1333
+ static wrapBody(response, transformStream) {
1334
+ if (!(response instanceof Response)) {
1335
+ throw new TypeError('Expected a Response instance');
1336
+ }
1337
+
1338
+ response.#wrapBody(transformStream);
1339
+ }
1340
+
1341
+ /**
1342
+ * Discards the uncommitted state of a response, restoring it to its
1343
+ * initial state.
1344
+ *
1345
+ * This operation is runtime-internal. It is intended to be called by the
1346
+ * Kernel when an error occurs before the response has been committed, so
1347
+ * that the system error handler can build a clean response.
1348
+ *
1349
+ * @param {Response} response - Response whose uncommitted state is discarded.
1350
+ * @throws {TypeError} If `response` is not a `Response` instance.
1351
+ * @throws {ResponseError} If the response has already been committed.
1352
+ * @example
1353
+ * ```js
1354
+ * // Kernel error recovery:
1355
+ * Response.discardUncommittedState(exchange.response);
1356
+ * await systemError(error, exchange);
1357
+ * exchange.response.end();
1358
+ * ```
1359
+ */
1360
+ static discardUncommittedState(response) {
1361
+ if (!(response instanceof Response)) {
1362
+ throw new TypeError('Expected a Response instance');
1363
+ }
1364
+
1365
+ response.#discardUncommittedState();
1366
+ }
1367
+ }
1368
+
1369
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1370
+ export default Response;