@jsenv/server 10.2.0 → 12.1.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
@@ -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")
package/main.js CHANGED
@@ -5,11 +5,8 @@
5
5
  */
6
6
 
7
7
  export { startServer } from "./src/startServer.js"
8
- export {
9
- composeServices,
10
- composeServicesWithTiming,
11
- redirectRequest,
12
- } 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"
13
10
  export { jsenvServerInternalErrorToResponse } from "./src/jsenvServerInternalErrorToResponse.js"
14
11
  export { readRequestBody } from "./src/readRequestBody.js"
15
12
  export { fetchFileSystem } from "./src/fetch_filesystem.js"
@@ -46,6 +43,7 @@ export { pluginContentNegotiationCheck } from "./src/content_negotiation/plugin_
46
43
  // others
47
44
  export { fetchUrl } from "./src/fetch_url.js"
48
45
  export { composeTwoResponses } from "./src/internal/response_composition.js"
46
+ export { pluginRessourceAliases } from "./src/ressource_aliases/plugin_ressource_aliases.js"
49
47
  export { pluginRequestWaitingCheck } from "./src/plugin_request_waiting_check.js"
50
48
  export { findFreePort } from "./src/internal/listen.js"
51
49
  export { urlToContentType } from "./src/urlToContentType.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "10.2.0",
3
+ "version": "12.1.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": ">=14.17.0"
16
+ "node": ">=16.12.0"
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public"
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "./*": "./*"
27
27
  },
28
+ "main": "./main.js",
28
29
  "files": [
29
30
  "/src/",
30
31
  "/main.js"
@@ -48,28 +49,26 @@
48
49
  "@jsenv/abort": "4.1.1",
49
50
  "@jsenv/logger": "4.0.1",
50
51
  "@jsenv/url-meta": "6.0.1",
51
- "node-fetch": "2.6.2"
52
+ "node-fetch": "2.6.2",
53
+ "path-to-regexp": "6.2.0"
52
54
  },
53
55
  "devDependencies": {
54
- "@babel/core": "7.16.0",
55
- "@babel/eslint-parser": "7.16.0",
56
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",
57
+ "@jsenv/core": "23.10.0",
58
+ "@jsenv/eslint-config": "16.0.9",
60
59
  "@jsenv/filesystem": "2.5.1",
61
60
  "@jsenv/github-release-package": "1.2.3",
62
- "@jsenv/https-local": "1.0.2",
61
+ "@jsenv/https-local": "1.0.3",
63
62
  "@jsenv/importmap-eslint-resolver": "5.1.2",
64
- "@jsenv/importmap-node-module": "2.4.1",
63
+ "@jsenv/importmap-node-module": "2.7.1",
65
64
  "@jsenv/package-publish": "1.6.2",
66
65
  "@jsenv/performance-impact": "2.2.1",
67
66
  "@jsenv/prettier-check-project": "5.6.1",
68
- "eslint": "7.32.0",
69
- "eslint-plugin-import": "2.25.2",
67
+ "eslint": "8.2.0",
68
+ "eslint-plugin-import": "2.25.3",
70
69
  "eventsource": "1.1.0",
71
- "fastify": "3.22.1",
72
- "fastify-static": "4.4.2",
70
+ "fastify": "3.24.0",
71
+ "fastify-static": "4.5.0",
73
72
  "playwright": "1.16.3",
74
73
  "prettier": "2.4.1"
75
74
  }
@@ -1,116 +1,93 @@
1
- import { urlToMeta, normalizeStructuredMetaMap } from "@jsenv/url-meta"
2
-
3
1
  import { nodeStreamToObservable } from "./nodeStreamToObservable.js"
4
2
  import { headersFromObject } from "./headersFromObject.js"
5
3
 
