@jsenv/server 13.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,962 @@
1
+ import http from "node:http"
2
+ import cluster from "node:cluster"
3
+ import { createDetailedMessage, createLogger } from "@jsenv/log"
4
+ import {
5
+ Abort,
6
+ raceProcessTeardownEvents,
7
+ createCallbackListNotifiedOnce,
8
+ } from "@jsenv/abort"
9
+ import { memoize } from "@jsenv/utils/src/memoize/memoize.js"
10
+
11
+ import { createServiceController } from "./service_controller.js"
12
+ import { timingToServerTimingResponseHeaders } from "./server_timing/timing_header.js"
13
+ import { createPolyglotServer } from "./internal/server-polyglot.js"
14
+ import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js"
15
+ import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js"
16
+ import {
17
+ fromNodeRequest,
18
+ createPushRequest,
19
+ applyRedirectionToRequest,
20
+ } from "./internal/request_factory.js"
21
+ import { populateNodeResponse } from "./internal/populateNodeResponse.js"
22
+ import {
23
+ statusToType,
24
+ colorizeResponseStatus,
25
+ } from "./internal/colorizeResponseStatus.js"
26
+ import { getServerOrigins } from "./internal/getServerOrigins.js"
27
+ import { listen, stopListening } from "./internal/listen.js"
28
+ import { composeTwoResponses } from "./internal/response_composition.js"
29
+ import { listenRequest } from "./internal/listenRequest.js"
30
+ import { listenEvent } from "./internal/listenEvent.js"
31
+ import { listenServerConnectionError } from "./internal/listenServerConnectionError.js"
32
+ import {
33
+ STOP_REASON_INTERNAL_ERROR,
34
+ STOP_REASON_PROCESS_SIGHUP,
35
+ STOP_REASON_PROCESS_SIGTERM,
36
+ STOP_REASON_PROCESS_SIGINT,
37
+ STOP_REASON_PROCESS_BEFORE_EXIT,
38
+ STOP_REASON_PROCESS_EXIT,
39
+ STOP_REASON_NOT_SPECIFIED,
40
+ } from "./stopReasons.js"
41
+ import { composeTwoHeaders } from "./internal/headers_composition.js"
42
+
43
+ export const startServer = async ({
44
+ signal = new AbortController().signal,
45
+ logLevel,
46
+ startLog = true,
47
+ serverName = "server",
48
+
49
+ protocol = "http",
50
+ http2 = false,
51
+ http1Allowed = true,
52
+ redirectHttpToHttps,
53
+ allowHttpRequestOnHttps = false,
54
+ acceptAnyIp = false,
55
+ host = acceptAnyIp ? undefined : "localhost",
56
+ port = 0, // assign a random available port
57
+ portHint,
58
+ privateKey,
59
+ certificate,
60
+
61
+ // when inside a worker, we should not try to stop server on SIGINT
62
+ // otherwise it can create an EPIPE error while primary process tries
63
+ // to kill the server
64
+ stopOnSIGINT = !cluster.isWorker,
65
+ // auto close the server when the process exits
66
+ stopOnExit = true,
67
+ // auto close when requestToResponse throw an error
68
+ stopOnInternalError = false,
69
+ keepProcessAlive = true,
70
+ services = [],
71
+ nagle = true,
72
+ serverTiming = false,
73
+ requestWaitingMs = 0,
74
+ requestWaitingCallback = ({ request, warn, requestWaitingMs }) => {
75
+ warn(
76
+ createDetailedMessage(
77
+ `still no response found for request after ${requestWaitingMs} ms`,
78
+ {
79
+ "request url": `${request.origin}${request.ressource}`,
80
+ "request headers": JSON.stringify(request.headers, null, " "),
81
+ },
82
+ ),
83
+ )
84
+ },
85
+ } = {}) => {
86
+ if (protocol !== "http" && protocol !== "https") {
87
+ throw new Error(`protocol must be http or https, got ${protocol}`)
88
+ }
89
+ if (protocol === "https") {
90
+ if (!certificate) {
91
+ throw new Error(`missing certificate for https server`)
92
+ }
93
+ if (!privateKey) {
94
+ throw new Error(`missing privateKey for https server`)
95
+ }
96
+ }
97
+ if (http2 && protocol !== "https") {
98
+ throw new Error(`http2 needs "https" but protocol is "${protocol}"`)
99
+ }
100
+
101
+ const logger = createLogger({ logLevel })
102
+ if (
103
+ redirectHttpToHttps === undefined &&
104
+ protocol === "https" &&
105
+ !allowHttpRequestOnHttps
106
+ ) {
107
+ redirectHttpToHttps = true
108
+ }
109
+ if (redirectHttpToHttps && protocol === "http") {
110
+ logger.warn(`redirectHttpToHttps ignored because protocol is http`)
111
+ redirectHttpToHttps = false
112
+ }
113
+ if (allowHttpRequestOnHttps && redirectHttpToHttps) {
114
+ logger.warn(
115
+ `redirectHttpToHttps ignored because allowHttpRequestOnHttps is enabled`,
116
+ )
117
+ redirectHttpToHttps = false
118
+ }
119
+
120
+ if (allowHttpRequestOnHttps && protocol === "http") {
121
+ logger.warn(`allowHttpRequestOnHttps ignored because protocol is http`)
122
+ allowHttpRequestOnHttps = false
123
+ }
124
+
125
+ const server = {}
126
+ const serviceController = createServiceController(services)
127
+
128
+ const processTeardownEvents = {
129
+ SIGHUP: stopOnExit,
130
+ SIGTERM: stopOnExit,
131
+ SIGINT: stopOnSIGINT,
132
+ beforeExit: stopOnExit,
133
+ exit: stopOnExit,
134
+ }
135
+
136
+ let status = "starting"
137
+ let nodeServer
138
+ const startServerOperation = Abort.startOperation()
139
+ const stopCallbackList = createCallbackListNotifiedOnce()
140
+
141
+ try {
142
+ startServerOperation.addAbortSignal(signal)
143
+ startServerOperation.addAbortSource((abort) => {
144
+ return raceProcessTeardownEvents(processTeardownEvents, ({ name }) => {
145
+ logger.info(`process teardown (${name}) -> aborting start server`)
146
+ abort()
147
+ })
148
+ })
149
+ startServerOperation.throwIfAborted()
150
+ nodeServer = await createNodeServer({
151
+ protocol,
152
+ redirectHttpToHttps,
153
+ allowHttpRequestOnHttps,
154
+ certificate,
155
+ privateKey,
156
+ http2,
157
+ http1Allowed,
158
+ })
159
+ startServerOperation.throwIfAborted()
160
+
161
+ // https://nodejs.org/api/net.html#net_server_unref
162
+ if (!keepProcessAlive) {
163
+ nodeServer.unref()
164
+ }
165
+
166
+ port = await listen({
167
+ signal: startServerOperation.signal,
168
+ server: nodeServer,
169
+ port,
170
+ portHint,
171
+ host,
172
+ })
173
+
174
+ serviceController.callHooks("serverListening", { port })
175
+ startServerOperation.addAbortCallback(async () => {
176
+ await stopListening(nodeServer)
177
+ })
178
+ startServerOperation.throwIfAborted()
179
+ } finally {
180
+ await startServerOperation.end()
181
+ }
182
+
183
+ // now the server is started (listening) it cannot be aborted anymore
184
+ // (otherwise an AbortError is thrown to the code calling "startServer")
185
+ // we can proceed to create a stop function to stop it gacefully
186
+ // and add a request handler
187
+ stopCallbackList.add(({ reason }) => {
188
+ logger.info(`${serverName} stopping server (reason: ${reason})`)
189
+ })
190
+ stopCallbackList.add(async () => {
191
+ await stopListening(nodeServer)
192
+ })
193
+ let stoppedResolve
194
+ const stoppedPromise = new Promise((resolve) => {
195
+ stoppedResolve = resolve
196
+ })
197
+ const stop = memoize(async (reason = STOP_REASON_NOT_SPECIFIED) => {
198
+ status = "stopping"
199
+ await Promise.all(stopCallbackList.notify({ reason }))
200
+ serviceController.callHooks("serverStopped", { reason })
201
+ status = "stopped"
202
+ stoppedResolve(reason)
203
+ })
204
+
205
+ const cancelProcessTeardownRace = raceProcessTeardownEvents(
206
+ processTeardownEvents,
207
+ (winner) => {
208
+ stop(PROCESS_TEARDOWN_EVENTS_MAP[winner.name])
209
+ },
210
+ )
211
+ stopCallbackList.add(cancelProcessTeardownRace)
212
+
213
+ const onError = (error) => {
214
+ if (status === "stopping" && error.code === "ECONNRESET") {
215
+ return
216
+ }
217
+ throw error
218
+ }
219
+
220
+ status = "opened"
221
+ const serverOrigins = await getServerOrigins({ protocol, host, port })
222
+ const serverOrigin = serverOrigins.local
223
+
224
+ const removeConnectionErrorListener = listenServerConnectionError(
225
+ nodeServer,
226
+ onError,
227
+ )
228
+ stopCallbackList.add(removeConnectionErrorListener)
229
+
230
+ const connectionsTracker = trackServerPendingConnections(nodeServer, {
231
+ http2,
232
+ })
233
+ // opened connection must be shutdown before the close event is emitted
234
+ stopCallbackList.add(connectionsTracker.stop)
235
+
236
+ const pendingRequestsTracker = trackServerPendingRequests(nodeServer, {
237
+ http2,
238
+ })
239
+ // ensure pending requests got a response from the server
240
+ stopCallbackList.add((reason) => {
241
+ pendingRequestsTracker.stop({
242
+ status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
243
+ reason,
244
+ })
245
+ })
246
+
247
+ request: {
248
+ const requestCallback = async (nodeRequest, nodeResponse) => {
249
+ // pause the stream to let a chance to "requestToResponse"
250
+ // to call "requestRequestBody". Without this the request body readable stream
251
+ // might be closed when we'll try to attach "data" and "end" listeners to it
252
+ nodeRequest.pause()
253
+ if (!nagle) {
254
+ nodeRequest.connection.setNoDelay(true)
255
+ }
256
+ if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
257
+ nodeResponse.writeHead(301, {
258
+ location: `${serverOrigin}${nodeRequest.url}`,
259
+ })
260
+ nodeResponse.end()
261
+ return
262
+ }
263
+
264
+ const receiveRequestOperation = Abort.startOperation()
265
+ receiveRequestOperation.addAbortSource((abort) => {
266
+ const closeEventCallback = () => {
267
+ if (nodeRequest.complete) {
268
+ receiveRequestOperation.end()
269
+ } else {
270
+ nodeResponse.destroy()
271
+ abort()
272
+ }
273
+ }
274
+ nodeRequest.once("close", closeEventCallback)
275
+ return () => {
276
+ nodeRequest.removeListener("close", closeEventCallback)
277
+ }
278
+ })
279
+ receiveRequestOperation.addAbortSource((abort) => {
280
+ return stopCallbackList.add(abort)
281
+ })
282
+
283
+ const sendResponseOperation = Abort.startOperation()
284
+ sendResponseOperation.addAbortSignal(receiveRequestOperation.signal)
285
+ sendResponseOperation.addAbortSource((abort) => {
286
+ return stopCallbackList.add(abort)
287
+ })
288
+
289
+ const request = fromNodeRequest(nodeRequest, {
290
+ serverOrigin,
291
+ signal: receiveRequestOperation.signal,
292
+ })
293
+
294
+ // Handling request is asynchronous, we buffer logs for that request
295
+ // until we know what happens with that request
296
+ // It delays logs until we know of the request will be handled
297
+ // but it's mandatory to make logs readable.
298
+ const rootRequestNode = {
299
+ logs: [],
300
+ children: [],
301
+ }
302
+ const addRequestLog = (node, { type, value }) => {
303
+ node.logs.push({ type, value })
304
+ }
305
+ const onRequestHandled = (node) => {
306
+ if (node !== rootRequestNode) {
307
+ // keep buffering until root request write logs for everyone
308
+ return
309
+ }
310
+ const prefixLines = (string, prefix) => {
311
+ return string.replace(/^(?!\s*$)/gm, prefix)
312
+ }
313
+ const writeLog = (
314
+ { type, value },
315
+ { someLogIsError, someLogIsWarn, depth },
316
+ ) => {
317
+ if (depth > 0) {
318
+ value = prefixLines(value, " ".repeat(depth))
319
+ }
320
+ if (type === "info") {
321
+ if (someLogIsError) {
322
+ type = "error"
323
+ } else if (someLogIsWarn) {
324
+ type = "warn"
325
+ }
326
+ }
327
+ logger[type](value)
328
+ }
329
+ const visitRequestNodeToLog = (requestNode, depth) => {
330
+ let someLogIsError = false
331
+ let someLogIsWarn = false
332
+ requestNode.logs.forEach((log) => {
333
+ if (log.type === "error") {
334
+ someLogIsError = true
335
+ }
336
+ if (log.type === "warn") {
337
+ someLogIsWarn = true
338
+ }
339
+ })
340
+
341
+ const firstLog = requestNode.logs.shift()
342
+ const lastLog = requestNode.logs.pop()
343
+ const middleLogs = requestNode.logs
344
+
345
+ writeLog(firstLog, { someLogIsError, someLogIsWarn, depth })
346
+ middleLogs.forEach((log) => {
347
+ writeLog(log, { someLogIsError, someLogIsWarn, depth })
348
+ })
349
+ requestNode.children.forEach((child) => {
350
+ visitRequestNodeToLog(child, depth + 1)
351
+ })
352
+ if (lastLog) {
353
+ writeLog(lastLog, {
354
+ someLogIsError,
355
+ someLogIsWarn,
356
+ depth: depth + 1,
357
+ })
358
+ }
359
+ }
360
+ visitRequestNodeToLog(rootRequestNode, 0)
361
+ }
362
+ nodeRequest.on("error", (error) => {
363
+ if (error.message === "aborted") {
364
+ addRequestLog(rootRequestNode, {
365
+ type: "debug",
366
+ value: createDetailedMessage(`request aborted by client`, {
367
+ "error message": error.message,
368
+ }),
369
+ })
370
+ } else {
371
+ // I'm not sure this can happen but it's here in case
372
+ addRequestLog(rootRequestNode, {
373
+ type: "error",
374
+ value: createDetailedMessage(`"error" event emitted on request`, {
375
+ "error stack": error.stack,
376
+ }),
377
+ })
378
+ }
379
+ })
380
+
381
+ const pushResponse = async ({ path, method }, { requestNode }) => {
382
+ const http2Stream = nodeResponse.stream
383
+
384
+ // being able to push a stream is nice to have
385
+ // so when it fails it's not critical
386
+ const onPushStreamError = (e) => {
387
+ addRequestLog(requestNode, {
388
+ type: "error",
389
+ value: createDetailedMessage(
390
+ `An error occured while pushing a stream to the response for ${request.ressource}`,
391
+ {
392
+ "error stack": e.stack,
393
+ },
394
+ ),
395
+ })
396
+ }
397
+
398
+ // not aborted, let's try to push a stream into that response
399
+ // https://nodejs.org/docs/latest-v16.x/api/http2.html#http2streampushstreamheaders-options-callback
400
+ let pushStream
401
+ try {
402
+ pushStream = await new Promise((resolve, reject) => {
403
+ http2Stream.pushStream(
404
+ {
405
+ ":path": path,
406
+ ...(method ? { ":method": method } : {}),
407
+ },
408
+ async (
409
+ error,
410
+ pushStream,
411
+ // headers
412
+ ) => {
413
+ if (error) {
414
+ reject(error)
415
+ }
416
+ resolve(pushStream)
417
+ },
418
+ )
419
+ })
420
+ } catch (e) {
421
+ onPushStreamError(e)
422
+ return
423
+ }
424
+
425
+ const abortController = new AbortController()
426
+ // It's possible to get NGHTTP2_REFUSED_STREAM errors here
427
+ // https://github.com/nodejs/node/issues/20824
428
+ const pushErrorCallback = (error) => {
429
+ onPushStreamError(error)
430
+ abortController.abort()
431
+ }
432
+ pushStream.on("error", pushErrorCallback)
433
+ sendResponseOperation.addEndCallback(() => {
434
+ pushStream.removeListener("error", onPushStreamError)
435
+ })
436
+
437
+ await sendResponseOperation.withSignal(async (signal) => {
438
+ const pushResponseOperation = Abort.startOperation()
439
+ pushResponseOperation.addAbortSignal(signal)
440
+ pushResponseOperation.addAbortSignal(abortController.signal)
441
+
442
+ const pushRequest = createPushRequest(request, {
443
+ signal: pushResponseOperation.signal,
444
+ pathname: path,
445
+ method,
446
+ })
447
+
448
+ try {
449
+ const responseProperties = await handleRequest(pushRequest, {
450
+ requestNode,
451
+ })
452
+ if (!abortController.signal.aborted) {
453
+ if (pushStream.destroyed) {
454
+ abortController.abort()
455
+ } else if (!http2Stream.pushAllowed) {
456
+ abortController.abort()
457
+ } else if (responseProperties !== ABORTED_RESPONSE_PROPERTIES) {
458
+ const responseLength =
459
+ responseProperties.headers["content-length"] || 0
460
+ const { effectiveRecvDataLength, remoteWindowSize } =
461
+ http2Stream.session.state
462
+ if (
463
+ effectiveRecvDataLength + responseLength >
464
+ remoteWindowSize
465
+ ) {
466
+ addRequestLog(requestNode, {
467
+ type: "debug",
468
+ value: `Aborting stream to prevent exceeding remoteWindowSize`,
469
+ })
470
+ abortController.abort()
471
+ }
472
+ }
473
+ }
474
+ await sendResponse({
475
+ signal: pushResponseOperation.signal,
476
+ request: pushRequest,
477
+ requestNode,
478
+ responseStream: pushStream,
479
+ responseProperties,
480
+ })
481
+ } finally {
482
+ await pushResponseOperation.end()
483
+ }
484
+ })
485
+ }
486
+
487
+ const handleRequest = async (request, { requestNode }) => {
488
+ let requestReceivedMeasure
489
+ if (serverTiming) {
490
+ requestReceivedMeasure = performance.now()
491
+ }
492
+ addRequestLog(requestNode, {
493
+ type: "info",
494
+ value: request.parent
495
+ ? `Push ${request.ressource}`
496
+ : `${request.method} ${request.origin}${request.ressource}`,
497
+ })
498
+ const warn = (value) => {
499
+ addRequestLog(requestNode, {
500
+ type: "warn",
501
+ value,
502
+ })
503
+ }
504
+
505
+ let requestWaitingTimeout
506
+ if (requestWaitingMs) {
507
+ requestWaitingTimeout = setTimeout(
508
+ () => requestWaitingCallback({ request, warn, requestWaitingMs }),
509
+ requestWaitingMs,
510
+ ).unref()
511
+ }
512
+
513
+ serviceController.callHooks(
514
+ "redirectRequest",
515
+ request,
516
+ { warn },
517
+ (newRequestProperties) => {
518
+ if (newRequestProperties) {
519
+ request = applyRedirectionToRequest(request, {
520
+ original: request.original || request,
521
+ previous: request,
522
+ ...newRequestProperties,
523
+ })
524
+ }
525
+ },
526
+ )
527
+
528
+ let handleRequestReturnValue
529
+ let errorWhileHandlingRequest = null
530
+ let handleRequestTimings = serverTiming ? {} : null
531
+ try {
532
+ handleRequestReturnValue =
533
+ await serviceController.callAsyncHooksUntil(
534
+ "handleRequest",
535
+ request,
536
+ {
537
+ timing: handleRequestTimings,
538
+ warn,
539
+ pushResponse: async ({ path, method }) => {
540
+ if (typeof path !== "string" || path[0] !== "/") {
541
+ addRequestLog(requestNode, {
542
+ type: "warn",
543
+ value: `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
544
+ })
545
+ return
546
+ }
547
+ if (!request.http2) {
548
+ addRequestLog(requestNode, {
549
+ type: "warn",
550
+ value: `response push ignored because request is not http2`,
551
+ })
552
+ return
553
+ }
554
+ const canPushStream = testCanPushStream(nodeResponse.stream)
555
+ if (!canPushStream.can) {
556
+ addRequestLog(requestNode, {
557
+ type: "debug",
558
+ value: `response push ignored because ${canPushStream.reason}`,
559
+ })
560
+ return
561
+ }
562
+
563
+ let preventedByService = null
564
+ const prevent = () => {
565
+ preventedByService = serviceController.getCurrentService()
566
+ }
567
+ serviceController.callHooksUntil(
568
+ "onResponsePush",
569
+ { path, method },
570
+ {
571
+ request,
572
+ warn,
573
+ prevent,
574
+ },
575
+ () => preventedByService,
576
+ )
577
+ if (preventedByService) {
578
+ addRequestLog(requestNode, {
579
+ type: "debug",
580
+ value: `response push prevented by "${preventedByService.name}" service`,
581
+ })
582
+ return
583
+ }
584
+
585
+ const requestChildNode = { logs: [], children: [] }
586
+ requestNode.children.push(requestChildNode)
587
+ await pushResponse(
588
+ { path, method },
589
+ {
590
+ requestNode: requestChildNode,
591
+ parentHttp2Stream: nodeResponse.stream,
592
+ },
593
+ )
594
+ },
595
+ },
596
+ )
597
+ } catch (error) {
598
+ errorWhileHandlingRequest = error
599
+ }
600
+
601
+ let responseProperties
602
+ if (errorWhileHandlingRequest) {
603
+ if (
604
+ errorWhileHandlingRequest.name === "AbortError" &&
605
+ request.signal.aborted
606
+ ) {
607
+ responseProperties = ABORTED_RESPONSE_PROPERTIES
608
+ } else {
609
+ // internal error, create 500 response
610
+ if (
611
+ // stopOnInternalError stops server only if requestToResponse generated
612
+ // a non controlled error (internal error).
613
+ // if requestToResponse gracefully produced a 500 response (it did not throw)
614
+ // then we can assume we are still in control of what we are doing
615
+ stopOnInternalError
616
+ ) {
617
+ // il faudrais pouvoir stop que les autres response ?
618
+ stop(STOP_REASON_INTERNAL_ERROR)
619
+ }
620
+ const handleErrorReturnValue =
621
+ await serviceController.callAsyncHooksUntil(
622
+ "handleError",
623
+ errorWhileHandlingRequest,
624
+ {
625
+ request,
626
+ warn,
627
+ },
628
+ )
629
+ if (!handleErrorReturnValue) {
630
+ throw errorWhileHandlingRequest
631
+ }
632
+ addRequestLog(requestNode, {
633
+ type: "error",
634
+ value: createDetailedMessage(
635
+ `internal error while handling request`,
636
+ {
637
+ "error stack": errorWhileHandlingRequest.stack,
638
+ },
639
+ ),
640
+ })
641
+ responseProperties = composeTwoResponses(
642
+ {
643
+ status: 500,
644
+ statusText: "Internal Server Error",
645
+ headers: {
646
+ // ensure error are not cached
647
+ "cache-control": "no-store",
648
+ "content-type": "text/plain",
649
+ },
650
+ },
651
+ handleErrorReturnValue,
652
+ )
653
+ }
654
+ } else {
655
+ const {
656
+ status = 501,
657
+ statusText,
658
+ statusMessage,
659
+ headers = {},
660
+ body,
661
+ ...rest
662
+ } = handleRequestReturnValue || {}
663
+ responseProperties = {
664
+ status,
665
+ statusText,
666
+ statusMessage,
667
+ headers,
668
+ body,
669
+ ...rest,
670
+ }
671
+ }
672
+
673
+ if (serverTiming) {
674
+ const responseReadyMeasure = performance.now()
675
+ const timeToStartResponding =
676
+ responseReadyMeasure - requestReceivedMeasure
677
+ const serverTiming = {
678
+ ...handleRequestTimings,
679
+ ...responseProperties.timing,
680
+ "time to start responding": timeToStartResponding,
681
+ }
682
+ responseProperties.headers = composeTwoHeaders(
683
+ responseProperties.headers,
684
+ timingToServerTimingResponseHeaders(serverTiming),
685
+ )
686
+ }
687
+ if (requestWaitingMs) {
688
+ clearTimeout(requestWaitingTimeout)
689
+ }
690
+ if (
691
+ request.method !== "HEAD" &&
692
+ responseProperties.headers["content-length"] > 0 &&
693
+ !responseProperties.body
694
+ ) {
695
+ addRequestLog(requestNode, {
696
+ type: "warn",
697
+ value: `content-length header is ${responseProperties.headers["content-length"]} but body is empty`,
698
+ })
699
+ }
700
+ serviceController.callHooks(
701
+ "injectResponseHeaders",
702
+ responseProperties,
703
+ {
704
+ request,
705
+ warn,
706
+ },
707
+ (returnValue) => {
708
+ if (returnValue) {
709
+ responseProperties.headers = composeTwoHeaders(
710
+ responseProperties.headers,
711
+ returnValue,
712
+ )
713
+ }
714
+ },
715
+ )
716
+ serviceController.callHooks("responseReady", responseProperties, {
717
+ request,
718
+ warn,
719
+ })
720
+ return responseProperties
721
+ }
722
+
723
+ const sendResponse = async ({
724
+ signal,
725
+ request,
726
+ requestNode,
727
+ responseStream,
728
+ responseProperties,
729
+ }) => {
730
+ // When "pushResponse" is called and the parent response has no body
731
+ // the parent response is immediatly ended. It means child responses (pushed streams)
732
+ // won't get a chance to be pushed.
733
+ // To let a chance to pushed streams we wait a little before sending the response
734
+ const ignoreBody = request.method === "HEAD"
735
+ const bodyIsEmpty = !responseProperties.body || ignoreBody
736
+ if (bodyIsEmpty && requestNode.children.length > 0) {
737
+ await new Promise((resolve) => setTimeout(resolve))
738
+ }
739
+
740
+ await populateNodeResponse(responseStream, responseProperties, {
741
+ signal,
742
+ ignoreBody,
743
+ onAbort: () => {
744
+ addRequestLog(requestNode, {
745
+ type: "info",
746
+ value: `response aborted`,
747
+ })
748
+ onRequestHandled(requestNode)
749
+ },
750
+ onError: (error) => {
751
+ addRequestLog(requestNode, {
752
+ type: "error",
753
+ value: createDetailedMessage(
754
+ `An error occured while sending response`,
755
+ {
756
+ "error stack": error.stack,
757
+ },
758
+ ),
759
+ })
760
+ onRequestHandled(requestNode)
761
+ },
762
+ onHeadersSent: ({ status, statusText }) => {
763
+ const statusType = statusToType(status)
764
+ addRequestLog(requestNode, {
765
+ type: {
766
+ information: "info",
767
+ success: "info",
768
+ redirection: "info",
769
+ client_error: "warn",
770
+ server_error: "error",
771
+ }[statusType],
772
+ value: `${colorizeResponseStatus(status)} ${
773
+ responseProperties.statusMessage || statusText
774
+ }`,
775
+ })
776
+ },
777
+ onEnd: () => {
778
+ onRequestHandled(requestNode)
779
+ },
780
+ })
781
+ }
782
+
783
+ try {
784
+ if (receiveRequestOperation.signal.aborted) {
785
+ return
786
+ }
787
+ const responseProperties = await handleRequest(request, {
788
+ requestNode: rootRequestNode,
789
+ })
790
+ nodeRequest.resume()
791
+ if (receiveRequestOperation.signal.aborted) {
792
+ return
793
+ }
794
+
795
+ // the node request readable stream is never closed because
796
+ // the response headers contains "connection: keep-alive"
797
+ // In this scenario we want to disable READABLE_STREAM_TIMEOUT warning
798
+ if (responseProperties.headers.connection === "keep-alive") {
799
+ clearTimeout(request.body.timeout)
800
+ }
801
+
802
+ await sendResponse({
803
+ signal: sendResponseOperation.signal,
804
+ request,
805
+ requestNode: rootRequestNode,
806
+ responseStream: nodeResponse,
807
+ responseProperties,
808
+ })
809
+ } finally {
810
+ await sendResponseOperation.end()
811
+ }
812
+ }
813
+ const removeRequestListener = listenRequest(nodeServer, requestCallback)
814
+ // ensure we don't try to handle new requests while server is stopping
815
+ stopCallbackList.add(removeRequestListener)
816
+ }
817
+
818
+ websocket: {
819
+ // https://github.com/websockets/ws/blob/master/doc/ws.md#class-websocket
820
+ const websocketHandlers = []
821
+ serviceController.services.forEach((service) => {
822
+ const { handleWebsocket } = service
823
+ if (handleWebsocket) {
824
+ websocketHandlers.push(handleWebsocket)
825
+ }
826
+ })
827
+ if (websocketHandlers.length > 0) {
828
+ const websocketClients = new Set()
829
+ const { WebSocketServer } = await import("ws")
830
+ let websocketServer = new WebSocketServer({ noServer: true })
831
+ const websocketOrigin =
832
+ protocol === "https" ? `wss://${host}:${port}` : `ws://${host}:${port}`
833
+ server.websocketOrigin = websocketOrigin
834
+ const upgradeCallback = (nodeRequest, socket, head) => {
835
+ websocketServer.handleUpgrade(
836
+ nodeRequest,
837
+ socket,
838
+ head,
839
+ async (websocket) => {
840
+ websocketClients.add(websocket)
841
+ websocket.once("close", () => {
842
+ websocketClients.delete(websocket)
843
+ })
844
+ const request = fromNodeRequest(nodeRequest, {
845
+ serverOrigin: websocketOrigin,
846
+ signal: new AbortController().signal,
847
+ })
848
+ serviceController.callAsyncHooksUntil(
849
+ "handleWebsocket",
850
+ websocket,
851
+ {
852
+ request,
853
+ },
854
+ )
855
+ },
856
+ )
857
+ }
858
+
859
+ // see server-polyglot.js, upgrade must be listened on https server when used
860
+ const facadeServer = nodeServer._tlsServer || nodeServer
861
+ const removeUpgradeCallback = listenEvent(
862
+ facadeServer,
863
+ "upgrade",
864
+ upgradeCallback,
865
+ )
866
+ stopCallbackList.add(removeUpgradeCallback)
867
+ stopCallbackList.add(() => {
868
+ websocketServer.close()
869
+ websocketServer = null
870
+ })
871
+ }
872
+ }
873
+
874
+ if (startLog) {
875
+ if (serverOrigins.network) {
876
+ logger.info(
877
+ `${serverName} started at ${serverOrigins.local} (${serverOrigins.network})`,
878
+ )
879
+ } else {
880
+ logger.info(`${serverName} started at ${serverOrigins.local}`)
881
+ }
882
+ }
883
+
884
+ Object.assign(server, {
885
+ getStatus: () => status,
886
+ port,
887
+ origin: serverOrigin,
888
+ origins: serverOrigins,
889
+ nodeServer,
890
+ stop,
891
+ stoppedPromise,
892
+ addEffect: (callback) => {
893
+ const cleanup = callback()
894
+ if (typeof cleanup === "function") {
895
+ stopCallbackList.add(cleanup)
896
+ }
897
+ },
898
+ })
899
+ return server
900
+ }
901
+
902
+ const createNodeServer = async ({
903
+ protocol,
904
+ redirectHttpToHttps,
905
+ allowHttpRequestOnHttps,
906
+ certificate,
907
+ privateKey,
908
+ http2,
909
+ http1Allowed,
910
+ }) => {
911
+ if (protocol === "http") {
912
+ return http.createServer()
913
+ }
914
+
915
+ if (redirectHttpToHttps || allowHttpRequestOnHttps) {
916
+ return createPolyglotServer({
917
+ certificate,
918
+ privateKey,
919
+ http2,
920
+ http1Allowed,
921
+ })
922
+ }
923
+
924
+ const { createServer } = await import("node:https")
925
+ return createServer({
926
+ cert: certificate,
927
+ key: privateKey,
928
+ })
929
+ }
930
+
931
+ const testCanPushStream = (http2Stream) => {
932
+ if (!http2Stream.pushAllowed) {
933
+ return {
934
+ can: false,
935
+ reason: `stream.pushAllowed is false`,
936
+ }
937
+ }
938
+
939
+ // See https://nodejs.org/dist/latest-v16.x/docs/api/http2.html#http2sessionstate
940
+ // And https://github.com/google/node-h2-auto-push/blob/67a36c04cbbd6da7b066a4e8d361c593d38853a4/src/index.ts#L100-L106
941
+ const { remoteWindowSize } = http2Stream.session.state
942
+ if (remoteWindowSize === 0) {
943
+ return {
944
+ can: false,
945
+ reason: `no more remoteWindowSize`,
946
+ }
947
+ }
948
+
949
+ return {
950
+ can: true,
951
+ }
952
+ }
953
+
954
+ const ABORTED_RESPONSE_PROPERTIES = {}
955
+
956
+ const PROCESS_TEARDOWN_EVENTS_MAP = {
957
+ SIGHUP: STOP_REASON_PROCESS_SIGHUP,
958
+ SIGTERM: STOP_REASON_PROCESS_SIGTERM,
959
+ SIGINT: STOP_REASON_PROCESS_SIGINT,
960
+ beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
961
+ exit: STOP_REASON_PROCESS_EXIT,
962
+ }