@jsenv/server 11.0.0 → 12.2.1

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
@@ -70,7 +70,10 @@ const notFoundService = (request) => {
70
70
  }
71
71
 
72
72
  const server = await startServer({
73
- requestToResponse: composeServices(indexService, notFoundService),
73
+ requestToResponse: composeServices({
74
+ index: indexService,
75
+ otherwise: notFoundService,
76
+ }),
74
77
  })
75
78
 
76
79
  const fetch = await import("node-fetch")
@@ -89,8 +92,8 @@ import { startServer } from "@jsenv/server"
89
92
 
90
93
  await startServer({
91
94
  protocol: "https",
92
- serverCertificate: readFileSyncAsString("./server.crt"),
93
- serverCertificatePrivateKey: readFileSyncAsString("./server.key"),
95
+ certificate: readFileSyncAsString("./server.crt"),
96
+ privateKey: readFileSyncAsString("./server.key"),
94
97
  allowHttpRequestOnHttps: true,
95
98
  requestToResponse: (request) => {
96
99
  const clientUsesHttp = request.origin.startsWith("http:")
package/main.js CHANGED
@@ -5,10 +5,8 @@
5
5
  */
6
6
 
7
7
  export { startServer } from "./src/startServer.js"
8
- export {
9
- composeServices,
10
- composeServicesWithTiming,
11
- } from "./src/service_composition/service_composition.js"
8
+ export { composeServices } from "./src/service_composition/service_composition.js"
9
+ export { setupRoutes } from "./src/service_composition/routing.js"
12
10
  export { jsenvServerInternalErrorToResponse } from "./src/jsenvServerInternalErrorToResponse.js"
13
11
  export { readRequestBody } from "./src/readRequestBody.js"
14
12
  export { fetchFileSystem } from "./src/fetch_filesystem.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "11.0.0",
3
+ "version": "12.2.1",
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": ">=14.17.0"
16
+ "node": ">=16.12.0"
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public"
@@ -25,52 +25,46 @@
25
25
  },
26
26
  "./*": "./*"
27
27
  },
28
+ "main": "./main.js",
28
29
  "files": [
29
30
  "/src/",
30
31
  "/main.js"
31
32
  ],
32
33
  "scripts": {
33
- "eslint-check": "node ./node_modules/eslint/bin/eslint.js . --ext=.js,.mjs",
34
- "importmap-generate": "node ./script/importmap/generate_importmap.mjs",
35
- "measure-performances": "node --expose-gc ./script/performance/generate_performance_report.mjs --log",
36
- "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",
37
38
  "test-with-coverage": "npm run test -- --coverage",
38
39
  "generate-html-snapshots": "node ./test/internal_error/generate_html_snapshot_files.mjs",
39
- "prettier-format": "node ./script/prettier/prettier_format.mjs",
40
- "prettier-format-stage": "npm run prettier-format -- --staged",
41
- "prettier-check": "npm run prettier-format -- --dry-run",
40
+ "prettier": "prettier --write .",
42
41
  "certificate-authority-install": "node ./script/certificate/certificate_authority_install.mjs",
43
- "playwright-install": "npx playwright install-deps && npx playwright install",
44
- "prepublishOnly": "node ./script/publish/remove_postinstall.mjs",
45
- "postpublish": "node ./script/publish/restore_postinstall.mjs"
42
+ "playwright-install": "npx playwright install-deps && npx playwright install"
46
43
  },
47
44
  "dependencies": {
48
- "@jsenv/abort": "4.1.1",
45
+ "@jsenv/abort": "4.1.2",
49
46
  "@jsenv/logger": "4.0.1",
50
47
  "@jsenv/url-meta": "6.0.1",
51
- "node-fetch": "2.6.2"
48
+ "node-fetch": "2.6.2",
49
+ "path-to-regexp": "6.2.0"
52
50
  },
53
51
  "devDependencies": {
54
- "@babel/core": "7.16.0",
55
- "@babel/eslint-parser": "7.16.0",
56
- "@jsenv/assert": "2.3.2",
57
- "@jsenv/codecov-upload": "3.5.0",
58
- "@jsenv/core": "23.7.1",
59
- "@jsenv/eslint-config": "16.0.8",
60
- "@jsenv/filesystem": "2.5.1",
52
+ "@jsenv/assert": "2.4.0",
53
+ "@jsenv/core": "24.5.0",
54
+ "@jsenv/eslint-config": "16.0.9",
55
+ "@jsenv/filesystem": "2.6.0",
61
56
  "@jsenv/github-release-package": "1.2.3",
62
- "@jsenv/https-local": "1.0.2",
63
- "@jsenv/importmap-eslint-resolver": "5.1.2",
64
- "@jsenv/importmap-node-module": "2.4.1",
57
+ "@jsenv/https-local": "1.0.3",
58
+ "@jsenv/importmap-eslint-resolver": "5.2.1",
59
+ "@jsenv/importmap-node-module": "4.0.1",
65
60
  "@jsenv/package-publish": "1.6.2",
66
61
  "@jsenv/performance-impact": "2.2.1",
67
- "@jsenv/prettier-check-project": "5.6.1",
68
- "eslint": "7.32.0",
69
- "eslint-plugin-import": "2.25.2",
62
+ "eslint": "8.4.1",
63
+ "eslint-plugin-import": "2.25.3",
70
64
  "eventsource": "1.1.0",
71
- "fastify": "3.22.1",
72
- "fastify-static": "4.4.2",
73
- "playwright": "1.16.3",
74
- "prettier": "2.4.1"
65
+ "fastify": "3.25.0",
66
+ "fastify-static": "4.5.0",
67
+ "playwright": "1.17.1",
68
+ "prettier": "2.5.1"
75
69
  }
76
- }
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
 
