webhoster 0.1.0 → 0.3.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 (86) hide show
  1. package/.eslintrc.json +74 -58
  2. package/.github/copilot-instructions.md +100 -0
  3. package/.github/workflows/test-matrix.yml +37 -0
  4. package/.test/benchmark.js +28 -0
  5. package/.test/constants.js +4 -0
  6. package/{test → .test}/http2server.js +1 -1
  7. package/{test → .test}/httpserver.js +1 -1
  8. package/{test → .test}/index.js +178 -192
  9. package/.test/multipromise.js +32 -0
  10. package/{test → .test}/tls.js +3 -3
  11. package/{test → .test}/urlencoded.js +3 -0
  12. package/.vscode/launch.json +24 -3
  13. package/README.md +116 -90
  14. package/data/CookieObject.js +14 -14
  15. package/errata/socketio.js +6 -11
  16. package/examples/starter.js +11 -0
  17. package/helpers/HeadersParser.js +7 -8
  18. package/helpers/HttpListener.js +387 -42
  19. package/helpers/RequestHeaders.js +43 -36
  20. package/helpers/RequestReader.js +27 -26
  21. package/helpers/ResponseHeaders.js +47 -36
  22. package/jsconfig.json +1 -1
  23. package/lib/HttpHandler.js +447 -277
  24. package/lib/HttpRequest.js +383 -39
  25. package/lib/HttpResponse.js +316 -52
  26. package/lib/HttpTransaction.js +146 -0
  27. package/middleware/AutoHeadersMiddleware.js +73 -0
  28. package/middleware/CORSMiddleware.js +45 -47
  29. package/middleware/CaseInsensitiveHeadersMiddleware.js +5 -11
  30. package/middleware/ContentDecoderMiddleware.js +81 -35
  31. package/middleware/ContentEncoderMiddleware.js +179 -132
  32. package/middleware/ContentLengthMiddleware.js +66 -43
  33. package/middleware/ContentWriterMiddleware.js +5 -11
  34. package/middleware/HashMiddleware.js +68 -40
  35. package/middleware/HeadMethodMiddleware.js +24 -22
  36. package/middleware/MethodMiddleware.js +29 -36
  37. package/middleware/PathMiddleware.js +49 -66
  38. package/middleware/ReadFormData.js +99 -0
  39. package/middleware/SendHeadersMiddleware.js +0 -2
  40. package/middleware/SendJsonMiddleware.js +131 -0
  41. package/middleware/SendStringMiddleware.js +87 -0
  42. package/package.json +38 -29
  43. package/polyfill/FormData.js +164 -0
  44. package/rollup.config.js +0 -1
  45. package/scripts/test-all-sync.sh +6 -0
  46. package/scripts/test-all.sh +7 -0
  47. package/templates/starter.js +53 -0
  48. package/test/fixtures/stream.js +68 -0
  49. package/test/helpers/HttpListener/construct.js +18 -0
  50. package/test/helpers/HttpListener/customOptions.js +22 -0
  51. package/test/helpers/HttpListener/doubleCreate.js +40 -0
  52. package/test/helpers/HttpListener/events.js +77 -0
  53. package/test/helpers/HttpListener/http.js +31 -0
  54. package/test/helpers/HttpListener/http2.js +41 -0
  55. package/test/helpers/HttpListener/https.js +38 -0
  56. package/test/helpers/HttpListener/startAll.js +31 -0
  57. package/test/helpers/HttpListener/stopNotStarted.js +23 -0
  58. package/test/lib/HttpHandler/class.js +8 -0
  59. package/test/lib/HttpHandler/handleRequest.js +11 -0
  60. package/test/lib/HttpHandler/middleware.js +941 -0
  61. package/test/lib/HttpHandler/parse.js +41 -0
  62. package/test/lib/HttpRequest/class.js +8 -0
  63. package/test/lib/HttpRequest/downstream.js +171 -0
  64. package/test/lib/HttpRequest/properties.js +101 -0
  65. package/test/lib/HttpRequest/read.js +518 -0
  66. package/test/lib/HttpResponse/class.js +8 -0
  67. package/test/lib/HttpResponse/properties.js +59 -0
  68. package/test/lib/HttpResponse/send.js +275 -0
  69. package/test/lib/HttpTransaction/class.js +8 -0
  70. package/test/lib/HttpTransaction/ping.js +50 -0
  71. package/test/lib/HttpTransaction/push.js +89 -0
  72. package/test/middleware/SendJsonMiddleware.js +222 -0
  73. package/test/sanity.js +10 -0
  74. package/test/templates/starter.js +93 -0
  75. package/tsconfig.json +12 -0
  76. package/types/index.js +61 -34
  77. package/types/typings.d.ts +8 -9
  78. package/utils/AsyncObject.js +6 -3
  79. package/utils/CaseInsensitiveObject.js +2 -3
  80. package/utils/function.js +1 -7
  81. package/utils/headers.js +42 -0
  82. package/utils/qualityValues.js +1 -1
  83. package/utils/stream.js +4 -20
  84. package/index.cjs +0 -3200
  85. package/test/constants.js +0 -4
  86. /package/{test → .test}/cookietester.js +0 -0
