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