@@ -16,8 +16,6 @@ export const pluginRequestWaitingCheck = ({
16
16
  } = {}) => {
17
17
  return {
18
18
  plugin_request_waiting_check: {
19
- init: () => {},
20
-
21
19
  onRequest: (request, { logger }) => {
22
20
  const timeout = setTimeout(
23
21
  () => requestWaitingCallback({ request, logger, requestWaitingMs }),
@@ -0,0 +1,35 @@
1
+ import { createRequire } from "node:module"
2
+
3
+ const require = createRequire(import.meta.url)
4
+
5
+ export const setupRoutes = (routes) => {
6
+ const { match } = require("path-to-regexp")
7
+
8
+ const candidates = Object.keys(routes).map((pathPattern) => {
9
+ const applyPatternMatching = match(pathPattern, {
10
+ decode: decodeURIComponent,
11
+ })
12
+ return {
13
+ applyPatternMatching,
14
+ requestHandler: routes[pathPattern],
15
+ }
16
+ })
17
+
18
+ return (request, { pushResponse, redirectRequest }) => {
19
+ let result
20
+ const found = candidates.find((candidate) => {
21
+ result = candidate.applyPatternMatching(request.pathname)
22
+ return Boolean(result)
23
+ })
24
+ if (found) {
25
+ return found.requestHandler(
26
+ {
27
+ ...request,
28
+ routeParams: result.params,
29
+ },
30
+ { pushResponse, redirectRequest },
31
+ )
32
+ }
33
+ return null
34
+ }
35
+ }
@@ -2,27 +2,7 @@ import { timeFunction } from "@jsenv/server/src/server_timing/timing_measure.js"
2
2
  import { composeTwoResponses } from "@jsenv/server/src/internal/response_composition.js"
3
3
  import { applyRedirectionToRequest } from "@jsenv/server/src/internal/request_factory.js"
4
4
 
5
- export const composeServices = (...callbacks) => {
6
- return (request, { pushResponse }) => {
7
- const redirectRequest = (requestRedirection) => {
8
- request = applyRedirectionToRequest(request, requestRedirection)
9
- return null
10
- }
11
-
12
- return firstOperationMatching({
13
- array: callbacks,
14
- start: (callback) => callback(request, { pushResponse, redirectRequest }),
15
- predicate: (returnValue) => {
16
- if (returnValue === null || typeof returnValue !== "object") {
17
- return false
18
- }
19
- return true
20
- },
21
- })
22
- }
23
- }
24
-
25
- export const composeServicesWithTiming = (namedServices) => {
5
+ export const composeServices = (namedServices) => {
26
6
  return async (request, { pushResponse }) => {
27
7
  const redirectRequest = (requestRedirection) => {
28
8
  request = applyRedirectionToRequest(request, requestRedirection)
@@ -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) => {
@@ -483,11 +460,15 @@ export const startServer = async ({
483
460
  if (onRequest) {
484
461
  const returnValue = onRequest(request, {
485
462
  warn,
463
+ logger,
486
464
  shortcircuitResponse,
487
465
  redirectRequest,
488
466
  })
489
467
  if (returnValue) {
490
- requestPlugins.push(returnValue)
468
+ requestPlugins.push({
469
+ pluginName,
470
+ ...returnValue,
471
+ })
491
472
  }
492
473
  }
493
474
  })
@@ -496,10 +477,53 @@ export const startServer = async ({
496
477
  : await generateResponseDescription({
497
478
  requestToResponse,
498
479
  request,
499
- 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
+
500
524
  const requestChildNode = { logs: [], children: [] }
501
525
  requestNode.children.push(requestChildNode)
502
- return pushResponse(
526
+ await pushResponse(
503
527
  { path, method },
504
528
  {
505
529
  requestNode: requestChildNode,
@@ -700,8 +724,8 @@ const createNodeServer = async ({
700
724
  protocol,
701
725
  redirectHttpToHttps,
702
726
  allowHttpRequestOnHttps,
703
- serverCertificate,
704
- serverCertificatePrivateKey,
727
+ certificate,
728
+ privateKey,
705
729
  http2,
706
730
  http1Allowed,
707
731
  }) => {
@@ -711,8 +735,8 @@ const createNodeServer = async ({
711
735
 
712
736
  if (redirectHttpToHttps || allowHttpRequestOnHttps) {
713
737
  return createPolyglotServer({
714
- serverCertificate,
715
- serverCertificatePrivateKey,
738
+ certificate,
739
+ privateKey,
716
740
  http2,
717
741
  http1Allowed,
718
742
  })
@@ -720,8 +744,8 @@ const createNodeServer = async ({
720
744
 
721
745
  const { createServer } = await import("https")
722
746
  return createServer({
723
- cert: serverCertificate,
724
- key: serverCertificatePrivateKey,
747
+ cert: certificate,
748
+ key: privateKey,
725
749
  })
726
750
  }
727
751
 
@@ -729,12 +753,10 @@ const generateResponseDescription = async ({
729
753
  requestToResponse,
730
754
  request,
731
755
  pushResponse,
732
- requestFactory,
733
756
  }) => {
734
757
  try {
735
758
  const returnValue = await requestToResponse(request, {
736
759
  pushResponse,
737
- requestFactory,
738
760
  })
739
761
  return {
740
762
  responseProperties: returnValue,