@@ -1,44 +1,56 @@
1
- import { Transform } from 'stream';
2
- import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
1
+ import { promisify } from 'node:util';
2
+ import {
3
+ // @ts-expect-error Bad types
4
+ BrotliCompress, Deflate, Gzip,
5
+ constants as ZlibContants,
6
+ brotliCompress, brotliCompressSync,
7
+ deflate, deflateSync,
8
+ gzip, gzipSync,
9
+ } from 'node:zlib';
3
10
 
4
11
  import { parseQualityValues } from '../utils/qualityValues.js';
5
12
 
6
- /** @typedef {import('../types').IMiddleware} IMiddleware */
13
+ const { BROTLI_OPERATION_FLUSH, Z_SYNC_FLUSH } = ZlibContants;
14
+
15
+ /** @typedef {import('http').IncomingHttpHeaders} IncomingHttpHeaders */
16
+ /** @typedef {import('../lib/HttpRequest.js').default} HttpRequest */
17
+ /** @typedef {import('../lib/HttpResponse.js').default} HttpResponse */
7
18
  /** @typedef {import('../types').MiddlewareFunction} MiddlewareFunction */
8
- /** @typedef {import('../types').MiddlewareFunctionParams} MiddlewareFunctionParams */
9
- /** @typedef {import('../types').MiddlewareFunctionResult} MiddlewareFunctionResult */
19
+ /** @typedef {import('../types').ResponseFinalizer} ResponseFinalizer */
10
20
 
11
21
  /** @typedef {'br'|'gzip'|'deflate'|'identity'|'*'} COMPATIBLE_ENCODING */
12
22
 
13
23
  const DEFAULT_MINIMUM_SIZE = 256;
14
24
 
25
+ const DEFAULT_ASYNC_THRESHOLD = 64 * 1024;
26
+
15
27
  /**
16
28
  * @typedef ContentEncoderMiddlewareOptions
17
29
  * @prop {number} [chunkSize]
18
30
  * @prop {boolean} [respondNotAcceptable=false]
19
31
  * @prop {'br'|'gzip'|'deflate'|'identity'} [preferredEncoding='identity']
32
+ * Minimum content size before using any compression
20
33
  * @prop {number} [minimumSize=DEFAULT_MINIMUM_SIZE]
34
+ * Minimum content size before using async compression
35
+ * @prop {number} [asyncThreshold=DEFAULT_ASYNC_THRESHOLD]
21
36
  */
22
37
 
23
38
  /** @type {COMPATIBLE_ENCODING[]} */
24
39
  const COMPATIBLE_ENCODINGS = ['br', 'gzip', 'deflate', 'identity', '*'];
25
40
 
