@jsenv/server 14.1.3 → 14.1.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "14.1.3",
3
+ "version": "14.1.5",
4
4
  "description": "Write your Node.js server using pure functions",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -2,7 +2,7 @@ import { Stream, Writable, Readable } from "node:stream"
2
2
  import { createReadStream } from "node:fs"
3
3
 
4
4
  import { isObservable, observableFromValue } from "./observable.js"
5
- import { nodeStreamToObservable } from "./nodeStreamToObservable.js"
5
+ import { observableFromNodeStream } from "./observable_from_node_stream.js"
6
6
 
7
7
  export const normalizeBodyMethods = (body) => {
8
8
  if (isObservable(body)) {
@@ -23,7 +23,7 @@ export const normalizeBodyMethods = (body) => {
23
23
 
24
24
  if (isNodeStream(body)) {
25
25
  return {
26
- asObservable: () => nodeStreamToObservable(body),
26
+ asObservable: () => observableFromNodeStream(body),
27
27
  destroy: () => {
28
28
  body.destroy()
29
29
  },
@@ -61,7 +61,7 @@ export const fileHandleToReadableStream = (fileHandle) => {
61
61
  }
62
62
 
63
63
  const fileHandleToObservable = (fileHandle) => {
64
- return nodeStreamToObservable(fileHandleToReadableStream(fileHandle))
64
+ return observableFromNodeStream(fileHandleToReadableStream(fileHandle))
65
65
  }
66
66
 
67
67
  const isNodeStream = (value) => {
@@ -2,9 +2,12 @@
2
2
  import { Readable } from "node:stream"
3
3
  import { createObservable } from "./observable.js"
4
4
 
5
- const readableStreamLifetimeInSeconds = 120
6
-
7
- export const nodeStreamToObservable = (nodeStream) => {
5
+ export const observableFromNodeStream = (
6
+ nodeStream,
7
+ {
8
+ readableStreamLifetime = 120_000, // 2s
9
+ } = {},
10
+ ) => {
8
11
  const observable = createObservable(({ next, error, complete }) => {
9
12
  if (nodeStream.isPaused()) {
10
13
  nodeStream.resume()
@@ -38,7 +41,9 @@ export const nodeStreamToObservable = (nodeStream) => {
38
41
  // used in the next ${readableStreamLifetimeInSeconds} otherwise destroys it
39
42
  const timeout = setTimeout(() => {
40
43
  process.emitWarning(
41
- `Readable stream not used after ${readableStreamLifetimeInSeconds} seconds. It will be destroyed to release resources`,
44
+ `Readable stream not used after ${
45
+ readableStreamLifetime / 1000
46
+ } seconds. It will be destroyed to release resources`,
42
47
  {
43
48
  CODE: "READABLE_STREAM_TIMEOUT",
44
49
  // url is for http client request
@@ -46,7 +51,7 @@ export const nodeStreamToObservable = (nodeStream) => {
46
51
  },
47
52
  )
48
53
  nodeStream.destroy()
49
- }, readableStreamLifetimeInSeconds * 1000)
54
+ }, readableStreamLifetime)
50
55
  observable.timeout = timeout
51
56
  onceReadableStreamUsedOrClosed(nodeStream, () => {
52
57
  clearTimeout(timeout)
@@ -1,9 +1,14 @@
1
- import { nodeStreamToObservable } from "./nodeStreamToObservable.js"
2
- import { headersFromObject } from "./headersFromObject.js"
1
+ import { observableFromNodeStream } from "./observable_from_node_stream.js"
2
+ import { headersFromObject } from "../internal/headersFromObject.js"
3
3
 
4
- export const fromNodeRequest = (nodeRequest, { serverOrigin, signal }) => {
4
+ export const fromNodeRequest = (
5
+ nodeRequest,
6
+ { serverOrigin, signal, requestBodyLifetime },
7
+ ) => {
5
8
  const headers = headersFromObject(nodeRequest.headers)
6
- const body = nodeStreamToObservable(nodeRequest)
9
+ const body = observableFromNodeStream(nodeRequest, {
10
+ readableStreamLifetime: requestBodyLifetime,
11
+ })
7
12
 
8
13
  let requestOrigin
9
14
  if (nodeRequest.upgrade) {
@@ -4,7 +4,7 @@ import { raceCallbacks } from "@jsenv/abort"
4
4
 
5
5
  import { normalizeBodyMethods } from "./body.js"
6
6
 
7
- export const populateNodeResponse = async (
7
+ export const writeNodeResponse = async (
8
8
  responseStream,
9
9
  { status, statusText, headers, body, bodyEncoding },
10
10
  { signal, ignoreBody, onAbort, onError, onHeadersSent, onEnd } = {},
@@ -170,7 +170,20 @@ const writeHead = (
170
170
  onHeadersSent({ nodeHeaders, status, statusText })
171
171
  return
172
172
  }
173
- responseStream.writeHead(status, statusText, nodeHeaders)
173
+
174
+ try {
175
+ responseStream.writeHead(status, statusText, nodeHeaders)
176
+ } catch (e) {
177
+ if (
178
+ e.code === "ERR_INVALID_CHAR" &&
179
+ e.message.includes("Invalid character in statusMessage")
180
+ ) {
181
+ throw new Error(`Invalid character in statusMessage
182
+ --- status message ---
183
+ ${statusText}`)
184
+ }
185
+ throw e
186
+ }
174
187
  onHeadersSent({ nodeHeaders, status, statusText })
175
188
  }
176
189
 
@@ -3,7 +3,7 @@ import { createLogger } from "@jsenv/log"
3
3
  import {
4
4
  createObservable,
5
5
  createCompositeProducer,
6
- } from "@jsenv/server/src/internal/observable.js"
6
+ } from "@jsenv/server/src/interfacing_with_node/observable.js"
7
7
 
8
8
  // https://www.html5rocks.com/en/tutorials/eventsource/basics/
9
9
  export const createSSERoom = ({
@@ -18,8 +18,8 @@ import {
18
18
  fromNodeRequest,
19
19
  createPushRequest,
20
20
  applyRedirectionToRequest,
21
- } from "./internal/request_factory.js"
22
- import { populateNodeResponse } from "./internal/populateNodeResponse.js"
21
+ } from "./interfacing_with_node/request_factory.js"
22
+ import { writeNodeResponse } from "./interfacing_with_node/write_node_response.js"
23
23
  import {
24
24
  statusToType,
25
25
  colorizeResponseStatus,
@@ -87,6 +87,13 @@ export const startServer = async ({
87
87
  ),
88
88
  )
89
89
  },
90
+ // timeAllocated to start responding to a request
91
+ // after this delay the server will respond with 504
92
+ responseTimeout = 60_000 * 10, // 10s
93
+ // time allocated to server code to start reading the request body
94
+ // after this delay the underlying stream is destroyed, attempting to read it would throw
95
+ // if used the stream stays opened, it's only if the stream is not read at all that it gets destroyed
96
+ requestBodyLifetime = 60_000 * 2, // 2s
90
97
  } = {}) => {
91
98
  const logger = createLogger({ logLevel })
92
99
 
@@ -207,6 +214,7 @@ export const startServer = async ({
207
214
  const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 })
208
215
  serverOrigins.externalip = createOrigin(firstExternalIp)
209
216
  } else if (hostnameInfo.label === "loopback") {
217
+ // nothing
210
218
  } else {
211
219
  serverOrigins.local = createOrigin(hostname)
212
220
  }
@@ -534,7 +542,8 @@ export const startServer = async ({
534
542
  abortController.abort()
535
543
  } else if (!http2Stream.pushAllowed) {
536
544
  abortController.abort()
537
- } else if (responseProperties !== ABORTED_RESPONSE_PROPERTIES) {
545
+ } else if (responseProperties.requestAborted) {
546
+ } else {
538
547
  const responseLength =
539
548
  responseProperties.headers["content-length"] || 0
540
549
  const { effectiveRecvDataLength, remoteWindowSize } =
@@ -608,75 +617,95 @@ export const startServer = async ({
608
617
  let handleRequestReturnValue
609
618
  let errorWhileHandlingRequest = null
610
619
  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
- )
620
+
621
+ let timeout
622
+ const timeoutPromise = new Promise((resolve) => {
623
+ timeout = setTimeout(() => {
624
+ resolve({
625
+ // the correct status code should be 500 because it's
626
+ // we don't really know what takes time
627
+ // in practice it's often because server is trying to reach an other server
628
+ // that is not responding so 504 is more correct
629
+ status: 504,
630
+ statusText: `server timeout after ${
631
+ responseTimeout / 1000
632
+ }s waiting to handle request`,
633
+ })
634
+ }, responseTimeout)
635
+ })
636
+ const handleRequestPromise = serviceController.callAsyncHooksUntil(
637
+ "handleRequest",
638
+ request,
639
+ {
640
+ timing: handleRequestTimings,
641
+ warn,
642
+ pushResponse: async ({ path, method }) => {
643
+ if (typeof path !== "string" || path[0] !== "/") {
644
+ addRequestLog(requestNode, {
645
+ type: "warn",
646
+ value: `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
647
+ })
648
+ return
649
+ }
650
+ if (!request.http2) {
651
+ addRequestLog(requestNode, {
652
+ type: "warn",
653
+ value: `response push ignored because request is not http2`,
654
+ })
655
+ return
656
+ }
657
+ const canPushStream = testCanPushStream(nodeResponse.stream)
658
+ if (!canPushStream.can) {
659
+ addRequestLog(requestNode, {
660
+ type: "debug",
661
+ value: `response push ignored because ${canPushStream.reason}`,
662
+ })
663
+ return
664
+ }
665
+
666
+ let preventedByService = null
667
+ const prevent = () => {
668
+ preventedByService = serviceController.getCurrentService()
669
+ }
670
+ serviceController.callHooksUntil(
671
+ "onResponsePush",
672
+ { path, method },
673
+ {
674
+ request,
675
+ warn,
676
+ prevent,
674
677
  },
675
- },
676
- )
677
- } catch (error) {
678
- errorWhileHandlingRequest = error
678
+ () => preventedByService,
679
+ )
680
+ if (preventedByService) {
681
+ addRequestLog(requestNode, {
682
+ type: "debug",
683
+ value: `response push prevented by "${preventedByService.name}" service`,
684
+ })
685
+ return
686
+ }
687
+
688
+ const requestChildNode = { logs: [], children: [] }
689
+ requestNode.children.push(requestChildNode)
690
+ await pushResponse(
691
+ { path, method },
692
+ {
693
+ requestNode: requestChildNode,
694
+ parentHttp2Stream: nodeResponse.stream,
695
+ },
696
+ )
697
+ },
698
+ },
699
+ )
700
+ try {
701
+ handleRequestReturnValue = await Promise.race([
702
+ timeoutPromise,
703
+ handleRequestPromise,
704
+ ])
705
+ } catch (e) {
706
+ errorWhileHandlingRequest = e
679
707
  }
708
+ clearTimeout(timeout)
680
709
 
681
710
  let responseProperties
682
711
  if (errorWhileHandlingRequest) {
@@ -684,7 +713,7 @@ export const startServer = async ({
684
713
  errorWhileHandlingRequest.name === "AbortError" &&
685
714
  request.signal.aborted
686
715
  ) {
687
- responseProperties = ABORTED_RESPONSE_PROPERTIES
716
+ responseProperties = { requestAborted: true }
688
717
  } else {
689
718
  // internal error, create 500 response
690
719
  if (
@@ -817,7 +846,7 @@ export const startServer = async ({
817
846
  await new Promise((resolve) => setTimeout(resolve))
818
847
  }
819
848
 
820
- await populateNodeResponse(responseStream, responseProperties, {
849
+ await writeNodeResponse(responseStream, responseProperties, {
821
850
  signal,
822
851
  ignoreBody,
823
852
  onAbort: () => {
@@ -926,6 +955,7 @@ export const startServer = async ({
926
955
  const request = fromNodeRequest(nodeRequest, {
927
956
  serverOrigin: websocketOrigin,
928
957
  signal: new AbortController().signal,
958
+ requestBodyLifetime,
929
959
  })
930
960
  serviceController.callAsyncHooksUntil(
931
961
  "handleWebsocket",
@@ -947,6 +977,10 @@ export const startServer = async ({
947
977
  )
948
978
  stopCallbackList.add(removeUpgradeCallback)
949
979
  stopCallbackList.add(() => {
980
+ websocketClients.forEach((websocketClient) => {
981
+ websocketClient.close()
982
+ })
983
+ websocketClients.clear()
950
984
  websocketServer.close()
951
985
  websocketServer = null
952
986
  })
@@ -1034,8 +1068,6 @@ const testCanPushStream = (http2Stream) => {
1034
1068
  }
1035
1069
  }
1036
1070
 
1037
- const ABORTED_RESPONSE_PROPERTIES = {}
1038
-
1039
1071
  const PROCESS_TEARDOWN_EVENTS_MAP = {
1040
1072
  SIGHUP: STOP_REASON_PROCESS_SIGHUP,
1041
1073
  SIGTERM: STOP_REASON_PROCESS_SIGTERM,