@jsenv/server 12.0.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,8 +92,8 @@ import { startServer } from "@jsenv/server"
92
92
 
93
93
  await startServer({
94
94
  protocol: "https",
95
- serverCertificate: readFileSyncAsString("./server.crt"),
96
- serverCertificatePrivateKey: readFileSyncAsString("./server.key"),
95
+ certificate: readFileSyncAsString("./server.crt"),
96
+ privateKey: readFileSyncAsString("./server.key"),
97
97
  allowHttpRequestOnHttps: true,
98
98
  requestToResponse: (request) => {
99
99
  const clientUsesHttp = request.origin.startsWith("http:")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "12.0.0",
3
+ "version": "12.3.0",
4
4
  "description": "Write your Node.js server using pure functions",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/jsenv/server"
14
14
  },
15
15
  "engines": {
16
- "node": ">=16.12.0"
16
+ "node": ">=16.13.0"
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public"
@@ -31,45 +31,40 @@
31
31
  "/main.js"
32
32
  ],
33
33
  "scripts": {
34
- "eslint-check": "node ./node_modules/eslint/bin/eslint.js . --ext=.js,.mjs",
35
- "importmap-generate": "node ./script/importmap/generate_importmap.mjs",
36
- "measure-performances": "node --expose-gc ./script/performance/generate_performance_report.mjs --log",
37
- "test": "node --unhandled-rejections=strict ./script/test/test.mjs",
34
+ "importmap": "node ./script/importmap/importmap.mjs",
35
+ "eslint": "node ./node_modules/eslint/bin/eslint.js . --ext=.js,.mjs",
36
+ "performance": "node --expose-gc ./script/performance/performance.mjs --log",
37
+ "test": "node ./script/test/test.mjs",
38
38
  "test-with-coverage": "npm run test -- --coverage",
39
39
  "generate-html-snapshots": "node ./test/internal_error/generate_html_snapshot_files.mjs",
40
- "prettier-format": "node ./script/prettier/prettier_format.mjs",
41
- "prettier-format-stage": "npm run prettier-format -- --staged",
42
- "prettier-check": "npm run prettier-format -- --dry-run",
43
- "certificate-authority-install": "node ./script/certificate/certificate_authority_install.mjs",
44
- "playwright-install": "npx playwright install-deps && npx playwright install",
45
- "prepublishOnly": "node ./script/publish/remove_postinstall.mjs",
46
- "postpublish": "node ./script/publish/restore_postinstall.mjs"
40
+ "prettier": "prettier --write .",
41
+ "certificate-install": "node ./script/certificate/certificate_install.mjs",
42
+ "playwright-install": "npx playwright install-deps && npx playwright install"
47
43
  },
48
44
  "dependencies": {
49
- "@jsenv/abort": "4.1.1",
45
+ "@jsenv/abort": "4.1.2",
50
46
  "@jsenv/logger": "4.0.1",
51
- "@jsenv/url-meta": "6.0.1",
47
+ "@jsenv/url-meta": "6.0.3",
52
48
  "node-fetch": "2.6.2",
53
49
  "path-to-regexp": "6.2.0"
54
50
  },
55
51
  "devDependencies": {
56
- "@jsenv/assert": "2.3.2",
57
- "@jsenv/core": "23.10.0",
52
+ "@jsenv/assert": "2.4.1",
53
+ "@jsenv/core": "25.1.0",
58
54
  "@jsenv/eslint-config": "16.0.9",
59
- "@jsenv/filesystem": "2.5.1",
55
+ "@jsenv/filesystem": "2.6.1",
60
56
  "@jsenv/github-release-package": "1.2.3",
61
57
  "@jsenv/https-local": "1.0.3",
62
- "@jsenv/importmap-eslint-resolver": "5.1.2",
63
- "@jsenv/importmap-node-module": "2.7.1",
58
+ "@jsenv/importmap-eslint-resolver": "5.2.2",
59
+ "@jsenv/importmap-node-module": "5.1.0",
64
60
  "@jsenv/package-publish": "1.6.2",
65
61
  "@jsenv/performance-impact": "2.2.1",
66
- "@jsenv/prettier-check-project": "5.6.1",
67
- "eslint": "8.2.0",
68
- "eslint-plugin-import": "2.25.3",
62
+ "eslint": "8.7.0",
63
+ "eslint-plugin-import": "2.25.4",
69
64
  "eventsource": "1.1.0",
70
- "fastify": "3.24.0",
65
+ "fastify": "3.25.3",
71
66
  "fastify-static": "4.5.0",
72
- "playwright": "1.16.3",
73
- "prettier": "2.4.1"
67
+ "playwright": "1.17.2",
68
+ "prettier": "2.5.1"
74
69
  }
75
- }
70
+ }
@@ -1,5 +1,3 @@
1
- import { urlToOrigin } from "@jsenv/filesystem"
2
-
3
1
  export const jsenvAccessControlAllowedHeaders = ["x-requested-with"]
