webhoster 0.1.1 → 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 (85) 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 -41
  33. package/middleware/ContentWriterMiddleware.js +5 -5
  34. package/middleware/HashMiddleware.js +68 -40
  35. package/middleware/HeadMethodMiddleware.js +24 -21
  36. package/middleware/MethodMiddleware.js +29 -36
  37. package/middleware/PathMiddleware.js +49 -66
  38. package/middleware/ReadFormData.js +99 -0
  39. package/middleware/SendJsonMiddleware.js +131 -0
  40. package/middleware/SendStringMiddleware.js +87 -0
  41. package/package.json +38 -29
  42. package/polyfill/FormData.js +164 -0
  43. package/rollup.config.js +0 -1
  44. package/scripts/test-all-sync.sh +6 -0
  45. package/scripts/test-all.sh +7 -0
  46. package/templates/starter.js +53 -0
  47. package/test/fixtures/stream.js +68 -0
  48. package/test/helpers/HttpListener/construct.js +18 -0
  49. package/test/helpers/HttpListener/customOptions.js +22 -0
  50. package/test/helpers/HttpListener/doubleCreate.js +40 -0
  51. package/test/helpers/HttpListener/events.js +77 -0
  52. package/test/helpers/HttpListener/http.js +31 -0
  53. package/test/helpers/HttpListener/http2.js +41 -0
  54. package/test/helpers/HttpListener/https.js +38 -0
  55. package/test/helpers/HttpListener/startAll.js +31 -0
  56. package/test/helpers/HttpListener/stopNotStarted.js +23 -0
  57. package/test/lib/HttpHandler/class.js +8 -0
  58. package/test/lib/HttpHandler/handleRequest.js +11 -0
  59. package/test/lib/HttpHandler/middleware.js +941 -0
  60. package/test/lib/HttpHandler/parse.js +41 -0
  61. package/test/lib/HttpRequest/class.js +8 -0
  62. package/test/lib/HttpRequest/downstream.js +171 -0
  63. package/test/lib/HttpRequest/properties.js +101 -0
  64. package/test/lib/HttpRequest/read.js +518 -0
  65. package/test/lib/HttpResponse/class.js +8 -0
  66. package/test/lib/HttpResponse/properties.js +59 -0
  67. package/test/lib/HttpResponse/send.js +275 -0
  68. package/test/lib/HttpTransaction/class.js +8 -0
  69. package/test/lib/HttpTransaction/ping.js +50 -0
  70. package/test/lib/HttpTransaction/push.js +89 -0
  71. package/test/middleware/SendJsonMiddleware.js +222 -0
  72. package/test/sanity.js +10 -0
  73. package/test/templates/starter.js +93 -0
  74. package/tsconfig.json +12 -0
  75. package/types/index.js +61 -34
  76. package/types/typings.d.ts +8 -9
  77. package/utils/AsyncObject.js +6 -3
  78. package/utils/CaseInsensitiveObject.js +2 -3
  79. package/utils/function.js +1 -7
  80. package/utils/headers.js +42 -0
  81. package/utils/qualityValues.js +1 -1
  82. package/utils/stream.js +4 -20
  83. package/index.cjs +0 -3190
  84. package/test/constants.js +0 -4
  85. /package/{test → .test}/cookietester.js +0 -0
@@ -1,46 +1,34 @@
1
- import { PassThrough } from 'stream';
1
+ import { posix } from 'node:path';
2
+ import { promisify } from 'node:util';
2
3
 
3
- import AsyncObject from '../utils/AsyncObject.js';
4
- import { noop } from '../utils/function.js';
4
+ import { isWritable } from '../utils/stream.js';
5
5
 
6
6
  import HttpRequest from './HttpRequest.js';
7
7
  import HttpResponse from './HttpResponse.js';
8
+ import HttpTransaction from './HttpTransaction.js';
8
9
 
9
10
  /** @typedef {import('../types').Middleware} Middleware */
10
11
  /** @typedef {import('../types').MiddlewareErrorHandler} MiddlewareErrorHandler */
11
- /** @typedef {import('../types').MiddlewareFunctionParams} MiddlewareFunctionParams */
12
- /** @typedef {import('../types').MiddlewareFunctionResult} MiddlewareFunctionResult */
13
- /** @typedef {import('../types').MiddlewareFunctionResultType} MiddlewareFunctionResultType */
12
+ /** @typedef {import('../types').MiddlewareFlowInstruction} MiddlewareFlowInstruction */
14
13
  /** @typedef {import('../types').RequestMethod} RequestMethod */
