@jsenv/server 12.7.4 → 13.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 (25) hide show
  1. package/README.md +68 -50
  2. package/package.json +2 -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/getServerOrigins.js +43 -14
  10. package/src/internal/listen.js +15 -13
  11. package/src/main.js +11 -13
  12. package/src/serve_directory.js +2 -2
  13. package/src/{startServer.js → server.js} +150 -122
  14. package/src/service_controller.js +173 -0
  15. package/src/{cors/plugin_cors.js → services/cors/jsenv_service_cors.js} +29 -45
  16. package/src/services/error_handler/jsenv_service_error_handler.js +98 -0
  17. package/src/{content_negotiation/checkContentNegotiation.js → services/response_acceptance_check/jsenv_service_response_acceptance_check.js} +16 -7
  18. package/src/services/ressource_aliases/jsenv_service_ressource_aliases.js +40 -0
  19. package/src/sse/{createSSERoom.js → sse_room.js} +18 -16
  20. package/src/content_negotiation/plugin_content_negotiation_check.js +0 -15
  21. package/src/error_to_response_default.js +0 -94
  22. package/src/plugin_request_waiting_check.js +0 -32
  23. package/src/ressource_aliases/plugin_ressource_aliases.js +0 -44
  24. package/src/server_timing/plugin_server_timing.js +0 -28
  25. package/src/service_composition/service_composition.js +0 -55
@@ -8,6 +8,8 @@ import {
8
8
  } from "@jsenv/abort"
9
9
  import { memoize } from "@jsenv/utils/src/memoize/memoize.js"
10
10
 
11
+ import { createServiceController } from "./service_controller.js"
12
+ import { timingToServerTimingResponseHeaders } from "./server_timing/timing_header.js"
11
13
  import { createPolyglotServer } from "./internal/server-polyglot.js"
12
14
  import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js"
13
15
  import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js"
@@ -26,7 +28,6 @@ import { listen, stopListening } from "./internal/listen.js"
26
28
  import { composeTwoResponses } from "./internal/response_composition.js"
27
29
  import { listenRequest } from "./internal/listenRequest.js"
28
30
  import { listenServerConnectionError } from "./internal/listenServerConnectionError.js"