4
2
 
5
3
  export const jsenvAccessControlAllowedMethods = [
@@ -107,7 +105,7 @@ const generateAccessControlHeaders = ({
107
105
  allowedOriginArray.push(headers.origin)
108
106
  vary.push("origin")
109
107
  } else if ("referer" in headers) {
110
- allowedOriginArray.push(urlToOrigin(headers.referer))
108
+ allowedOriginArray.push(new URL(headers.referer).origin)
111
109
  vary.push("referer")
112
110
  } else {
113
111
  allowedOriginArray.push("*")
@@ -0,0 +1,24 @@
1
+ export const memoize = (compute) => {
2
+ let memoized = false
3
+ let memoizedValue
4
+
5
+ const fnWithMemoization = (...args) => {
6
+ if (memoized) {
7
+ return memoizedValue
8
+ }
9
+ // if compute is recursive wait for it to be fully done before storing the value
10
+ // so set memoized boolean after the call
11
+ memoizedValue = compute(...args)
12
+ memoized = true
13
+ return memoizedValue
14
+ }
15
+
16
+ fnWithMemoization.forget = () => {
17
+ const value = memoizedValue
18
+ memoized = false
19
+ memoizedValue = undefined
20
+ return value
21
+ }
22
+
23
+ return fnWithMemoization
24
+ }
@@ -11,13 +11,13 @@ import { listenEvent } from "./listenEvent.js"
11
11
  export const createPolyglotServer = async ({
12
12
  http2 = false,
13
13
  http1Allowed = true,
14
- serverCertificate,
15
- serverCertificatePrivateKey,
14
+ certificate,
15
+ privateKey,
16
16
  }) => {
17
17
  const httpServer = http.createServer()
18
18
  const tlsServer = await createSecureServer({
19
- serverCertificate,
20
- serverCertificatePrivateKey,
19
+ certificate,
20
+ privateKey,
21
21
  http2,
22
22
  http1Allowed,
23
23
  })
@@ -63,24 +63,24 @@ export const createPolyglotServer = async ({
63
63
  // The async part is just to lazyly import "http2" or "https"
64
64
  // so that these module are parsed only if used.
65
65
  const createSecureServer = async ({
66
- serverCertificate,
67
- serverCertificatePrivateKey,
66
+ certificate,
67
+ privateKey,
68
68
  http2,
69
69
  http1Allowed,
70
70
  }) => {
71
71
  if (http2) {
72
72
  const { createSecureServer } = await import("http2")
73
73
  return createSecureServer({
74
- cert: serverCertificate,
75
- key: serverCertificatePrivateKey,
74
+ cert: certificate,
75
+ key: privateKey,
76
76
  allowHTTP1: http1Allowed,
77
77
  })
78
78
  }
79
79
 
80
80
  const { createServer } = await import("https")
81
81
  return createServer({
82
- cert: serverCertificate,
83
- key: serverCertificatePrivateKey,
82
+ cert: certificate,
83
+ key: privateKey,
84
84
  })
85
85
  }
86
86
 
@@ -8,6 +8,7 @@ import {
8
8
  // https://www.html5rocks.com/en/tutorials/eventsource/basics/
9
9
  export const createSSERoom = ({
10
10
  logLevel,
11
+ effect = () => {},
11
12
  // do not keep process alive because of rooms, something else must keep it alive
12
13
  keepProcessAlive = false,
13
14
  keepaliveDuration = 30 * 1000,
@@ -29,6 +30,7 @@ export const createSSERoom = ({
29
30
  let previousEventId = 0
30
31
  let opened = false
31
32
  let interval
33
+ let cleanupEffect = CLEANUP_NOOP
32
34
 
33
35
  const join = (request) => {
34
36
  // should we ensure a given request can join a room only once?
@@ -56,6 +58,14 @@ export const createSSERoom = ({
56
58
  next,
57
59
  complete,
58
60
  }
61
+ if (clients.size === 0) {
62
+ const effectReturnValue = effect()
63
+ if (typeof effectReturnValue === "function") {
64
+ cleanupEffect = effectReturnValue
65
+ } else {
66
+ cleanupEffect = CLEANUP_NOOP
67
+ }
68
+ }
59
69
  clients.add(client)
60
70
  logger.debug(
61
71
  `A client has joined. Number of client in room: ${clients.size}`,
@@ -101,6 +111,10 @@ export const createSSERoom = ({
101
111
 
102
112
  return () => {
103
113
  clients.delete(client)
114
+ if (clients.size === 0) {
115
+ cleanupEffect()
116
+ cleanupEffect = CLEANUP_NOOP
117
+ }
104
118
  logger.debug(`A client left. Number of client in room: ${clients.size}`)
105
119
  }
106
120
  })
@@ -210,6 +224,8 @@ export const createSSERoom = ({
210
224
  return room
211
225
  }
212
226
 
227
+ const CLEANUP_NOOP = () => {}
228
+
213
229
  const requestMap = new Map()
214
230
 
215
231
  const connectRequestAndRoom = (request, room, roomObservable) => {
@@ -1,6 +1,5 @@
1
1
  import http from "node:http"
2
2
  import cluster from "node:cluster"
3
- import { memoize } from "@jsenv/filesystem"
4
3
  import { createDetailedMessage, createLogger } from "@jsenv/logger"
5
4
  import {
6
5
  Abort,
@@ -23,6 +22,7 @@ import { listen, stopListening } from "./internal/listen.js"
23
22
  import { composeTwoResponses } from "./internal/response_composition.js"
24
23
  import { listenRequest } from "./internal/listenRequest.js"
25
24
  import { listenServerConnectionError } from "./internal/listenServerConnectionError.js"
25
+ import { memoize } from "./internal/memoize.js"
26
26
  import { jsenvServerInternalErrorToResponse } from "./jsenvServerInternalErrorToResponse.js"
27
27
  import {
28
28
  STOP_REASON_INTERNAL_ERROR,
@@ -47,8 +47,8 @@ export const startServer = async ({
47
47
  ip = "0.0.0.0", // will it work on windows ? https://github.com/nodejs/node/issues/14900
48
48
  port = 0, // assign a random available port
49
49
  portHint,
50
- serverCertificatePrivateKey,
51
- serverCertificate,
50
+ privateKey,
51
+ certificate,
52
52
 
53
53
  // when inside a worker, we should not try to stop server on SIGINT
54
54
  // otherwise it can create an EPIPE error while primary process tries
@@ -74,11 +74,11 @@ export const startServer = async ({
74
74
  throw new Error(`protocol must be http or https, got ${protocol}`)
75
75
  }
76
76
  if (protocol === "https") {
77
- if (!serverCertificate) {
78
- throw new Error(`missing serverCertificate for https server`)
77
+ if (!certificate) {
78
+ throw new Error(`missing certificate for https server`)
79
79
  }
80
- if (!serverCertificatePrivateKey) {
81
- throw new Error(`missing serverCertificatePrivateKey for https server`)
80
+ if (!privateKey) {
81
+ throw new Error(`missing privateKey for https server`)
82
82
  }
83
83
  }
84
84
  if (http2 && protocol !== "https") {
@@ -134,8 +134,8 @@ export const startServer = async ({
134
134
  protocol,
135
135
  redirectHttpToHttps,
136
136
  allowHttpRequestOnHttps,
137
- serverCertificate,
138
- serverCertificatePrivateKey,
137
+ certificate,
138
+ privateKey,
139
139
  http2,
140
140
  http1Allowed,
141
141
  })
@@ -332,29 +332,6 @@ export const startServer = async ({
332
332
  const pushResponse = async ({ path, method }, { requestNode }) => {
333
333
  const http2Stream = nodeResponse.stream
334
334
 
335
- if (typeof path !== "string" || path[0] !== "/") {
336
- addRequestLog(requestNode, {
337
- type: "warn",
338
- value: `pushResponse call ignored because path is invalid (must be a string starting with "/", found ${path})`,
339
- })
340
- return
341
- }
342
- if (!request.http2) {
343
- addRequestLog(requestNode, {
344
- type: "warn",
345
- value: `pushResponse call ignored because request is not http2`,
346
- })
347
- return
348
- }
349
- const canPushStream = testCanPushStream(http2Stream)
350
- if (!canPushStream.can) {
351
- addRequestLog(requestNode, {
352
- type: "debug",
353
- value: `Ignoring call to pushResponse because ${canPushStream.reason}`,
354
- })
355
- return
356
- }
357
-
358
335
  // being able to push a stream is nice to have
359
336
  // so when it fails it's not critical
360
337
  const onPushStreamError = (e) => {
@@ -488,19 +465,65 @@ export const startServer = async ({
488
465
  redirectRequest,
489
466
  })
490
467
  if (returnValue) {
491
- requestPlugins.push(returnValue)
468
+ requestPlugins.push({
469
+ pluginName,
470
+ ...returnValue,
471
+ })
492
472
  }
493
473
  }
494
474
  })
495
- let { aborted, error, responseProperties } = responseFromShortcircuit
496
- ? { responseProperties: responseFromShortcircuit }
475
+ const result = responseFromShortcircuit
476
+ ? { returnValue: responseFromShortcircuit }
497
477
  : await generateResponseDescription({
498
478
  requestToResponse,
499
479
  request,
500
- pushResponse: ({ path, method }) => {
480
+ pushResponse: async ({ path, method }) => {
481
+ if (typeof path !== "string" || path[0] !== "/") {
482
+ addRequestLog(requestNode, {
483
+ type: "warn",
484
+ value: `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
485
+ })
486
+ return
487
+ }
488
+ if (!request.http2) {
489
+ addRequestLog(requestNode, {
490
+ type: "warn",
491
+ value: `response push ignored because request is not http2`,
492
+ })
493
+ return
494
+ }
495
+ const canPushStream = testCanPushStream(nodeResponse.stream)
496
+ if (!canPushStream.can) {
497
+ addRequestLog(requestNode, {
498
+ type: "debug",
499
+ value: `response push ignored because ${canPushStream.reason}`,
500
+ })
501
+ return
502
+ }
503
+
504
+ let prevented = false
505
+ const prevent = () => {
506
+ prevented = true
507
+ }
508
+ const preventedByPlugin = requestPlugins.find((requestPlugin) => {
509
+ const { onPushResponse } = requestPlugin
510
+ if (!onPushResponse) {
511
+ return false
512
+ }
513
+ onPushResponse({ path, method }, { prevent })
514
+ return prevented
515
+ })
516
+ if (prevented) {
517
+ addRequestLog(requestNode, {
518
+ type: "debug",
519
+ value: `response push prevented by "${preventedByPlugin.pluginName}" plugin`,
520
+ })
521
+ return
522
+ }
523
+
501
524
  const requestChildNode = { logs: [], children: [] }
502
525
  requestNode.children.push(requestChildNode)
503
- return pushResponse(
526
+ await pushResponse(
504
527
  { path, method },
505
528
  {
506
529
  requestNode: requestChildNode,
@@ -510,65 +533,60 @@ export const startServer = async ({
510
533
  },
511
534
  })
512
535
 
536
+ const { aborted } = result
513
537
  if (aborted) {
514
538
  return ABORTED_RESPONSE_PROPERTIES
515
539
  }
516
-
517
- // internal error, create 500 response
518
- if (error) {
519
- if (
520
- // stopOnInternalError stops server only if requestToResponse generated
521
- // a non controlled error (internal error).
522
- // if requestToResponse gracefully produced a 500 response (it did not throw)
523
- // then we can assume we are still in control of what we are doing
524
- stopOnInternalError
525
- ) {
526
- // il faudrais pouvoir stop que les autres response ?
527
- stop(STOP_REASON_INTERNAL_ERROR)
528
- }
529
-
530
- const propertiesFromError = await serverInternalErrorToResponse(error, {
531
- request,
532
- sendServerInternalErrorDetails,
533
- })
534
- responseProperties = composeTwoResponses(
535
- {
536
- status: 500,
537
- statusText: statusToStatusText(500),
538
- headers: {
539
- // ensure error are not cached
540
- "cache-control": "no-store",
541
- "content-type": "text/plain",
540
+ const readResponseProperties = async () => {
541
+ const { error } = result
542
+ // internal error, create 500 response
543
+ if (error) {
544
+ if (
545
+ // stopOnInternalError stops server only if requestToResponse generated
546
+ // a non controlled error (internal error).
547
+ // if requestToResponse gracefully produced a 500 response (it did not throw)
548
+ // then we can assume we are still in control of what we are doing
549
+ stopOnInternalError
550
+ ) {
551
+ // il faudrais pouvoir stop que les autres response ?
552
+ stop(STOP_REASON_INTERNAL_ERROR)
553
+ }
554
+ const propertiesFromError = await serverInternalErrorToResponse(
555
+ error,
556
+ {
557
+ request,
558
+ sendServerInternalErrorDetails,
542
559
  },
543
- },
544
- propertiesFromError,
545
- )
546
- addRequestLog(requestNode, {
547
- type: "error",
548
- value: createDetailedMessage(
549
- `internal error while handling request`,
560
+ )
561
+ addRequestLog(requestNode, {
562
+ type: "error",
563
+ value: createDetailedMessage(
564
+ `internal error while handling request`,
565
+ {
566
+ "error stack": error.stack,
567
+ },
568
+ ),
569
+ })
570
+ return composeTwoResponses(
550
571
  {
551
- "error stack": error.stack,
572
+ status: 500,
573
+ statusText: statusToStatusText(500),
574
+ headers: {
575
+ // ensure error are not cached
576
+ "cache-control": "no-store",
577
+ "content-type": "text/plain",
578
+ },
552
579
  },
553
- ),
554
- })
555
- } else {
556
- const {
557
- status = 501,
558
- statusText = statusToStatusText(status),
559
- headers = {},
560
- body,
561
- ...rest
562
- } = responseProperties || {}
563
- responseProperties = {
564
- status,
565
- statusText,
566
- headers,
567
- body,
568
- ...rest,
580
+ propertiesFromError,
581
+ )
569
582
  }
583
+ const responseProperties = normalizeResponseProperties(
584
+ result.returnValue,
585
+ )
586
+ return responseProperties
570
587
  }
571
588
 
589
+ let responseProperties = await readResponseProperties()
572
590
  // call every plugin "onResponse" hook
573
591
  requestPlugins.forEach((requestPlugin) => {
574
592
  const { onResponse } = requestPlugin
@@ -582,7 +600,6 @@ export const startServer = async ({
582
600
  }
583
601
  }
584
602
  })
585
-
586
603
  if (
587
604
  request.method !== "HEAD" &&
588
605
  responseProperties.headers["content-length"] > 0 &&
@@ -593,7 +610,6 @@ export const startServer = async ({
593
610
  value: `content-length header is ${responseProperties.headers["content-length"]} but body is empty`,
594
611
  })
595
612
  }
596
-
597
613
  return responseProperties
598
614
  }
599
615
 
@@ -701,8 +717,8 @@ const createNodeServer = async ({
701
717
  protocol,
702
718
  redirectHttpToHttps,
703
719
  allowHttpRequestOnHttps,
704
- serverCertificate,
705
- serverCertificatePrivateKey,
720
+ certificate,
721
+ privateKey,
706
722
  http2,
707
723
  http1Allowed,
708
724
  }) => {
@@ -712,8 +728,8 @@ const createNodeServer = async ({
712
728
 
713
729
  if (redirectHttpToHttps || allowHttpRequestOnHttps) {
714
730
  return createPolyglotServer({
715
- serverCertificate,
716
- serverCertificatePrivateKey,
731
+ certificate,
732
+ privateKey,
717
733
  http2,
718
734
  http1Allowed,
719
735
  })
@@ -721,11 +737,43 @@ const createNodeServer = async ({
721
737
 
722
738
  const { createServer } = await import("https")
723
739
  return createServer({
724
- cert: serverCertificate,
725
- key: serverCertificatePrivateKey,
740
+ cert: certificate,
741
+ key: privateKey,
726
742
  })
727
743
  }
728
744
 
745
+ const normalizeResponseProperties = (returnValue) => {
746
+ if (returnValue && returnValue.constructor.name === "Response") {
747
+ const responseHeaders = {}
748
+ const headersToIgnore = ["connection"]
749
+ returnValue.headers.forEach((value, name) => {
750
+ if (!headersToIgnore.includes(name)) {
751
+ responseHeaders[name] = value
752
+ }
753
+ })
754
+ return {
755
+ status: returnValue.status,
756
+ statusText: returnValue.statusText,
757
+ headers: responseHeaders,
758
+ body: returnValue.body, // node-fetch assumed
759
+ }
760
+ }
761
+ const {
762
+ status = 501,
763
+ statusText = statusToStatusText(status),
764
+ headers = {},
765
+ body,
766
+ ...rest
767
+ } = returnValue || {}
768
+ return {
769
+ status,
770
+ statusText,
771
+ headers,
772
+ body,
773
+ ...rest,
774
+ }
775
+ }
776
+
729
777
  const generateResponseDescription = async ({
730
778
  requestToResponse,
731
779
  request,
@@ -736,7 +784,7 @@ const generateResponseDescription = async ({
736
784
  pushResponse,
737
785
  })
738
786
  return {
739
- responseProperties: returnValue,
787
+ returnValue,
740
788
  }
741
789
  } catch (error) {
742
790
  if (error.name === "AbortError" && request.signal.aborted) {