15
- /** @typedef {import('../types/index.js').HandlerState} HandlerState */
16
14
 
17
15
  /** @type {HttpHandler} */
18
16
  let defaultInstance = null;
19
17
 
20
- /**
21
- * @param {Middleware} middleware
22
- * @return {boolean}
23
- */
24
- function isErrorHandler(middleware) {
25
- return !!middleware && typeof middleware === 'object' && 'onError' in middleware && middleware.onError != null;
26
- }
27
-
28
18
  /**
29
19
  * @typedef {Object} HttpHandlerOptions
30
- * @prop {Middleware[]} [preprocessors]
31
- * @prop {Set<Middleware>} [middleware]
20
+ * @prop {Middleware[]} [middleware]
32
21
  * @prop {MiddlewareErrorHandler[]} [errorHandlers]
33
22
  */
34
23
  export default class HttpHandler {
35
- /** @param {HttpHandlerOptions} options */
36
- constructor(options = {}) {
37
- this.preprocessors = options.preprocessors || [];
38
- this.middleware = options.middleware || new Set();
39
- this.errorHandlers = options.errorHandlers || [];
40
- this.handleRequest = this.handleRequest.bind(this);
41
- this.handleHttp1Request = this.handleHttp1Request.bind(this);
42
- this.handleHttp2Stream = this.handleHttp2Stream.bind(this);
43
- }
24
+ /** @type {true} */
25
+ static CONTINUE = true;
26
+
27
+ /** @type {false} */
28
+ static BREAK = false;
29
+
30
+ /** @type {0} */
31
+ static END = 0;
44
32
 
45
33
  /** @return {HttpHandler} */
46
34
  static get defaultInstance() {
@@ -51,329 +39,511 @@ export default class HttpHandler {
51
39
  }
52
40
 
53
41
  /**
54
- * @param {Object} params
55
- * @param {HttpRequest} params.req
56
- * @param {HttpResponse} params.res
57
- * @return {Promise<HttpResponse>}
42
+ * @param {any} result
43
+ * @return {?MiddlewareFlowInstruction}
58
44
  */
59
- handleRequest({ req, res }) {
60
- /** @type {HandlerState} */
61
- const state = { treeIndex: [] };
62
- let isInErrorState = false;
63
- /** @type {any} */
64
- let caughtError = null;
65
- // eslint-disable-next-line @typescript-eslint/no-this-alias
66
- const context = this;
67
- /**
68
- * @param {Middleware} middleware
69
- * @return {Promise<MiddlewareFunctionResult>}
70
- */
71
- function handleMiddleware(middleware) {
72
- const isMiddlewareAnErrorHandler = isErrorHandler(middleware);
73
- if (isInErrorState) {
74
- if (isMiddlewareAnErrorHandler) {
75
- isInErrorState = false;
76
- /** @type {MiddlewareFunctionResult} */
77
- let returnValue;
78
- try {
79
- returnValue = /** @type {MiddlewareErrorHandler} */ (middleware)
80
- .onError({
81
- req, res, state, err: caughtError,
82
- });
83
- } catch (err) {
84
- isInErrorState = true;
85
- caughtError = err;
86
- returnValue = 'continue';
87
- }
88
- return Promise.resolve().then(() => returnValue);
89
- }
90
- if (middleware !== context.errorHandlers) {
91
- // Consume and advance
92
- return Promise.resolve();
93
- }
94
- } else if (isMiddlewareAnErrorHandler) {
95
- // Don't run error handler if not in error state.
96
- return Promise.resolve();
97
- }
45
+ static ParseResultSync(result) {
46
+ // Fast return
47
+ switch (result) {
48
+ case true:
49
+ case null:
50
+ case undefined:
51
+ case HttpHandler.CONTINUE:
52
+ return HttpHandler.CONTINUE;
53
+ case false:
54
+ case HttpHandler.BREAK:
55
+ return HttpHandler.BREAK;
56
+ case 0:
57
+ return HttpHandler.END;
58
+ default:
59
+ }
60
+ return null;
61
+ }
98
62
 
99
- if (typeof middleware === 'boolean') {
100
- if (middleware === false) {
101
- return Promise.resolve('break');
102
- }
103
- return Promise.resolve();
104
- }
63
+ /**
64
+ * @param {string} scheme
65
+ * @param {string} authority
66
+ * @param {string} path
67
+ */
68
+ static parseURL(scheme, authority, path) {
69
+ let query = '';
70
+ let fragment = '';
105
71
 
106
- switch (middleware) {
107
- case 'break':
108
- return Promise.resolve('break');
109
- case 'end':
110
- return Promise.resolve('end');
111
- case 'continue':
112
- return Promise.resolve();
113
- default:
114
- }
72
+ const authoritySplit = authority.split(':');
73
+ let pathname = '';
74
+ let search = '';
75
+ let hash = '';
76
+ const queryIndex = path.indexOf('?');
77
+ const fragmentIndex = path.indexOf('#');
78
+ const hasQuery = queryIndex !== -1;
79
+ const hasFragment = fragmentIndex !== -1;
115
80
 
116
- if (middleware === null || typeof middleware === 'string' || typeof middleware === 'undefined') {
117
- return Promise.resolve();
81
+ // URL variables
82
+ pathname = path;
83
+ if (hasQuery && hasFragment) {
84
+ // Both ? and # present
85
+ if (queryIndex < fragmentIndex) {
86
+ pathname = path.slice(0, queryIndex);
87
+ search = path.slice(queryIndex, fragmentIndex);
88
+ hash = path.slice(fragmentIndex);
89
+ query = search.slice(1);
90
+ fragment = hash.slice(1);
91
+ } else {
92
+ // # comes before ?, treat as no query
93
+ pathname = path.slice(0, fragmentIndex);
94
+ hash = path.slice(fragmentIndex);
95
+ fragment = hash.slice(1);
118
96
  }
97
+ } else if (hasQuery) {
98
+ pathname = path.slice(0, queryIndex);
99
+ search = path.slice(queryIndex);
100
+ query = search.slice(1);
101
+ } else if (hasFragment) {
102
+ pathname = path.slice(0, fragmentIndex);
103
+ hash = path.slice(fragmentIndex);
104
+ fragment = hash.slice(1);
105
+ } else {
106
+ pathname = path;
107
+ }
108
+ // Remove dot segments
109
+ pathname = posix.normalize(pathname);
110
+
111
+ return {
112
+ href: `${scheme}://${authority}${pathname}${search}${hash}`,
113
+ origin: `${scheme}://${authority}`,
114
+ protocol: `${scheme}:`,
115
+ username: '',
116
+ password: '',
117
+ host: authority,
118
+ hostname: authoritySplit[0],
119
+ port: authoritySplit[1] ?? '',
120
+ pathname,
121
+ search,
122
+ hash,
123
+ query,
124
+ fragment,
125
+ url: `${scheme}://${authority}${path}`,
126
+ };
127
+ }
119
128
 
120
- if (middleware instanceof Map) {
121
- return handleMiddleware(middleware.values());
129
+ /** @param {HttpHandlerOptions} options */
130
+ constructor(options = {}) {
131
+ this.middleware = options.middleware || [];
132
+ this.errorHandlers = options.errorHandlers || [];
133
+ this.handleTransaction = this.handleTransaction.bind(this);
134
+ this.handleHttp1Request = this.handleHttp1Request.bind(this);
135
+ this.handleHttp2Stream = this.handleHttp2Stream.bind(this);
136
+ }
137
+
138
+ /**
139
+ * @param {HttpTransaction} transaction
140
+ * @param {Middleware} middleware
141
+ * @return {Promise<MiddlewareFlowInstruction>}
142
+ */
143
+ async processMiddleware(transaction, middleware) {
144
+ if (middleware == null) return HttpHandler.CONTINUE;
145
+
146
+ // Check if error handler
147
+ const isErrorHandler = (typeof middleware === 'object'
148
+ && typeof middleware.onError === 'function');
149
+
150
+ let value = middleware;
151
+ if (transaction.error) {
152
+ if (isErrorHandler) {
153
+ value = middleware.onError;
154
+ } else if (!transaction.isErrorHandlerState()) {
155
+ if (middleware !== this.errorHandlers) return HttpHandler.CONTINUE;
156
+ transaction.setErrorHandlerState();
122
157
  }
158
+ } else if (isErrorHandler) {
159
+ return HttpHandler.CONTINUE;
160
+ }
161
+
162
+ let syncResult = HttpHandler.ParseResultSync(value);
163
+ if (syncResult != null) {
164
+ return syncResult;
165
+ }
123
166
 
124
- if (Symbol.iterator in middleware) {
125
- const iterator = (/** @type {Iterable<Middleware>} */ (middleware))[Symbol.iterator]();
167
+ /** @type {?MiddlewareFlowInstruction} */
168
+ let result;
169
+ switch (typeof value) {
170
+ case 'number':
171
+ transaction.response.status = value;
172
+ try {
173
+ return transaction.response.end();
174
+ } catch (error) {
175
+ transaction.error = error;
176
+ return HttpHandler.CONTINUE;
177
+ }
178
+ case 'function':
179
+ try {
180
+ result = value.constructor.name === 'AsyncFunction'
181
+ ? await value(transaction)
182
+ : value(transaction);
183
+ if (result == null) {
184
+ if (isErrorHandler) transaction.error = null;
185
+ return HttpHandler.CONTINUE;
186
+ }
126
187
 
127
- /**
128
- * @param {MiddlewareFunctionResult} [chainResult]
129
- * @return {Promise<MiddlewareFunctionResult>}
130
- */
131
- const chainLoop = (chainResult) => {
132
- if (chainResult === 'end') {
133
- return Promise.resolve('end');
188
+ // Sync operation returned Promise
189
+ if (typeof result === 'object' && typeof result.then === 'function') {
190
+ if (isErrorHandler) transaction.error = null;
191
+ result = await result;
134
192
  }
135
- if (chainResult === false || chainResult === 'break') {
136
- return Promise.resolve();
193
+ syncResult = HttpHandler.ParseResultSync(result);
194
+ if (syncResult != null) {
195
+ if (isErrorHandler) transaction.error = null;
196
+ return syncResult;
137
197
  }
138
- if (chainResult != null && chainResult !== true) {
139
- return handleMiddleware(chainResult).then(chainLoop);
198
+
199
+ // Slip in support for functions that return an Array
200
+ if (Array.isArray(result)) {
201
+ result = transaction.response.end(result);
202
+ if (isErrorHandler) transaction.error = null;
203
+ return result;
140
204
  }
141
- const chainIteration = iterator.next();
142
- if (chainIteration.done) return Promise.resolve();
143
- state.treeIndex[state.treeIndex.length - 1] += 1;
144
- /** @type {Middleware} */
145
- const innerMiddleware = chainIteration.value;
146
- return handleMiddleware(innerMiddleware).then(chainLoop);
147
- };
148
205
 
149
- // Start looping
150
- state.treeIndex.push(-1);
151
- return chainLoop().then((result) => {
152
- state.treeIndex.pop();
206
+ if (isErrorHandler) transaction.error = null;
207
+ result = await this.processMiddleware(transaction, result);
153
208
  return result;
154
- });
155
- }
209
+ } catch (error) {
210
+ // console.warn('Caught runtime error', err.message, err.stack);
211
+ transaction.error = error;
212
+ return HttpHandler.CONTINUE;
213
+ }
214
+ case 'object':
215
+ if (Array.isArray(value)) {
216
+ const { treeIndex } = transaction.state;
217
+ treeIndex.push(-1);
218
+ const { length } = value;
219
+ for (let index = 0; index < length; index++) {
220
+ const innerMiddleware = value[index];
221
+ treeIndex[treeIndex.length - 1] += 1;
222
+ if (innerMiddleware == null) continue;
223
+ result = HttpHandler.ParseResultSync(innerMiddleware);
224
+ if (result == null) {
225
+ // eslint-disable-next-line no-await-in-loop
226
+ result = await this.processMiddleware(transaction, innerMiddleware);
227
+ }
156
228
 
157
- if (middleware && typeof middleware === 'object') {
158
- if ('execute' in middleware) {
159
- if (typeof middleware.execute === 'function') {
160
- return handleMiddleware(middleware.execute.bind(middleware));
229
+ if (result === HttpHandler.END) {
230
+ break;
231
+ }
232
+ if (result === HttpHandler.BREAK) {
233
+ // Break from branch and continue in parent
234
+ result = HttpHandler.CONTINUE;
235
+ break;
236
+ }
237
+ // Continue in branch
161
238
  }
162
- return handleMiddleware(middleware.execute);
239
+ treeIndex.pop();
240
+ return result;
163
241
  }
164
- return handleMiddleware(Object.values(middleware));
165
- }
166
242
 
167
- if (typeof middleware !== 'function') {
168
- console.warn('Unknown middleware', middleware);
169
- return Promise.resolve();
170
- }
171
- return Promise.resolve().then(() => middleware({ req, res, state })).catch((err) => {
172
- isInErrorState = true;
173
- caughtError = err;
174
- return /** @type {'continue'} */ ('continue');
175
- });
243
+ if ('execute' in value && typeof value.execute === 'function') {
244
+ return await this.processMiddleware(transaction, value.execute.bind(value));
245
+ }
246
+ // Static caller
247
+ if ('Execute' in value && typeof value.Execute === 'function') {
248
+ return await this.processMiddleware(transaction, value.Execute);
249
+ }
250
+ if ('then' in value && typeof value.then === 'function') {
251
+ return await this.processMiddleware(transaction, await value);
252
+ }
253
+ // Fallthrough for Objects
254
+ case 'string':
255
+ try {
256
+ transaction.response.status ??= value ? 200 : 204;
257
+ return transaction.response.end(value);
258
+ } catch (error) {
259
+ transaction.error = error;
260
+ return HttpHandler.CONTINUE;
261
+ }
262
+ default:
263
+ console.warn('Unknown middleware', value);
264
+ return HttpHandler.CONTINUE;
176
265
  }
266
+ }
177
267
 
268
+ /**
269
+ * @param {HttpTransaction} transaction
270
+ * @return {Promise<HttpTransaction>}
271
+ */
272
+ async handleTransaction(transaction) {
178
273
  const allMiddleware = [
179
- this.preprocessors,
180
274
  this.middleware,
181
275
  this.errorHandlers,
182
276
  ];
183
- return handleMiddleware(allMiddleware).then(() => {
184
- if (isInErrorState) {
185
- isInErrorState = false;
186
- throw caughtError;
277
+
278
+ const finalFinalResponse = await this.processMiddleware(transaction, allMiddleware);
279
+ if (finalFinalResponse !== HttpHandler.END) {
280
+ // console.warn(transaction.request.url, "Middleware resolution did not complete with 'end'");
281
+ } else if (!transaction.response.wasEndCalled()) {
282
+ transaction.response.end();
283
+ }
284
+ if (transaction.error) {
285
+ console.warn('Webhoster did not find error handler. Crash prevented.', transaction.request.path, transaction.error);
286
+ if (!transaction.response.wasEndCalled()) {
287
+ // Use generic error response and don't expose error
288
+ transaction.response.status = 500;
289
+ transaction.response.headers['content-type'] = 'text/plain';
290
+ transaction.response.end('Internal Server Error');
187
291
  }
188
- return res;
189
- });
292
+ }
293
+ return transaction;
190
294
  }
191
295
 
192
296
  /**
193
297
  * @param {import('http').IncomingMessage} incomingMessage
194
298
  * @param {import('http').ServerResponse} serverResponse
195
- * @return {Promise<HttpResponse>}
299
+ * @return {Promise<HttpTransaction>}
196
300
  */
197
- handleHttp1Request(incomingMessage, serverResponse) {
301
+ async handleHttp1Request(incomingMessage, serverResponse) {
302
+ /** @throws {Error} */
303
+ function onMalformed() {
304
+ const error = new Error('PROTOCOL_ERROR');
305
+ incomingMessage.destroy(error);
306
+ serverResponse.destroy(error);
307
+ throw error;
308
+ }
309
+
310
+ const {
311
+ method, headers, socket, url: path,
312
+ } = incomingMessage;
313
+
314
+ if (!method) onMalformed();
198
315
  // @ts-ignore If TLSSocketLike
199
- const protocol = incomingMessage.socket.encrypted ? 'https:' : 'http:';
200
-
201
- let url;
202
- try {
203
- url = new URL(`${protocol}//${incomingMessage.headers.host}${incomingMessage.url}`);
204
- } catch (error) {
205
- serverResponse.writeHead(400);
206
- serverResponse.end();
207
- return Promise.reject(error);
316
+ const scheme = socket.encrypted ? 'https' : 'http';
317
+ const authority = headers.host;
318
+ if (!authority) onMalformed();
319
+ if (authority.includes('@')) onMalformed();
320
+
321
+ let urlOptions;
322
+ if (method === 'CONNECT') {
323
+ if (scheme || path) onMalformed();
324
+ } else {
325
+ if (!scheme || !path) onMalformed();
326
+ if (path === '*') {
327
+ // asterisk-form
328
+ if (method !== 'OPTIONS') onMalformed();
329
+ } else {
330
+ urlOptions = HttpHandler.parseURL(scheme, authority, path);
331
+ }
208
332
  }
209
333
 
210
- const req = new HttpRequest({
211
- headers: incomingMessage.headers,
212
- method: /** @type {RequestMethod} */ (incomingMessage.method?.toUpperCase()),
334
+ const request = new HttpRequest({
335
+ headers,
336
+ method,
213
337
  stream: incomingMessage,
214
- socket: incomingMessage.socket,
215
- url,
338
+
339
+ scheme,
340
+ authority,
341
+ path,
342
+
343
+ ...urlOptions,
216
344
  });
217
345
 
218
- const res = new HttpResponse({
346
+ const response = new HttpResponse({
219
347
  stream: serverResponse,
220
- socket: serverResponse.socket,
348
+ headers: {},
349
+ request,
221
350
  onHeadersSent() {
222
351
  return serverResponse.headersSent;
223
352
  },
224
- onSendHeaders(flush) {
225
- if (res.status == null) return Promise.reject(new Error('NO_STATUS'));
226
- serverResponse.writeHead(res.status, this.headers);
227
- if (flush) {
353
+ onSendHeaders(flush, end) {
354
+ if (response.status == null) {
355
+ throw new Error('NO_STATUS');
356
+ }
357
+ if (!isWritable(serverResponse)) return false;
358
+ serverResponse.writeHead(response.status, response.headers);
359
+ if (end) {
360
+ serverResponse.end();
361
+ } else if (flush) {
228
362
  serverResponse.flushHeaders();
229
363
  }
230
- return Promise.resolve();
364
+ return true;
231
365
  },
232
366
  });
233
367
 
234
- return this.handleRequest({ req, res }).then(() => {
235
- if (!res.stream.writableEnded) {
236
- console.warn('Response stream was not ended.');
237
- res.stream.end();
238
- }
239
- return res;
368
+ const transaction = new HttpTransaction({
369
+ httpVersion: '2.0',
370
+ request,
371
+ response,
372
+ socket,
373
+ canPing: false,
374
+ canPushPath: false,
240
375
  });
376
+
377
+ await this.handleTransaction(transaction);
378
+
379
+ if (isWritable(serverResponse)) {
380
+ setTimeout(() => {
381
+ if (isWritable(serverResponse)) {
382
+ console.warn('Respond stream end lagging more than 60s. Did you forget to call `.end()`?', request.url);
383
+ }
384
+ }, 60_000);
385
+ }
386
+ return transaction;
241
387
  }
242
388
 
243
389
  /**
244
390
  * @param {import('http2').ServerHttp2Stream} stream
245
391
  * @param {import('http2').IncomingHttpHeaders} headers
246
- * @param {Partial<import('./HttpResponse.js').HttpResponseOptions>} [responseOptions]
247
- * @return {Promise<HttpResponse>}
392
+ * @param {Partial<import('./HttpTransaction.js').HttpTransactionOptions<unknown>>} [transactionOptions]
393
+ * @return {Promise<HttpTransaction>}
248
394
  */
249
- handleHttp2Stream(stream, headers, responseOptions = {}) {
250
- let url;
251
- try {
252
- url = new URL([
253
- headers[':scheme'] ?? '',
254
- '://',
255
- headers[':authority'] ?? '',
256
- headers[':path'] ?? '',
257
- ].join(''));
258
- } catch (error) {
259
- stream.respond({ ':status': 400 }, { endStream: true });
260
- return Promise.reject(error);
395
+ async handleHttp2Stream(stream, headers, transactionOptions = {}) {
396
+ /** @throws {Error} */
397
+ function onMalformed() {
398
+ const error = new Error('PROTOCOL_ERROR');
399
+ stream.destroy(error);
400
+ throw error;
401
+ }
402
+
403
+ const {
404
+ ':method': method,
405
+ ':scheme': scheme,
406
+ ':authority': authorityHeader,
407
+ host: hostHeader,
408
+ } = headers;
409
+
410
+ if (!method) onMalformed();
411
+ // HTTP/2 to HTTP/1 translation
412
+ const authority = /** @type {string} */ (authorityHeader || hostHeader);
413
+ if (!authority) onMalformed();
414
+ if (authority.includes('@')) onMalformed();
415
+
416
+ const path = headers[':path'];
417
+
418
+ let urlOptions;
419
+ if (method === 'CONNECT') {
420
+ if (scheme || path) onMalformed();
421
+ } else {
422
+ if (!scheme || !path) onMalformed();
423
+ if (path === '*') {
424
+ // asterisk-form
425
+ if (method !== 'OPTIONS') onMalformed();
426
+ } else {
427
+ urlOptions = HttpHandler.parseURL(scheme, authority, path);
428
+ }
261
429
  }
262
430
 
263
- /** @type {Set<AsyncObject<any>>} */
264
- const pendingPushSyncLocks = new Set();
265
- const req = new HttpRequest({
431
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
432
+ const context = this;
433
+
434
+ /** @type {Promise<any>[]} */
435
+ const pendingStreamLocks = [];
436
+
437
+ const request = new HttpRequest({
266
438
  headers,
267
- url,
268
- method: /** @type {RequestMethod} */ (headers[':method']),
439
+ method,
269
440
  stream,
270
- socket: stream.session.socket,
271
- canPing: true,
272
- onPing() {
273
- return new Promise((resolve, reject) => {
274
- stream.session.ping((err, duration) => {
275
- if (err) {
276
- reject(err);
277
- return;
278
- }
279
- resolve(duration);
280
- });
281
- });
282
- },
441
+
442
+ scheme,
443
+ authority,
444
+ path,
445
+
446
+ ...urlOptions,
283
447
  });
284
448
 
285
- // eslint-disable-next-line @typescript-eslint/no-this-alias
286
- const context = this;
287
- const res = new HttpResponse({
449
+ const response = new HttpResponse({
450
+ request,
288
451
  stream,
289
- socket: stream.session.socket,
290
- canPushPath: stream.pushAllowed,
291
452
  onHeadersSent() {
292
453
  return stream.headersSent;
293
454
  },
294
- onSendHeaders() {
295
- if (this.headers[':status'] == null) {
296
- if (res.status == null) {
455
+ onSendHeaders(flush, end) {
456
+ if (response.headers[':status'] == null) {
457
+ if (response.status == null) {
297
458
  throw new Error('NO_STATUS');
298
459
  }
299
- this.headers[':status'] = res.status;
460
+ response.headers[':status'] = response.status;
300
461
  }
301
- stream.respond(this.headers);
462
+ if (!isWritable(stream)) return false;
463
+ stream.respond(response.headers, { endStream: end });
464
+ return true;
302
465
  },
303
- onPushPath(path) {
304
- const syncLock = new AsyncObject();
305
- pendingPushSyncLocks.add(syncLock);
306
- syncLock.prepare();
307
- return new Promise((resolve, reject) => {
308
- if (!stream.pushAllowed) {
309
- reject(new Error('PUSH_NOT_ALLOWED'));
310
- return;
311
- }
312
- const newHeaders = {
313
- ':scheme': headers[':scheme'],
314
- ':authority': headers[':authority'],
315
- ':path': path,
316
- ':method': 'GET',
317
- };
318
- [
319
- 'accept',
320
- 'accept-encoding',
321
- 'accept-language',
322
- 'user-agent',
323
- 'cache-control',
324
- ].forEach((passedHeader) => {
325
- if (passedHeader in headers) {
466
+ });
467
+
468
+ const transaction = new HttpTransaction({
469
+ httpVersion: '2.0',
470
+ request,
471
+ response,
472
+ socket: stream.session.socket,
473
+ canPing: true,
474
+ onPing: promisify(stream.session.ping),
475
+ canPushPath: () => stream.pushAllowed,
476
+ onPushPath: async (pushPath) => {
477
+ if (!stream.pushAllowed) {
478
+ throw new Error('PUSH_NOT_ALLOWED');
479
+ }
480
+
481
+ const newHeaders = {
482
+ ':scheme': headers[':scheme'],
483
+ ':authority': headers[':authority'],
484
+ ':path': pushPath,
485
+ ':method': 'GET',
486
+ };
487
+ for (const passedHeader of [
488
+ 'accept',
489
+ 'accept-encoding',
490
+ 'accept-language',
491
+ 'user-agent',
492
+ 'cache-control',
493
+ ]) {
494
+ if (passedHeader in headers) {
326
495
  // @ts-ignore Coerce
327
- newHeaders[passedHeader] = headers[passedHeader];
328
- }
329
- });
330
- stream.pushStream(newHeaders, ((err, pushStream) => {
331
- if (err) {
332
- pendingPushSyncLocks.delete(syncLock);
333
- syncLock.set(null);
334
- reject(err);
335
- return;
336
- }
337
- context.handleHttp2Stream(pushStream, newHeaders, { canPushPath: false })
338
- .then(resolve).catch(reject).finally(() => {
339
- pendingPushSyncLocks.delete(syncLock);
340
- syncLock.set(null);
341
- });
342
- }));
343
- });
496
+ newHeaders[passedHeader] = headers[passedHeader];
497
+ }
498
+ }
499
+
500
+ // Build promise function
501
+ const promiseFunction = async () => {
502
+ try {
503
+ const pushStream = await new Promise((resolve, reject) => {
504
+ stream.pushStream(newHeaders, ((error, newStream) => (error ? reject(error) : resolve(newStream))));
505
+ });
506
+ pushStream.addListener('error', (error) => {
507
+ if (error?.code === 'ECONNRESET') {
508
+ console.warn('HTTP/2 stream connection reset.', headers[':path']);
509
+ } else {
510
+ console.error('HTTP/2 stream error', headers, error);
511
+ }
512
+ });
513
+ await context.handleHttp2Stream(pushStream, newHeaders, { canPushPath: false });
514
+ } catch (error) {
515
+ console.error('onPushFailed', error, error.stack);
516
+ throw error;
517
+ }
518
+ };
519
+
520
+ // Schedule microtask
521
+ const promiseExecution = promiseFunction();
522
+
523
+ // Add as stream lock
524
+ pendingStreamLocks.push(promiseExecution);
525
+
526
+ // Wait for promise to complete before returning
527
+ await promiseExecution;
344
528
  },
345
- ...responseOptions,
529
+ ...transactionOptions,
346
530
  });
347
531
 
348
- // Workaround for https://github.com/nodejs/node/issues/31309
349
- const STREAM_WAIT_MS = 0;
350
- /** @type {NodeJS.Timeout} */
351
- let pingTimeout = null;
352
- /** @return {void} */
353
- function sendPing() {
354
- if (stream.session) stream.session.ping(noop);
532
+ await this.handleTransaction(transaction);
533
+
534
+ if (pendingStreamLocks.length) {
535
+ // Wait for all child push streams to terminate before we return.
536
+ await Promise.allSettled(pendingStreamLocks);
355
537
  }
356
- const autoPingStream = new PassThrough({
357
- read(...args) {
358
- clearTimeout(pingTimeout);
359
- pingTimeout = setTimeout(sendPing, STREAM_WAIT_MS);
360
- // eslint-disable-next-line no-underscore-dangle
361
- return PassThrough.prototype._read.call(this, ...args);
362
- },
363
- });
364
- stream.pipe(autoPingStream);
365
- req.replaceStream(autoPingStream);
366
538
 
367
- stream.on('error', (err) => {
368
- console.error(err);
369
- console.error(headers[':path']);
370
- });
371
- return this.handleRequest({ req, res })
372
- .then(() => Promise.all([...pendingPushSyncLocks.values()]
373
- .map((syncLock) => syncLock.get().catch(noop))))
374
- .then(() => {
375
- res.stream.end();
376
- })
377
- .then(() => res);
539
+ if (isWritable(stream)) {
540
+ setTimeout(() => {
541
+ if (isWritable(stream)) {
542
+ console.warn('Respond stream end lagging more than 60s. Did you forget to call `.end()`?', request.url);
543
+ }
544
+ }, 60_000);
545
+ }
546
+
547
+ return transaction;
378
548
  }
379
549
  }