29
- import { applyDefaultErrorToResponse } from "./error_to_response_default.js"
30
31
  import {
31
32
  STOP_REASON_INTERNAL_ERROR,
32
33
  STOP_REASON_PROCESS_SIGHUP,
@@ -36,6 +37,7 @@ import {
36
37
  STOP_REASON_PROCESS_EXIT,
37
38
  STOP_REASON_NOT_SPECIFIED,
38
39
  } from "./stopReasons.js"
40
+ import { composeTwoHeaders } from "./internal/headers_composition.js"
39
41
 
40
42
  export const startServer = async ({
41
43
  signal = new AbortController().signal,
@@ -48,8 +50,8 @@ export const startServer = async ({
48
50
  http1Allowed = true,
49
51
  redirectHttpToHttps,
50
52
  allowHttpRequestOnHttps = false,
51
- listenAnyIp = false,
52
- ip = listenAnyIp ? undefined : "127.0.0.1",
53
+ acceptAnyIp = false,
54
+ host = acceptAnyIp ? undefined : "localhost",
53
55
  port = 0, // assign a random available port
54
56
  portHint,
55
57
  privateKey,
@@ -64,16 +66,21 @@ export const startServer = async ({
64
66
  // auto close when requestToResponse throw an error
65
67
  stopOnInternalError = false,
66
68
  keepProcessAlive = true,
67
- requestToResponse = () => null,
68
- plugins = {},
69
-
70
- sendErrorDetails = false,
71
- errorToResponse = () => null,
72
-
73
- onListenStart = () => {},
74
- onListenEnd = () => {},
75
- onStop = () => {},
69
+ services = [],
76
70
  nagle = true,
71
+ serverTiming = false,
72
+ requestWaitingMs = 0,
73
+ requestWaitingCallback = ({ request, warn, requestWaitingMs }) => {
74
+ warn(
75
+ createDetailedMessage(
76
+ `still no response found for request after ${requestWaitingMs} ms`,
77
+ {
78
+ "request url": `${request.origin}${request.ressource}`,
79
+ "request headers": JSON.stringify(request.headers, null, " "),
80
+ },
81
+ ),
82
+ )
83
+ },
77
84
  } = {}) => {
78
85
  if (protocol !== "http" && protocol !== "https") {
79
86
  throw new Error(`protocol must be http or https, got ${protocol}`)
@@ -114,6 +121,8 @@ export const startServer = async ({
114
121
  allowHttpRequestOnHttps = false
115
122
  }
116
123
 
124
+ const serviceController = createServiceController(services)
125
+
117
126
  const processTeardownEvents = {
118
127
  SIGHUP: stopOnExit,
119
128
  SIGTERM: stopOnExit,
@@ -151,15 +160,14 @@ export const startServer = async ({
151
160
  nodeServer.unref()
152
161
  }
153
162
 
154
- onListenStart()
155
163
  port = await listen({
156
164
  signal: startServerOperation.signal,
157
165
  server: nodeServer,
158
166
  port,
159
167
  portHint,
160
- ip,
168
+ host,
161
169
  })
162
- onListenEnd(port)
170
+ serviceController.callHooks("serverListening", { port })
163
171
  startServerOperation.addAbortCallback(async () => {
164
172
  await stopListening(nodeServer)
165
173
  })
@@ -177,7 +185,6 @@ export const startServer = async ({
177
185
  stopCallbackList.add(({ reason }) => {
178
186
  logger.info(`${serverName} stopping server (reason: ${reason})`)
179
187
  })
180
- stopCallbackList.add(onStop)
181
188
  stopCallbackList.add(async () => {
182
189
  await stopListening(nodeServer)
183
190
  })
@@ -188,6 +195,7 @@ export const startServer = async ({
188
195
  const stop = memoize(async (reason = STOP_REASON_NOT_SPECIFIED) => {
189
196
  status = "stopping"
190
197
  await Promise.all(stopCallbackList.notify({ reason }))
198
+ serviceController.callHooks("serverStopped", { reason })
191
199
  status = "stopped"
192
200
  stoppedResolve(reason)
193
201
  })
@@ -208,8 +216,8 @@ export const startServer = async ({
208
216
  }
209
217
 
210
218
  status = "opened"
211
- const serverOrigins = getServerOrigins({ protocol, ip, port })
212
- const serverOrigin = serverOrigins.internal
219
+ const serverOrigins = await getServerOrigins({ protocol, host, port })
220
+ const serverOrigin = serverOrigins.local
213
221
 
214
222
  const removeConnectionErrorListener = listenServerConnectionError(
215
223
  nodeServer,
@@ -467,13 +475,16 @@ export const startServer = async ({
467
475
  }
468
476
 
469
477
  const handleRequest = async (request, { requestNode }) => {
478
+ let requestReceivedMeasure
479
+ if (serverTiming) {
480
+ requestReceivedMeasure = performance.now()
481
+ }
470
482
  addRequestLog(requestNode, {
471
483
  type: "info",
472
484
  value: request.parent
473
485
  ? `Push ${request.ressource}`
474
486
  : `${request.method} ${request.origin}${request.ressource}`,
475
487
  })
476
-
477
488
  const warn = (value) => {
478
489
  addRequestLog(requestNode, {
479
490
  type: "warn",
@@ -481,36 +492,39 @@ export const startServer = async ({
481
492
  })
482
493
  }
483
494
 
484
- const requestPlugins = []
485
- let responseFromShortcircuit
486
- const shortcircuitResponse = (value) => {
487
- responseFromShortcircuit = value
488
- }
489
- const redirectRequest = (requestRedirection) => {
490
- request = applyRedirectionToRequest(request, requestRedirection)
495
+ let requestWaitingTimeout
496
+ if (requestWaitingMs) {
497
+ requestWaitingTimeout = setTimeout(
498
+ () => requestWaitingCallback({ request, warn, requestWaitingMs }),
499
+ requestWaitingMs,
500
+ ).unref()
491
501
  }
492
- Object.keys(plugins).forEach((pluginName) => {
493
- const { onRequest } = plugins[pluginName]
494
- if (onRequest) {
495
- const returnValue = onRequest(request, {
496
- warn,
497
- logger,
498
- shortcircuitResponse,
499
- redirectRequest,
500
- })
501
- if (returnValue) {
502
- requestPlugins.push({
503
- pluginName,
504
- ...returnValue,
502
+
503
+ serviceController.callHooks(
504
+ "redirectRequest",
505
+ request,
506
+ { warn },
507
+ (newRequestProperties) => {
508
+ if (newRequestProperties) {
509
+ request = applyRedirectionToRequest(request, {
510
+ original: request.original || request,
511
+ previous: request,
512
+ ...newRequestProperties,
505
513
  })
506
514
  }
507
- }
508
- })
509
- const result = responseFromShortcircuit
510
- ? { returnValue: responseFromShortcircuit }
511
- : await generateResponseDescription({
512
- requestToResponse,
513
- request,
515
+ },
516
+ )
517
+
518
+ let handleRequestReturnValue
519
+ let errorWhileHandlingRequest = null
520
+ let handleRequestTimings = serverTiming ? {} : null
521
+ try {
522
+ handleRequestReturnValue = await serviceController.callAsyncHooksUntil(
523
+ "handleRequest",
524
+ request,
525
+ {
526
+ timing: handleRequestTimings,
527
+ warn,
514
528
  pushResponse: async ({ path, method }) => {
515
529
  if (typeof path !== "string" || path[0] !== "/") {
516
530
  addRequestLog(requestNode, {
@@ -535,22 +549,24 @@ export const startServer = async ({
535
549
  return
536
550
  }
537
551
 
538
- let prevented = false
552
+ let preventedByService = null
539
553
  const prevent = () => {
540
- prevented = true
554
+ preventedByService = serviceController.getCurrentService()
541
555
  }
542
- const preventedByPlugin = requestPlugins.find((requestPlugin) => {
543
- const { onPushResponse } = requestPlugin
544
- if (!onPushResponse) {
545
- return false
546
- }
547
- onPushResponse({ path, method }, { prevent })
548
- return prevented
549
- })
550
- if (prevented) {
556
+ serviceController.callHooksUntil(
557
+ "onResponsePush",
558
+ { path, method },
559
+ {
560
+ request,
561
+ warn,
562
+ prevent,
563
+ },
564
+ () => preventedByService,
565
+ )
566
+ if (preventedByService) {
551
567
  addRequestLog(requestNode, {
552
568
  type: "debug",
553
- value: `response push prevented by "${preventedByPlugin.pluginName}" plugin`,
569
+ value: `response push prevented by "${preventedByService.name}" service`,
554
570
  })
555
571
  return
556
572
  }
@@ -565,16 +581,21 @@ export const startServer = async ({
565
581
  },
566
582
  )
567
583
  },
568
- })
569
-
570
- const { aborted } = result
571
- if (aborted) {
572
- return ABORTED_RESPONSE_PROPERTIES
584
+ },
585
+ )
586
+ } catch (error) {
587
+ errorWhileHandlingRequest = error
573
588
  }
574
- const readResponseProperties = async () => {
575
- const { error } = result
576
- // internal error, create 500 response
577
- if (error) {
589
+
590
+ let responseProperties
591
+ if (errorWhileHandlingRequest) {
592
+ if (
593
+ errorWhileHandlingRequest.name === "AbortError" &&
594
+ request.signal.aborted
595
+ ) {
596
+ responseProperties = ABORTED_RESPONSE_PROPERTIES
597
+ } else {
598
+ // internal error, create 500 response
578
599
  if (
579
600
  // stopOnInternalError stops server only if requestToResponse generated
580
601
  // a non controlled error (internal error).
@@ -585,25 +606,28 @@ export const startServer = async ({
585
606
  // il faudrais pouvoir stop que les autres response ?
586
607
  stop(STOP_REASON_INTERNAL_ERROR)
587
608
  }
588
- const responseDescriptionFromError =
589
- (await errorToResponse(error, {
590
- request,
591
- sendErrorDetails,
592
- })) ||
593
- (await applyDefaultErrorToResponse(error, {
594
- request,
595
- sendErrorDetails,
596
- }))
609
+ const handleErrorReturnValue =
610
+ await serviceController.callAsyncHooksUntil(
611
+ "handleError",
612
+ errorWhileHandlingRequest,
613
+ {
614
+ request,
615
+ warn,
616
+ },
617
+ )
618
+ if (!handleErrorReturnValue) {
619
+ throw errorWhileHandlingRequest
620
+ }
597
621
  addRequestLog(requestNode, {
598
622
  type: "error",
599
623
  value: createDetailedMessage(
600
624
  `internal error while handling request`,
601
625
  {
602
- "error stack": error.stack,
626
+ "error stack": errorWhileHandlingRequest.stack,
603
627
  },
604
628
  ),
605
629
  })
606
- return composeTwoResponses(
630
+ responseProperties = composeTwoResponses(
607
631
  {
608
632
  status: 500,
609
633
  statusText: "Internal Server Error",
@@ -613,10 +637,10 @@ export const startServer = async ({
613
637
  "content-type": "text/plain",
614
638
  },
615
639
  },
616
- responseDescriptionFromError,
640
+ handleErrorReturnValue,
617
641
  )
618
642
  }
619
-
643
+ } else {
620
644
  const {
621
645
  status = 501,
622
646
  statusText,
@@ -624,8 +648,8 @@ export const startServer = async ({
624
648
  headers = {},
625
649
  body,
626
650
  ...rest
627
- } = result.returnValue || {}
628
- const responseProperties = {
651
+ } = handleRequestReturnValue || {}
652
+ responseProperties = {
629
653
  status,
630
654
  statusText,
631
655
  statusMessage,
@@ -633,23 +657,25 @@ export const startServer = async ({
633
657
  body,
634
658
  ...rest,
635
659
  }
636
- return responseProperties
637
660
  }
638
661
 
639
- let responseProperties = await readResponseProperties()
640
- // call every plugin "onResponse" hook
641
- requestPlugins.forEach((requestPlugin) => {
642
- const { onResponse } = requestPlugin
643
- if (onResponse) {
644
- const returnValue = onResponse(responseProperties)
645
- if (returnValue) {
646
- responseProperties = composeTwoResponses(
647
- responseProperties,
648
- returnValue,
649
- )
650
- }
662
+ if (serverTiming) {
663
+ const responseReadyMeasure = performance.now()
664
+ const timeToStartResponding =
665
+ responseReadyMeasure - requestReceivedMeasure
666
+ const serverTiming = {
667
+ ...handleRequestTimings,
668
+ ...responseProperties.timing,
669
+ "time to start responding": timeToStartResponding,
651
670
  }
652
- })
671
+ responseProperties.headers = composeTwoHeaders(
672
+ responseProperties.headers,
673
+ timingToServerTimingResponseHeaders(serverTiming),
674
+ )
675
+ }
676
+ if (requestWaitingMs) {
677
+ clearTimeout(requestWaitingTimeout)
678
+ }
653
679
  if (
654
680
  request.method !== "HEAD" &&
655
681
  responseProperties.headers["content-length"] > 0 &&
@@ -660,6 +686,26 @@ export const startServer = async ({
660
686
  value: `content-length header is ${responseProperties.headers["content-length"]} but body is empty`,
661
687
  })
662
688
  }
689
+ serviceController.callHooks(
690
+ "injectResponseHeaders",
691
+ responseProperties,
692
+ {
693
+ request,
694
+ warn,
695
+ },
696
+ (returnValue) => {
697
+ if (returnValue) {
698
+ responseProperties.headers = composeTwoHeaders(
699
+ responseProperties.headers,
700
+ returnValue,
701
+ )
702
+ }
703
+ },
704
+ )
705
+ serviceController.callHooks("responseReady", responseProperties, {
706
+ request,
707
+ warn,
708
+ })
663
709
  return responseProperties
664
710
  }
665
711
 
@@ -759,9 +805,13 @@ export const startServer = async ({
759
805
  stopCallbackList.add(removeRequestListener)
760
806
 
761
807
  if (startLog) {
762
- logger.info(
763
- `${serverName} started at ${serverOrigin} (${serverOrigins.external})`,
764
- )
808
+ if (serverOrigins.network) {
809
+ logger.info(
810
+ `${serverName} started at ${serverOrigins.local} (${serverOrigins.network})`,
811
+ )
812
+ } else {
813
+ logger.info(`${serverName} started at ${serverOrigins.local}`)
814
+ }
765
815
  }
766
816
 
767
817
  const addEffect = (callback) => {
@@ -773,6 +823,7 @@ export const startServer = async ({
773
823
 
774
824
  return {
775
825
  getStatus: () => status,
826
+ port,
776
827
  origin: serverOrigin,
777
828
  origins: serverOrigins,
778
829
  nodeServer,
@@ -811,29 +862,6 @@ const createNodeServer = async ({
811
862
  })
812
863
  }
813
864
 
814
- const generateResponseDescription = async ({
815
- requestToResponse,
816
- request,
817
- pushResponse,
818
- }) => {
819
- try {
820
- const returnValue = await requestToResponse(request, {
821
- pushResponse,
822
- })
823
- return {
824
- returnValue,
825
- }
826
- } catch (error) {
827
- if (error.name === "AbortError" && request.signal.aborted) {
828
- return {
829
- aborted: true,
830
- }
831
- }
832
- return {
833
- error,
834
- }
835
- }
836
- }
837
865
  const testCanPushStream = (http2Stream) => {
838
866
  if (!http2Stream.pushAllowed) {
839
867
  return {
@@ -0,0 +1,173 @@
1
+ import { timeStart } from "./server_timing/timing_measure.js"
2
+
3
+ const HOOK_NAMES = [
4
+ "serverListening",
5
+ "redirectRequest",
6
+ "handleRequest",
7
+ "handleError",
8
+ "onResponsePush",
9
+ "injectResponseHeaders",
10
+ "responseReady",
11
+ "serverStopped",
12
+ ]
13
+
14
+ export const createServiceController = (services) => {
15
+ const flatServices = flattenAndFilterServices(services)
16
+ const hookGroups = {}
17
+
18
+ const addService = (service) => {
19
+ Object.keys(service).forEach((key) => {
20
+ if (key === "name") return
21
+ const isHook = HOOK_NAMES.includes(key)
22
+ if (!isHook) {
23
+ console.warn(
24
+ `Unexpected "${key}" property on "${service.name}" service`,
25
+ )
26
+ }
27
+ const hookName = key
28
+ const hookValue = service[hookName]
29
+ if (hookValue) {
30
+ const group = hookGroups[hookName] || (hookGroups[hookName] = [])
31
+ group.push({
32
+ service,
33
+ name: hookName,
34
+ value: hookValue,
35
+ })
36
+ }
37
+ })
38
+ }
39
+ flatServices.forEach((service) => {
40
+ addService(service)
41
+ })
42
+
43
+ let currentService = null
44
+ let currentHookName = null
45
+ const callHook = (hook, info, context) => {
46
+ const hookFn = hook.value
47
+ if (!hookFn) {
48
+ return null
49
+ }
50
+ currentService = hook.service
51
+ currentHookName = hook.name
52
+ let timeEnd
53
+ if (context && context.timing) {
54
+ timeEnd = timeStart(
55
+ `${currentService.name.replace("jsenv:", "")}.${currentHookName}`,
56
+ )
57
+ }
58
+ let valueReturned = hookFn(info, context)
59
+ if (context && context.timing) {
60
+ Object.assign(context.timing, timeEnd())
61
+ }
62
+ currentService = null
63
+ currentHookName = null
64
+ return valueReturned
65
+ }
66
+ const callAsyncHook = async (hook, info, context) => {
67
+ const hookFn = hook.value
68
+ if (!hookFn) {
69
+ return null
70
+ }
71
+ currentService = hook.service
72
+ currentHookName = hook.name
73
+ let timeEnd
74
+ if (context && context.timing) {
75
+ timeEnd = timeStart(
76
+ `${currentService.name.replace("jsenv:", "")}.${currentHookName}`,
77
+ )
78
+ }
79
+ let valueReturned = await hookFn(info, context)
80
+ if (context && context.timing) {
81
+ Object.assign(context.timing, timeEnd())
82
+ }
83
+ currentService = null
84
+ currentHookName = null
85
+ return valueReturned
86
+ }
87
+
88
+ const callHooks = (hookName, info, context, callback = () => {}) => {
89
+ const hooks = hookGroups[hookName]
90
+ if (hooks) {
91
+ for (const hook of hooks) {
92
+ const returnValue = callHook(hook, info, context)
93
+ if (returnValue) {
94
+ callback(returnValue)
95
+ }
96
+ }
97
+ }
98
+ }
99
+ const callHooksUntil = (
100
+ hookName,
101
+ info,
102
+ context,
103
+ until = (returnValue) => returnValue,
104
+ ) => {
105
+ const hooks = hookGroups[hookName]
106
+ if (hooks) {
107
+ for (const hook of hooks) {
108
+ const returnValue = callHook(hook, info, context)
109
+ const untilReturnValue = until(returnValue)
110
+ if (untilReturnValue) {
111
+ return untilReturnValue
112
+ }
113
+ }
114
+ }
115
+ return null
116
+ }
117
+ const callAsyncHooksUntil = (hookName, info, context) => {
118
+ const hooks = hookGroups[hookName]
119
+ if (!hooks) {
120
+ return null
121
+ }
122
+ if (hooks.length === 0) {
123
+ return null
124
+ }
125
+ return new Promise((resolve, reject) => {
126
+ const visit = (index) => {
127
+ if (index >= hooks.length) {
128
+ return resolve()
129
+ }
130
+ const hook = hooks[index]
131
+ const returnValue = callAsyncHook(hook, info, context)
132
+ return Promise.resolve(returnValue).then((output) => {
133
+ if (output) {
134
+ return resolve(output)
135
+ }
136
+ return visit(index + 1)
137
+ }, reject)
138
+ }
139
+ visit(0)
140
+ })
141
+ }
142
+
143
+ return {
144
+ services: flatServices,
145
+
146
+ callHooks,
147
+ callHooksUntil,
148
+ callAsyncHooksUntil,
149
+
150
+ getCurrentService: () => currentService,
151
+ getCurrentHookName: () => currentHookName,
152
+ }
153
+ }
154
+
155
+ const flattenAndFilterServices = (services) => {
156
+ const flatServices = []
157
+ const visitServiceEntry = (serviceEntry) => {
158
+ if (Array.isArray(serviceEntry)) {
159
+ serviceEntry.forEach((value) => visitServiceEntry(value))
160
+ return
161
+ }
162
+ if (typeof serviceEntry === "object" && serviceEntry !== null) {
163
+ if (!serviceEntry.name) {
164
+ serviceEntry.name = "anonymous"
165
+ }
166
+ flatServices.push(serviceEntry)
167
+ return
168
+ }
169
+ throw new Error(`services must be objects, got ${serviceEntry}`)
170
+ }
171
+ services.forEach((serviceEntry) => visitServiceEntry(serviceEntry))
172
+ return flatServices
173
+ }