26
- /** @implements {IMiddleware} */
27
41
  export default class ContentEncoderMiddleware {
28
- /** @param {ContentEncoderMiddlewareOptions} [options] */
29
- constructor(options = {}) {
30
- this.chunkSize = options.chunkSize;
31
- this.respondNotAcceptable = options.respondNotAcceptable === true;
32
- this.preferredEncoding = options.preferredEncoding ?? 'identity';
33
- this.minimumSize = options.minimumSize ?? DEFAULT_MINIMUM_SIZE;
34
- }
42
+ static BrotliCompressAsync = promisify(brotliCompress);
43
+
44
+ static GzipAsync = promisify(gzip);
45
+
46
+ static DeflateAsync = promisify(deflate);
35
47
 
36
48
  /**
37
- * @param {import('../types/index.js').HttpRequest} req
49
+ * @param {IncomingHttpHeaders} headers
38
50
  * @throws {NotAcceptableException} Error with `NOT_ACCEPTIBLE` message
39
51
  * @return {COMPATIBLE_ENCODING}
40
52
  */
41
- static chooseEncoding(req) {
53
+ static chooseEncoding(headers) {
42
54
  /**
43
55
  * A request without an Accept-Encoding header field implies that the
44
56
  * user agent has no preferences regarding content-codings. Although
@@ -46,12 +58,11 @@ export default class ContentEncoderMiddleware {
46
58
  * does not imply that the user agent will be able to correctly process
47
59
  * all encodings.
48
60
  */
49
- if ('accept-encoding' in req.headers === false) {
61
+ if ('accept-encoding' in headers === false) {
50
62
  return '*';
51
63
  }
52
64
 
53
- /** @type {string} */
54
- const acceptString = (req.headers['accept-encoding']);
65
+ const acceptString = /** @type {string} */ (headers['accept-encoding']);
55
66
  const encodings = parseQualityValues(acceptString?.toLowerCase());
56
67
  if (!encodings.size) {
57
68
  /**
@@ -64,18 +75,18 @@ export default class ContentEncoderMiddleware {
64
75
  let encoding = COMPATIBLE_ENCODINGS[0];
65
76
  const allowWildcards = (encodings.get('*')?.q !== 0);
66
77
  const encodingEntries = [...encodings.entries()];
67
- // @ts-ignore Cannot cast to COMPATIBLE_ENCODINGS
78
+ // @ts-expect-error Cannot cast to COMPATIBLE_ENCODINGS
68
79
  encoding = (encodingEntries.find(([value, spec]) => spec.q !== 0 && COMPATIBLE_ENCODINGS.includes(value))?.[0]);
69
80
  if (allowWildcards && (encoding === '*' || !encoding)) {
70
- // Server preference
71
- // Get first compatible encoding not specified
81
+ // Server preference
82
+ // Get first compatible encoding not specified
72
83
  encoding = COMPATIBLE_ENCODINGS.find((value) => !encodings.has(value));
73
84
  }
74
85
  if (allowWildcards && !encoding) {
75
- // Get highest q'd compatible encoding not q=0 or '*'
76
- // @ts-ignore Cannot cast to COMPATIBLE_ENCODINGS
77
- encoding = encodingEntries
78
- // @ts-ignore Cannot cast to COMPATIBLE_ENCODINGS
86
+ // Get highest q'd compatible encoding not q=0 or '*'
87
+ // @ts-expect-error Cannot cast to COMPATIBLE_ENCODINGS
88
+ encoding = /** @type {COMPATIBLE_ENCODINGS} */ encodingEntries
89
+ // @ts-expect-error Cannot cast to COMPATIBLE_ENCODINGS
79
90
  .find(([value, spec]) => spec.q !== 0 && value !== '*' && COMPATIBLE_ENCODINGS.includes(value))?.[0];
80
91
  }
81
92
  if (!encoding) {
@@ -84,28 +95,32 @@ export default class ContentEncoderMiddleware {
84
95
  return encoding;
85
96
  }
86
97
 
98
+ /** @param {ContentEncoderMiddlewareOptions} [options] */
99
+ constructor(options = {}) {
100
+ this.chunkSize = options.chunkSize;
101
+ this.respondNotAcceptable = options.respondNotAcceptable === true;
102
+ this.preferredEncoding = options.preferredEncoding ?? 'identity';
103
+ this.minimumSize = options.minimumSize ?? DEFAULT_MINIMUM_SIZE;
104
+ this.asyncThreshold = options.asyncThreshold ?? DEFAULT_ASYNC_THRESHOLD;
105
+ this.finalizeResponse = this.finalizeResponse.bind(this);
106
+ }
107
+
87
108
  /**
88
- * Implements `Accept-Encoding`
89
- * https://tools.ietf.org/html/rfc7231#section-5.3.4
90
- * @param {MiddlewareFunctionParams} params
91
- * @return {MiddlewareFunctionResult}
109
+ * @param {HttpResponse} response
110
+ * @return {void}
92
111
  */
93
- execute({ req, res }) {
94
- if (req.method === 'HEAD') {
95
- // Never needs content-encoding
96
- return 'continue';
97
- }
98
-
112
+ addTransformStream(response) {
99
113
  /** @type {COMPATIBLE_ENCODING} */
100
114
  let parsedEncoding;
101
115
  if (this.respondNotAcceptable) {
102
- // Parse now to catch the error;
116
+ // Parse now to catch the error;
103
117
  try {
104
- parsedEncoding = ContentEncoderMiddleware.chooseEncoding(req);
118
+ parsedEncoding = ContentEncoderMiddleware.chooseEncoding(response.request.headers);
105
119
  } catch (error) {
106
120
  if (error?.message === 'NOT_ACCEPTABLE') {
107
- res.status = 406;
108
- return 'end';
121
+ response.status = 406;
122
+ response.end();
123
+ throw new Error('NOT_ACCEPTABLE');
109
124
  }
110
125
  // Unknown error
111
126
  throw error;
@@ -116,7 +131,7 @@ export default class ContentEncoderMiddleware {
116
131
  const getContentEncoding = () => {
117
132
  if (!parsedEncoding) {
118
133
  try {
119
- parsedEncoding = ContentEncoderMiddleware.chooseEncoding(req);
134
+ parsedEncoding = ContentEncoderMiddleware.chooseEncoding(response.request.headers);
120
135
  } catch (error) {
121
136
  if (error?.message !== 'NOT_ACCEPTABLE') {
122
137
  throw error;
@@ -126,118 +141,150 @@ export default class ContentEncoderMiddleware {
126
141
  if (!parsedEncoding || parsedEncoding === '*') {
127
142
  parsedEncoding = this.preferredEncoding || 'identity';
128
143
  }
129
- res.headers['content-encoding'] = parsedEncoding;
144
+ response.headers['content-encoding'] = parsedEncoding;
130
145
  return parsedEncoding;
131
146
  };
132
147
 
133
- let finalCalled = false;
134
- let transformCount = 0;
135
- let inputLength = 0;
136
- const newStream = new Transform({
137
- transform(chunk, encoding, callback) {
138
- transformCount += 1;
139
- inputLength += chunk.length;
140
- // Stall to see if more chunks are in transit
141
- process.nextTick(() => {
142
- this.push(chunk);
148
+ let encoding = response.request.headers['content-encoding'];
149
+ // Only continue if unset (missing header). Blank is still considered set.
150
+ // This allows forced encoding (eg: use gzip regardless of size; always identity)
151
+
152
+ // Unset means server preference
153
+ if (encoding == null) {
154
+ encoding = getContentEncoding().toLowerCase?.();
155
+ }
156
+
157
+ const isEventStream = response.headers['content-type']?.includes('text/event-stream');
158
+
159
+ let newStream;
160
+ switch (encoding) {
161
+ case 'br':
162
+ // @ts-expect-error Bad types
163
+ newStream = new BrotliCompress({
164
+ chunkSize: this.chunkSize,
165
+ flush: isEventStream ? BROTLI_OPERATION_FLUSH : undefined,
143
166
  });
144
- callback();
145
- },
146
- final(callback) {
147
- finalCalled = true;
148
- callback();
149
- },
150
- });
151
- const destination = res.replaceStream(newStream);
167
+ break;
168
+ case 'gzip':
169
+ // @ts-expect-error Bad types
170
+ newStream = new Gzip({
171
+ chunkSize: this.chunkSize,
172
+ flush: isEventStream ? Z_SYNC_FLUSH : undefined,
173
+ });
174
+ break;
175
+ case 'deflate':
176
+ // @ts-expect-error Bad types
177
+ newStream = new Deflate({
178
+ chunkSize: this.chunkSize,
179
+ flush: isEventStream ? Z_SYNC_FLUSH : undefined,
180
+ });
181
+ break;
182
+ default:
183
+ return;
184
+ }
185
+ response.pipes.push(newStream);
186
+ }
152
187
 
153
- /**
154
- * @param {'br'|'gzip'|'deflate'} encoding
155
- * @return {import("zlib").Gzip}
156
- */
157
- const buildGzipStream = (encoding) => {
158
- /** @type {import("zlib").Gzip} */
159
- let gzipStream;
160
- switch (encoding) {
161
- case 'deflate':
162
- gzipStream = createDeflate({ chunkSize: this.chunkSize });
163
- break;
164
- case 'gzip':
165
- gzipStream = createGzip({ chunkSize: this.chunkSize });
166
- break;
167
- case 'br':
168
- gzipStream = createBrotliCompress({ chunkSize: this.chunkSize });
169
- break;
170
- default:
171
- throw new Error('UNKNOWN_ENCODING');
172
- }
188
+ /** @type {ResponseFinalizer} */
189
+ finalizeResponse(response) {
190
+ if (response.isStreaming) {
191
+ this.addTransformStream(response);
192
+ return true;
193
+ }
173
194
 
174
- /** @type {Buffer[]} */
175
- const pendingChunks = [];
176
-
177
- gzipStream.on('data', (chunk) => {
178
- if (finalCalled) {
179
- pendingChunks.push(chunk);
180
- } else {
181
- let previousChunk;
182
- // eslint-disable-next-line no-cond-assign
183
- while (previousChunk = pendingChunks.shift()) {
184
- destination.write(previousChunk);
185
- }
186
- destination.write(chunk);
187
- }
188
- });
189
- gzipStream.on('end', () => {
190
- let chunk;
191
- // eslint-disable-next-line no-cond-assign
192
- while (chunk = pendingChunks.shift()) {
193
- destination.write(chunk);
195
+ if (response.body == null) return true;
196
+
197
+ /** @type {COMPATIBLE_ENCODING} */
198
+ let parsedEncoding;
199
+ if (this.respondNotAcceptable) {
200
+ // Parse now to catch the error;
201
+ try {
202
+ parsedEncoding = ContentEncoderMiddleware.chooseEncoding(response.request.headers);
203
+ } catch (error) {
204
+ if (error?.message === 'NOT_ACCEPTABLE') {
205
+ // Strip content
206
+ response.body = null;
207
+ response.status = 206;
208
+ return false;
194
209
  }
195
- destination.end();
196
- });
210
+ // Unknown error
211
+ throw error;
212
+ }
213
+ }
197
214
 
198
- return gzipStream;
199
- };
215
+ if (!Buffer.isBuffer(response.body)) return true;
200
216
 
201
- // Don't do any work until first chunk is received (if at all).
202
- // This allows middleware to set `Content-Encoding` manually,
203
- // prevents allocation memory for a gzip stream unnecessarily, and
204
- // prevents polluting 204 responses.
205
-
206
- const onEnd = () => destination.end();
207
- newStream.once('data', (chunk) => {
208
- // Will be handled by .pipe() or .end() call
209
- newStream.off('end', onEnd);
210
-
211
- /** @type {string} */
212
- let encoding = (res.headers['content-encoding']);
213
- if (encoding == null) {
214
- // Only continue if unset. Blank is still considered set.
215
- // This allows forced encoding (eg: use gzip regardless of size; always identity)
216
- if (inputLength > (this.minimumSize ?? DEFAULT_MINIMUM_SIZE) || transformCount > 1) {
217
- // If we're getting data in chunks, assume larger than minimum
218
- encoding = getContentEncoding().toLowerCase?.();
219
- } else {
220
- encoding = 'identity';
217
+ /** @return {string} */
218
+ const getContentEncoding = () => {
219
+ if (!parsedEncoding) {
220
+ try {
221
+ parsedEncoding = ContentEncoderMiddleware.chooseEncoding(response.request.headers);
222
+ } catch (error) {
223
+ if (error?.message !== 'NOT_ACCEPTABLE') {
224
+ throw error;
225
+ }
221
226
  }
222
227
  }
228
+ if (!parsedEncoding || parsedEncoding === '*') {
229
+ parsedEncoding = this.preferredEncoding || 'identity';
230
+ }
231
+ response.headers['content-encoding'] = parsedEncoding;
232
+ return parsedEncoding;
233
+ };
223
234
 
224
- let next;
235
+ let encoding = /** @type {string} */ (response.headers['content-encoding']);
236
+ // Only continue if unset (missing header). Blank is still considered set.
237
+ // This allows forced encoding (eg: use gzip regardless of size; always identity)
238
+
239
+ // Unset means server preference
240
+ if (encoding == null) {
241
+ encoding = (response.body.length < this.minimumSize) ? 'identity' : getContentEncoding().toLowerCase?.();
242
+ }
243
+
244
+ const options = { chunkSize: this.chunkSize };
245
+
246
+ if (response.body.length < this.asyncThreshold) {
225
247
  switch (encoding) {
226
248
  case 'br':
249
+ response.body = brotliCompressSync(response.body, options);
250
+ break;
227
251
  case 'gzip':
252
+ response.body = gzipSync(response.body, options);
253
+ break;
228
254
  case 'deflate':
229
- next = buildGzipStream(encoding);
255
+ response.body = deflateSync(response.body, options);
230
256
  break;
231
257
  default:
232
- next = destination;
233
258
  }
234
- next.write(chunk);
235
- newStream.pipe(next);
236
- });
259
+ return true;
260
+ }
237
261
 
238
- // In case no data is passed
239
- newStream.on('end', onEnd);
262
+ let promise;
263
+ switch (encoding) {
264
+ case 'br':
265
+ promise = ContentEncoderMiddleware.BrotliCompressAsync(response.body, options);
266
+ break;
267
+ case 'gzip':
268
+ promise = ContentEncoderMiddleware.GzipAsync(response.body, options);
269
+ break;
270
+ case 'deflate':
271
+ promise = ContentEncoderMiddleware.DeflateAsync(response.body, options);
272
+ break;
273
+ default:
274
+ return true;
275
+ }
276
+ return promise.then((result) => {
277
+ response.body = result;
278
+ return true;
279
+ });
280
+ }
240
281
 
241
- return 'continue';
282
+ /**
283
+ * Implements `Accept-Encoding`
284
+ * https://tools.ietf.org/html/rfc7231#section-5.3.4
285
+ * @type {MiddlewareFunction}
286
+ */
287
+ execute({ response }) {
288
+ response.finalizers.push(this.finalizeResponse);
242
289
  }
243
290
  }
@@ -1,67 +1,67 @@
1
- import { Transform } from 'stream';
2
-
3
- /** @typedef {import('../types').IMiddleware} IMiddleware */
1
+ /** @typedef {import('../lib/HttpResponse.js').default} HttpResponse */
4
2
  /** @typedef {import('../types').MiddlewareFunction} MiddlewareFunction */
5
- /** @typedef {import('../types').MiddlewareFunctionParams} MiddlewareFunctionParams */
6
- /** @typedef {import('../types').MiddlewareFunctionResult} MiddlewareFunctionResult */
3
+ /** @typedef {import('../types').ResponseFinalizer} ResponseFinalizer */
4
+
5
+ import { Transform } from 'node:stream';
7
6
 
8
7
  /**
9
8
  * @typedef {Object} ContentLengthMiddlewareOptions
10
- * @prop {boolean} [delayCycle=true]
11
- * Delays writing to stream by one I/O cycle.
9
+ * @prop {number} [delayCycles=2]
10
+ * Delays writing to stream by setTimeout cycles when piping.
12
11
  * If `.end()` is called on the same event loop as write, then the
13
12
  * content length can be still calculated despite receiving data in chunks.
14
- * Compared to no delay, chunks are held in memory for two event loops instead
15
- * of just one.
16
13
  * @prop {boolean} [overrideHeader=false]
17
14
  * Always replace `Content-Length` header
18
15
  */
19
16
 
20
- /** @implements {IMiddleware} */
21
17
  export default class ContentLengthMiddleware {
22
18
  /** @param {ContentLengthMiddlewareOptions} [options] */
23
19
  constructor(options = {}) {
24
- this.delayCycle = options.delayCycle !== false;
20
+ this.delayCycles = options.delayCycles ?? 2;
25
21
  this.overrideHeader = options.overrideHeader !== true;
22
+ this.finalizeResponse = this.finalizeResponse.bind(this);
26
23
  }
27
24
 
28
25
  /**
29
- * @param {MiddlewareFunctionParams} params
30
- * @return {MiddlewareFunctionResult}
26
+ * @param {HttpResponse} response
27
+ * @return {void}
31
28
  */
32
- execute({ req, res }) {
33
- if (req.method === 'HEAD') {
34
- return 'continue';
35
- }
36
-
29
+ addTransformStream(response) {
30
+ if (response.headersSent) return;
31
+ let { delayCycles } = this;
32
+ const { overrideHeader } = this;
33
+ if (response.headers['content-length'] && !overrideHeader) return;
37
34
  let length = 0;
38
35
  /** @type {Buffer[]} */
39
36
  const pendingChunks = [];
40
- let delayPending = false;
41
- const { delayCycle, overrideHeader } = this;
42
- const newWritable = new Transform({
37
+ response.pipes.push(new Transform({
38
+ objectMode: true,
43
39
  transform(chunk, encoding, callback) {
44
- console.log('ContentLengthMiddleware:', 'tranform');
45
40
  length += chunk.length;
46
- if (delayCycle === false) {
41
+ if (!delayCycles) {
47
42
  callback(null, chunk);
48
43
  return;
49
44
  }
50
45
 
51
46
  pendingChunks.push(chunk);
52
- if (!delayPending) {
53
- delayPending = true;
54
- process.nextTick(() => setImmediate(() => {
55
- delayPending = false;
56
- pendingChunks.splice(0, pendingChunks.length)
57
- .forEach((buffer) => this.push(buffer));
58
- }));
47
+ // eslint-disable-next-line no-underscore-dangle, unicorn/consistent-function-scoping
48
+ let fn = () => this._flush(() => { /** noop */ });
49
+ for (let i = 0; i < delayCycles; i++) {
50
+ const prev = fn;
51
+ fn = () => setTimeout(prev);
59
52
  }
53
+ fn();
60
54
  callback();
61
55
  },
62
56
  flush(callback) {
63
- console.log('ContentLengthMiddleware:', 'flush');
64
- if (!res.headersSent) {
57
+ for (const buffer of pendingChunks.splice(0, pendingChunks.length)) {
58
+ this.push(buffer);
59
+ }
60
+ delayCycles = 0;
61
+ callback?.();
62
+ },
63
+ final(callback) {
64
+ if (!response.headersSent) {
65
65
  /**
66
66
  * Any response message which "MUST NOT" include a message-body
67
67
  * (such as the 1xx, 204, and 304 responses and any response to a HEAD request)
@@ -69,23 +69,46 @@ export default class ContentLengthMiddleware {
69
69
  * regardless of the entity-header fields present in the message.
70
70
  * https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.4
71
71
  */
72
- if ((res.status >= 100 && res.status < 200) || res.status === 204 || res.status === 304) {
72
+ if ((response.status >= 100 && response.status < 200) || response.status === 204 || response.status === 304) {
73
73
  if (overrideHeader) {
74
- delete res.headers['content-length'];
74
+ delete response.headers['content-length'];
75
75
  }
76
- } else if (overrideHeader === true || res.headers['content-length'] == null) {
77
- res.headers['content-length'] = length;
76
+ } else if (overrideHeader === true || response.headers['content-length'] == null) {
77
+ response.headers['content-length'] = length;
78
78
  }
79
79
  }
80
- pendingChunks.splice(0, pendingChunks.length)
81
- .forEach((buffer) => this.push(buffer));
82
- callback();
80
+ callback?.();
83
81
  },
84
- });
82
+ }));
83
+ }
85
84
 
86
- const destination = res.replaceStream(newWritable);
87
- newWritable.pipe(destination);
85
+ /** @type {ResponseFinalizer} */
86
+ finalizeResponse(response) {
87
+ if (response.headersSent) return;
88
+ if (response.isStreaming) {
89
+ this.addTransformStream(response);
90
+ return;
91
+ }
92
+ if (!Buffer.isBuffer(response.body)) return;
93
+ if (!response.body.byteLength) return;
94
+ /**
95
+ * Any response message which "MUST NOT" include a message-body
96
+ * (such as the 1xx, 204, and 304 responses and any response to a HEAD request)
97
+ * is always terminated by the first empty line after the header fields,
98
+ * regardless of the entity-header fields present in the message.
99
+ * https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.4
100
+ */
101
+ if (response.status === 204 || response.status === 304 || (response.status >= 100 && response.status < 200)) {
102
+ if (this.overrideHeader) {
103
+ delete response.headers['content-length'];
104
+ }
105
+ } else if (this.overrideHeader === true || response.headers['content-length'] == null) {
106
+ response.headers['content-length'] = response.body.byteLength;
107
+ }
108
+ }
88
109
 
89
- return 'continue';
110
+ /** @type {MiddlewareFunction} */
111
+ execute({ response }) {
112
+ response.finalizers.push(this.finalizeResponse);
90
113
  }
91
114
  }
@@ -1,4 +1,4 @@
1
- import { Transform } from 'stream';
1
+ import { Transform } from 'node:stream';
2
2
 
3
3
  /** @typedef {import('../types').IMiddleware} IMiddleware */
4
4
  /** @typedef {import('../types').MiddlewareFunction} MiddlewareFunction */
@@ -42,13 +42,13 @@ export default class ContentWriterMiddleware {
42
42
  case 'ucs2':
43
43
  case 'utf16le':
44
44
  return 'utf16le';
45
- default:
46
- case 'utf-8':
47
- case 'utf8':
48
- return 'utf-8';
49
45
  case 'base64':
50
46
  case 'hex':
51
47
  return /** @type {BufferEncoding} */ (charset);
48
+ case 'utf-8':
49
+ case 'utf8':
50
+ default:
51
+ return 'utf-8';
52
52
  }
53
53
  }
54
54
 
@@ -116,9 +116,7 @@ export default class ContentWriterMiddleware {
116
116
  const newWritable = new Transform({
117
117
  writableObjectMode: true,
118
118
  transform: (chunk, e, callback) => {
119
- console.log('ContentWriterMiddleware:', 'transform', chunk.length);
120
119
  if (Buffer.isBuffer(chunk)) {
121
- console.log('Calling back with buffer');
122
120
  callback(null, chunk);
123
121
  return;
124
122
  }
@@ -134,9 +132,7 @@ export default class ContentWriterMiddleware {
134
132
  res.locals[cacheName] = chunk;
135
133
  }
136
134
  }
137
- console.log('Calling back with string');
138
135
  const callbackData = Buffer.from(chunk, encoding);
139
- console.log(callbackData);
140
136
  callback(null, callbackData);
141
137
  return;
142
138
  }
@@ -150,12 +146,10 @@ export default class ContentWriterMiddleware {
150
146
  if (this.setJSON && !hasSetJSON && !res.headersSent) {
151
147
  setJSONMediaType();
152
148
  }
153
- console.log('calling back with JSON');
154
149
  callback(null, Buffer.from(JSON.stringify(chunk), encoding));
155
150
  return;
156
151
  }
157
152
 
158
- console.log('calling back with string?');
159
153
  callback(null, chunk);
160
154
  },
161
155
  });