6
- export const createRequestFactory = ({ serverOrigin, filenameAliases }) => {
7
- const aliasMetaMap = normalizeStructuredMetaMap(
8
- { alias: filenameAliases },
9
- "file:///",
10
- )
11
-
12
- const getAlias = (pathname) => {
13
- const { alias } = urlToMeta({
14
- url: `file://${pathname}`,
15
- structuredMetaMap: aliasMetaMap,
16
- })
17
- return alias
4
+ export const fromNodeRequest = (nodeRequest, { serverOrigin, signal }) => {
5
+ const headers = headersFromObject(nodeRequest.headers)
6
+ const body = nodeStreamToObservable(nodeRequest)
7
+
8
+ let requestOrigin
9
+ if (nodeRequest.authority) {
10
+ requestOrigin = nodeRequest.connection.encrypted
11
+ ? `https://${nodeRequest.authority}`
12
+ : `http://${nodeRequest.authority}`
13
+ } else if (nodeRequest.headers.host) {
14
+ requestOrigin = nodeRequest.connection.encrypted
15
+ ? `https://${nodeRequest.headers.host}`
16
+ : `http://${nodeRequest.headers.host}`
17
+ } else {
18
+ requestOrigin = serverOrigin
18
19
  }
19
20
 
20
- const getPropertiesFromRessource = ({ ressource, baseUrl }) => {
21
- const urlObject = new URL(ressource, baseUrl)
22
- let pathname = urlObject.pathname
23
- const alias = getAlias(pathname)
24
- if (alias) {
25
- pathname = replaceFilename(pathname, alias)
26
- ressource = `${pathname}${urlObject.search}`
27
- }
21
+ return Object.freeze({
22
+ signal,
23
+ http2: Boolean(nodeRequest.stream),
24
+ origin: requestOrigin,
25
+ ...getPropertiesFromRessource({
26
+ ressource: nodeRequest.url,
27
+ baseUrl: requestOrigin,
28
+ }),
29
+ method: nodeRequest.method,
30
+ headers,
31
+ body,
32
+ })
33
+ }
28
34
 
29
- return {
30
- url: String(urlObject),
31
- pathname,
32
- ressource,
33
- alias,
34
- }
35
+ export const applyRedirectionToRequest = (
36
+ request,
37
+ { ressource, pathname, ...rest },
38
+ ) => {
39
+ return {
40
+ ...request,
41
+ ...(ressource
42
+ ? getPropertiesFromRessource({
43
+ ressource,
44
+ baseUrl: request.url,
45
+ })
46
+ : pathname
47
+ ? getPropertiesFromPathname({
48
+ pathname,
49
+ baseUrl: request.url,
50
+ })
51
+ : {}),
52
+ ...rest,
35
53
  }
54
+ }
36
55
 
37
- const getPropertiesFromPathname = ({ pathname, baseUrl }) => {
38
- return getPropertiesFromRessource({
39
- ressource: `${pathname}${new URL(baseUrl).search}`,
40
- baseUrl,
41
- })
42
- }
56
+ const getPropertiesFromRessource = ({ ressource, baseUrl }) => {
57
+ const urlObject = new URL(ressource, baseUrl)
58
+ let pathname = urlObject.pathname
43
59
 
44
60
  return {
45
- fromNodeRequest: (nodeRequest, { signal }) => {
46
- const headers = headersFromObject(nodeRequest.headers)
47
- const body = nodeStreamToObservable(nodeRequest)
48
-
49
- let requestOrigin
50
- if (nodeRequest.authority) {
51
- requestOrigin = nodeRequest.connection.encrypted
52
- ? `https://${nodeRequest.authority}`
53
- : `http://${nodeRequest.authority}`
54
- } else if (nodeRequest.headers.host) {
55
- requestOrigin = nodeRequest.connection.encrypted
56
- ? `https://${nodeRequest.headers.host}`
57
- : `http://${nodeRequest.headers.host}`
58
- } else {
59
- requestOrigin = serverOrigin
60
- }
61
-
62
- return Object.freeze({
63
- signal,
64
- http2: Boolean(nodeRequest.stream),
65
- origin: requestOrigin,
66
- ...getPropertiesFromRessource({
67
- ressource: nodeRequest.url,
68
- baseUrl: requestOrigin,
69
- }),
70
- method: nodeRequest.method,
71
- headers,
72
- body,
73
- })
74
- },
75
-
76
- createPushRequest: (request, { signal, pathname, method }) => {
77
- const pushRequest = Object.freeze({
78
- ...request,
79
- parent: request,
80
- signal,
81
- http2: true,
82
- ...(pathname
83
- ? getPropertiesFromPathname({
84
- pathname,
85
- baseUrl: request.url,
86
- })
87
- : {}),
88
- method: method || request.method,
89
- headers: getHeadersInheritedByPushRequest(request),
90
- body: undefined,
91
- })
61
+ url: String(urlObject),
62
+ pathname,
63
+ ressource,
64
+ }
65
+ }
92
66
 
93
- return pushRequest
94
- },
67
+ const getPropertiesFromPathname = ({ pathname, baseUrl }) => {
68
+ return getPropertiesFromRessource({
69
+ ressource: `${pathname}${new URL(baseUrl).search}`,
70
+ baseUrl,
71
+ })
72
+ }
95
73
 
96
- applyRedirectionToRequest: (request, { ressource, pathname, ...rest }) => {
97
- return {
98
- ...request,
99
- ...(ressource
100
- ? getPropertiesFromRessource({
101
- ressource,
102
- baseUrl: request.url,
103
- })
104
- : pathname
105
- ? getPropertiesFromPathname({
106
- pathname,
107
- baseUrl: request.url,
108
- })
109
- : {}),
110
- ...rest,
111
- }
112
- },
113
- }
74
+ export const createPushRequest = (request, { signal, pathname, method }) => {
75
+ const pushRequest = Object.freeze({
76
+ ...request,
77
+ parent: request,
78
+ signal,
79
+ http2: true,
80
+ ...(pathname
81
+ ? getPropertiesFromPathname({
82
+ pathname,
83
+ baseUrl: request.url,
84
+ })
85
+ : {}),
86
+ method: method || request.method,
87
+ headers: getHeadersInheritedByPushRequest(request),
88
+ body: undefined,
89
+ })
90
+ return pushRequest
114
91
  }
115
92
 
116
93
  const getHeadersInheritedByPushRequest = (request) => {
@@ -126,8 +103,3 @@ const getHeadersInheritedByPushRequest = (request) => {
126
103
 
127
104
  return headersInherited
128
105
  }
129
-
130
- const replaceFilename = (pathname, filename) => {
131
- const lastSlashIndex = pathname.lastIndexOf("/")
132
- return `${pathname.slice(0, lastSlashIndex + 1)}${filename}`
133
- }
@@ -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,55 @@
1
+ import { urlToMeta, normalizeStructuredMetaMap } from "@jsenv/url-meta"
2
+
3
+ export const pluginRessourceAliases = (ressourceAliases) => {
4
+ const ressourceAliasMap = {}
5
+ Object.keys(ressourceAliases).forEach((key) => {
6
+ ressourceAliasMap[ressourceAliases[key]] = key
7
+ })
8
+ const structuredMetaMap = normalizeStructuredMetaMap(
9
+ { alias: ressourceAliasMap },
10
+ "file:///",
11
+ )
12
+
13
+ const getRessourceAlias = (pathname) => {
14
+ const { alias } = urlToMeta({
15
+ url: `file://${pathname}`,
16
+ structuredMetaMap,
17
+ })
18
+ return alias
19
+ }
20
+
21
+ return {
22
+ ressource_aliases: {
23
+ onRequest: (request, { redirectRequest }) => {
24
+ const ressourceAlias = getRessourceAlias(request.pathname)
25
+ if (!ressourceAlias) {
26
+ return
27
+ }
28
+
29
+ const urlObjectAfterAlias = replaceUrlRessource(
30
+ request.url,
31
+ ressourceAlias,
32
+ )
33
+ const ressource = `${urlObjectAfterAlias.pathname}${urlObjectAfterAlias.search}`
34
+ redirectRequest({
35
+ ressourceBeforeAlias: request.ressource,
36
+ ressource,
37
+ })
38
+ },
39
+ },
40
+ }
41
+ }
42
+
43
+ const replaceUrlRessource = (url, ressource) => {
44
+ const urlObject = new URL(url)
45
+
46
+ const searchSeparatorIndex = ressource.indexOf("?")
47
+ if (searchSeparatorIndex === -1) {
48
+ urlObject.pathname = ressource
49
+ return urlObject
50
+ }
51
+
52
+ const pathname = ressource.slice(0, searchSeparatorIndex)
53
+ urlObject.pathname = pathname
54
+ return urlObject
55
+ }
@@ -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
+ }
@@ -1,39 +1,13 @@
1
1
  import { timeFunction } from "@jsenv/server/src/server_timing/timing_measure.js"
2
2
  import { composeTwoResponses } from "@jsenv/server/src/internal/response_composition.js"
3
+ import { applyRedirectionToRequest } from "@jsenv/server/src/internal/request_factory.js"
3
4
 
4
- const requestRedirectionSymbol = Symbol("request_redirection")
5
-
6
- export const redirectRequest = (redirection) => {
7
- return {
8
- [requestRedirectionSymbol]: redirection,
9
- }
10
- }
11
-
12
- export const composeServices = (...callbacks) => {
13
- return (request, { pushResponse, requestFactory }) => {
14
- return firstOperationMatching({
15
- array: callbacks,
16
- start: (callback) => callback(request, { pushResponse }),
17
- predicate: (returnValue) => {
18
- if (returnValue === null || typeof returnValue !== "object") {
19
- return false
20
- }
21
- const requestRedirection = returnValue[requestRedirectionSymbol]
22
- if (requestRedirection) {
23
- request = requestFactory.applyRedirectionToRequest(
24
- request,
25
- requestRedirection,
26
- )
27
- return false
28
- }
29
- return true
30
- },
31
- })
32
- }
33
- }
5
+ export const composeServices = (namedServices) => {
6
+ return async (request, { pushResponse }) => {
7
+ const redirectRequest = (requestRedirection) => {
8
+ request = applyRedirectionToRequest(request, requestRedirection)
9
+ }
34
10
 
35
- export const composeServicesWithTiming = (namedServices) => {
36
- return async (request, { pushResponse, requestFactory }) => {
37
11
  const servicesTiming = {}
38
12
  const response = await firstOperationMatching({
39
13
  array: Object.keys(namedServices).map((serviceName) => {
@@ -41,7 +15,7 @@ export const composeServicesWithTiming = (namedServices) => {
41
15
  }),
42
16
  start: async ({ serviceName, serviceFn }) => {
43
17
  const [serviceTiming, value] = await timeFunction(serviceName, () =>
44
- serviceFn(request, { pushResponse }),
18
+ serviceFn(request, { pushResponse, redirectRequest }),
45
19
  )
46
20
  Object.assign(servicesTiming, serviceTiming)
47
21
  return value
@@ -50,14 +24,6 @@ export const composeServicesWithTiming = (namedServices) => {
50
24
  if (returnValue === null || typeof returnValue !== "object") {
51
25
  return false
52
26
  }
53
- const requestRedirection = returnValue[requestRedirectionSymbol]
54
- if (requestRedirection) {
55
- request = requestFactory.applyRedirectionToRequest(
56
- request,
57
- requestRedirection,
58
- )
59
- return false
60
- }
61
27
  return true
62
28
  },
63
29
  })
@@ -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) => {
@@ -11,7 +11,11 @@ import {
11
11
  import { createPolyglotServer } from "./internal/server-polyglot.js"
12
12
  import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js"
13
13
  import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js"
14
- import { createRequestFactory } from "./internal/request_factory.js"
14
+ import {
15
+ fromNodeRequest,
16
+ createPushRequest,
17
+ applyRedirectionToRequest,
18
+ } from "./internal/request_factory.js"
15
19
  import { populateNodeResponse } from "./internal/populateNodeResponse.js"
16
20
  import { colorizeResponseStatus } from "./internal/colorizeResponseStatus.js"
17
21
  import { getServerOrigins } from "./internal/getServerOrigins.js"
@@ -56,7 +60,6 @@ export const startServer = async ({
56
60
  stopOnInternalError = false,
57
61
  keepProcessAlive = true,
58
62
  requestToResponse = () => null,
59
- filenameAliases = {},
60
63
  plugins = {},
61
64
 
62
65
  sendServerInternalErrorDetails = false,
@@ -226,11 +229,6 @@ export const startServer = async ({
226
229
  })
227
230
  })
228
231
 
229
- const requestFactory = createRequestFactory({
230
- serverOrigin,
231
- filenameAliases,
232
- })
233
-
234
232
  const requestCallback = async (nodeRequest, nodeResponse) => {
235
233
  if (!nagle) {
236
234
  nodeRequest.connection.setNoDelay(true)
@@ -268,7 +266,8 @@ export const startServer = async ({
268
266
  return stopCallbackList.add(abort)
269
267
  })
270
268
 
271
- const request = requestFactory.fromNodeRequest(nodeRequest, {
269
+ const request = fromNodeRequest(nodeRequest, {
270
+ serverOrigin,
272
271
  signal: receiveRequestOperation.signal,
273
272
  })
274
273
 
@@ -414,7 +413,7 @@ export const startServer = async ({
414
413
  pushResponseOperation.addAbortSignal(signal)
415
414
  pushResponseOperation.addAbortSignal(abortController.signal)
416
415
 
417
- const pushRequest = requestFactory.createPushRequest(request, {
416
+ const pushRequest = createPushRequest(request, {
418
417
  signal: pushResponseOperation.signal,
419
418
  pathname: path,
420
419
  method,
@@ -476,12 +475,17 @@ export const startServer = async ({
476
475
  const shortcircuitResponse = (value) => {
477
476
  responseFromShortcircuit = value
478
477
  }
478
+ const redirectRequest = (requestRedirection) => {
479
+ request = applyRedirectionToRequest(request, requestRedirection)
480
+ }
479
481
  Object.keys(plugins).forEach((pluginName) => {
480
482
  const { onRequest } = plugins[pluginName]
481
483
  if (onRequest) {
482
484
  const returnValue = onRequest(request, {
483
485
  warn,
486
+ logger,
484
487
  shortcircuitResponse,
488
+ redirectRequest,
485
489
  })
486
490
  if (returnValue) {
487
491
  requestPlugins.push(returnValue)
@@ -504,7 +508,6 @@ export const startServer = async ({
504
508
  },
505
509
  )
506
510
  },
507
- requestFactory,
508
511
  })
509
512
 
510
513
  if (aborted) {
@@ -727,12 +730,10 @@ const generateResponseDescription = async ({
727
730
  requestToResponse,
728
731
  request,
729
732
  pushResponse,
730
- requestFactory,
731
733
  }) => {
732
734
  try {
733
735
  const returnValue = await requestToResponse(request, {
734
736
  pushResponse,
735
- requestFactory,
736
737
  })
737
738
  return {
738
739
  responseProperties: returnValue,