@jsenv/server 12.7.5 → 13.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.
Files changed (28) hide show
  1. package/README.md +66 -81
  2. package/package.json +3 -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/internal/request_factory.js +3 -1
  12. package/src/internal/server-polyglot.js +1 -0
  13. package/src/main.js +11 -13
  14. package/src/serve_directory.js +2 -2
  15. package/src/service_controller.js +174 -0
  16. package/src/{cors/plugin_cors.js → services/cors/jsenv_service_cors.js} +29 -45
  17. package/src/services/error_handler/jsenv_service_error_handler.js +98 -0
  18. package/src/{content_negotiation/checkContentNegotiation.js → services/response_acceptance_check/jsenv_service_response_acceptance_check.js} +16 -7
  19. package/src/services/ressource_aliases/jsenv_service_ressource_aliases.js +40 -0
  20. package/src/sse/{createSSERoom.js → sse_room.js} +18 -16
  21. package/src/start_server.js +962 -0
  22. package/src/content_negotiation/plugin_content_negotiation_check.js +0 -15
  23. package/src/error_to_response_default.js +0 -94
  24. package/src/plugin_request_waiting_check.js +0 -32
  25. package/src/ressource_aliases/plugin_ressource_aliases.js +0 -44
  26. package/src/server_timing/plugin_server_timing.js +0 -28
  27. package/src/service_composition/service_composition.js +0 -55
  28. package/src/startServer.js +0 -868
@@ -0,0 +1,174 @@
1
+ import { timeStart } from "./server_timing/timing_measure.js"
2
+
3
+ const HOOK_NAMES = [
4
+ "serverListening",
5
+ "redirectRequest",
6
+ "handleRequest",
7
+ "handleWebsocket",
8
+ "handleError",
9
+ "onResponsePush",
10
+ "injectResponseHeaders",
11
+ "responseReady",
12
+ "serverStopped",
13
+ ]
14
+
15
+ export const createServiceController = (services) => {
16
+ const flatServices = flattenAndFilterServices(services)
17
+ const hookGroups = {}
18
+
19
+ const addService = (service) => {
20
+ Object.keys(service).forEach((key) => {
21
+ if (key === "name") return
22
+ const isHook = HOOK_NAMES.includes(key)
23
+ if (!isHook) {
24
+ console.warn(
25
+ `Unexpected "${key}" property on "${service.name}" service`,
26
+ )
27
+ }
28
+ const hookName = key
29
+ const hookValue = service[hookName]
30
+ if (hookValue) {
31
+ const group = hookGroups[hookName] || (hookGroups[hookName] = [])
32
+ group.push({
33
+ service,
34
+ name: hookName,
35
+ value: hookValue,
36
+ })
37
+ }
38
+ })
39
+ }
40
+ flatServices.forEach((service) => {
41
+ addService(service)
42
+ })
43
+
44
+ let currentService = null
45
+ let currentHookName = null
46
+ const callHook = (hook, info, context) => {
47
+ const hookFn = hook.value
48
+ if (!hookFn) {
49
+ return null
50
+ }
51
+ currentService = hook.service
52
+ currentHookName = hook.name
53
+ let timeEnd
54
+ if (context && context.timing) {
55
+ timeEnd = timeStart(
56
+ `${currentService.name.replace("jsenv:", "")}.${currentHookName}`,
57
+ )
58
+ }
59
+ let valueReturned = hookFn(info, context)
60
+ if (context && context.timing) {
61
+ Object.assign(context.timing, timeEnd())
62
+ }
63
+ currentService = null
64
+ currentHookName = null
65
+ return valueReturned
66
+ }
67
+ const callAsyncHook = async (hook, info, context) => {
68
+ const hookFn = hook.value
69
+ if (!hookFn) {
70
+ return null
71
+ }
72
+ currentService = hook.service
73
+ currentHookName = hook.name
74
+ let timeEnd
75
+ if (context && context.timing) {
76
+ timeEnd = timeStart(
77
+ `${currentService.name.replace("jsenv:", "")}.${currentHookName}`,
78
+ )
79
+ }
80
+ let valueReturned = await hookFn(info, context)
81
+ if (context && context.timing) {
82
+ Object.assign(context.timing, timeEnd())
83
+ }
84
+ currentService = null
85
+ currentHookName = null
86
+ return valueReturned
87
+ }
88
+
89
+ const callHooks = (hookName, info, context, callback = () => {}) => {
90
+ const hooks = hookGroups[hookName]
91
+ if (hooks) {
92
+ for (const hook of hooks) {
93
+ const returnValue = callHook(hook, info, context)
94
+ if (returnValue) {
95
+ callback(returnValue)
96
+ }
97
+ }
98
+ }
99
+ }
100
+ const callHooksUntil = (
101
+ hookName,
102
+ info,
103
+ context,
104
+ until = (returnValue) => returnValue,
105
+ ) => {
106
+ const hooks = hookGroups[hookName]
107
+ if (hooks) {
108
+ for (const hook of hooks) {
109
+ const returnValue = callHook(hook, info, context)
110
+ const untilReturnValue = until(returnValue)
111
+ if (untilReturnValue) {
112
+ return untilReturnValue
113
+ }
114
+ }
115
+ }
116
+ return null
117
+ }
118
+ const callAsyncHooksUntil = (hookName, info, context) => {
119
+ const hooks = hookGroups[hookName]
120
+ if (!hooks) {
121
+ return null
122
+ }
123
+ if (hooks.length === 0) {
124
+ return null
125
+ }
126
+ return new Promise((resolve, reject) => {
127
+ const visit = (index) => {
128
+ if (index >= hooks.length) {
129
+ return resolve()
130
+ }
131
+ const hook = hooks[index]
132
+ const returnValue = callAsyncHook(hook, info, context)
133
+ return Promise.resolve(returnValue).then((output) => {
134
+ if (output) {
135
+ return resolve(output)
136
+ }
137
+ return visit(index + 1)
138
+ }, reject)
139
+ }
140
+ visit(0)
141
+ })
142
+ }
143
+
144
+ return {
145
+ services: flatServices,
146
+
147
+ callHooks,
148
+ callHooksUntil,
149
+ callAsyncHooksUntil,
150
+
151
+ getCurrentService: () => currentService,
152
+ getCurrentHookName: () => currentHookName,
153
+ }
154
+ }
155
+
156
+ const flattenAndFilterServices = (services) => {
157
+ const flatServices = []
158
+ const visitServiceEntry = (serviceEntry) => {
159
+ if (Array.isArray(serviceEntry)) {
160
+ serviceEntry.forEach((value) => visitServiceEntry(value))
161
+ return
162
+ }
163
+ if (typeof serviceEntry === "object" && serviceEntry !== null) {
164
+ if (!serviceEntry.name) {
165
+ serviceEntry.name = "anonymous"
166
+ }
167
+ flatServices.push(serviceEntry)
168
+ return
169
+ }
170
+ throw new Error(`services must be objects, got ${serviceEntry}`)
171
+ }
172
+ services.forEach((serviceEntry) => visitServiceEntry(serviceEntry))
173
+ return flatServices
174
+ }
@@ -8,7 +8,7 @@ export const jsenvAccessControlAllowedMethods = [
8
8
  "OPTIONS",
9
9
  ]
10
10
 
11
- export const pluginCORS = ({
11
+ export const jsenvServiceCORS = ({
12
12
  accessControlAllowedOrigins = [],
13
13
  accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
14
14
  accessControlAllowedHeaders = jsenvAccessControlAllowedHeaders,
@@ -19,7 +19,7 @@ export const pluginCORS = ({
19
19
  // by default OPTIONS request can be cache for a long time, it's not going to change soon ?
20
20
  // we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
21
21
  accessControlMaxAge = 600,
22
- timingAllowOrigin,
22
+ timingAllowOrigin = false,
23
23
  } = {}) => {
24
24
  // TODO: we should check access control params to throw or warn if we find strange values
25
25
 
@@ -27,56 +27,40 @@ export const pluginCORS = ({
27
27
  accessControlAllowRequestOrigin || accessControlAllowedOrigins.length
28
28
 
29
29
  if (!corsEnabled) {
30
- return {
31
- cors: {},
32
- }
30
+ return []
33
31
  }
34
32
 
35
33
  return {
36
- cors: {
37
- onServerParams: ({ plugins }) => {
38
- if (timingAllowOrigin === undefined && plugins.server_timings) {
39
- timingAllowOrigin = true
40
- }
41
- },
42
-
43
- onRequest: (request, { shortcircuitResponse }) => {
44
- if (request.method === "OPTIONS") {
45
- // when request method is "OPTIONS" we must return a 200 without body
46
- // So we bypass "requestToResponse" in that scenario using shortcircuitResponse
47
- shortcircuitResponse({
48
- status: 200,
49
- headers: {
50
- "content-length": 0,
51
- },
52
- })
53
- }
54
-
55
- if (request.parent) {
56
- return null
57
- }
34
+ name: "jsenv:cors",
58
35
 
36
+ handleRequest: (request) => {
37
+ // when request method is "OPTIONS" we must return a 200 without body
38
+ // So we bypass "requestToResponse" in that scenario using shortcircuitResponse
39
+ if (request.method === "OPTIONS") {
59
40
  return {
60
- onResponse: () => {
61
- const accessControlHeaders = generateAccessControlHeaders({
62
- request,
63
- accessControlAllowedOrigins,
64
- accessControlAllowRequestOrigin,
65
- accessControlAllowedMethods,
66
- accessControlAllowRequestMethod,
67
- accessControlAllowedHeaders,
68
- accessControlAllowRequestHeaders,
69
- accessControlAllowCredentials,
70
- accessControlMaxAge,
71
- timingAllowOrigin,
72
- })
73
-
74
- return {
75
- headers: accessControlHeaders,
76
- }
41
+ status: 200,
42
+ headers: {
43
+ "content-length": 0,
77
44
  },
78
45
  }
79
- },
46
+ }
47
+ return null
48
+ },
49
+
50
+ injectResponseHeaders: (response, { request }) => {
51
+ const accessControlHeaders = generateAccessControlHeaders({
52
+ request,
53
+ accessControlAllowedOrigins,
54
+ accessControlAllowRequestOrigin,
55
+ accessControlAllowedMethods,
56
+ accessControlAllowRequestMethod,
57
+ accessControlAllowedHeaders,
58
+ accessControlAllowRequestHeaders,
59
+ accessControlAllowCredentials,
60
+ accessControlMaxAge,
61
+ timingAllowOrigin,
62
+ })
63
+ return accessControlHeaders
80
64
  },
81
65
  }
82
66
  }
@@ -0,0 +1,98 @@
1
+ import { pickContentType } from "../../content_negotiation/pick_content_type.js"
2
+
3
+ export const jsenvServiceErrorHandler = ({ sendErrorDetails = false } = {}) => {
4
+ return {
5
+ name: "jsenv:error_handler",
6
+ handleError: (serverInternalError, { request }) => {
7
+ const serverInternalErrorIsAPrimitive =
8
+ serverInternalError === null ||
9
+ (typeof serverInternalError !== "object" &&
10
+ typeof serverInternalError !== "function")
11
+ if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
12
+ return serverInternalError.asResponse()
13
+ }
14
+ const dataToSend = serverInternalErrorIsAPrimitive
15
+ ? {
16
+ code: "VALUE_THROWED",
17
+ value: serverInternalError,
18
+ }
19
+ : {
20
+ code: serverInternalError.code || "UNKNOWN_ERROR",
21
+ ...(sendErrorDetails
22
+ ? {
23
+ stack: serverInternalError.stack,
24
+ ...serverInternalError,
25
+ }
26
+ : {}),
27
+ }
28
+
29
+ const availableContentTypes = {
30
+ "text/html": () => {
31
+ const renderHtmlForErrorWithoutDetails = () => {
32
+ return `<p>Details not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).</p>`
33
+ }
34
+
35
+ const renderHtmlForErrorWithDetails = () => {
36
+ if (serverInternalErrorIsAPrimitive) {
37
+ return `<pre>${JSON.stringify(
38
+ serverInternalError,
39
+ null,
40
+ " ",
41
+ )}</pre>`
42
+ }
43
+ return `<pre>${serverInternalError.stack}</pre>`
44
+ }
45
+
46
+ const body = `<!DOCTYPE html>
47
+ <html>
48
+ <head>
49
+ <title>Internal server error</title>
50
+ <meta charset="utf-8" />
51
+ <link rel="icon" href="data:," />
52
+ </head>
53
+
54
+ <body>
55
+ <h1>Internal server error</h1>
56
+ <p>${
57
+ serverInternalErrorIsAPrimitive
58
+ ? `Code inside server has thrown a literal.`
59
+ : `Code inside server has thrown an error.`
60
+ }</p>
61
+ <details>
62
+ <summary>See internal error details</summary>
63
+ ${
64
+ sendErrorDetails
65
+ ? renderHtmlForErrorWithDetails()
66
+ : renderHtmlForErrorWithoutDetails()
67
+ }
68
+ </details>
69
+ </body>
70
+ </html>`
71
+
72
+ return {
73
+ headers: {
74
+ "content-type": "text/html",
75
+ "content-length": Buffer.byteLength(body),
76
+ },
77
+ body,
78
+ }
79
+ },
80
+ "application/json": () => {
81
+ const body = JSON.stringify(dataToSend)
82
+ return {
83
+ headers: {
84
+ "content-type": "application/json",
85
+ "content-length": Buffer.byteLength(body),
86
+ },
87
+ body,
88
+ }
89
+ },
90
+ }
91
+ const bestContentType = pickContentType(
92
+ request,
93
+ Object.keys(availableContentTypes),
94
+ )
95
+ return availableContentTypes[bestContentType || "application/json"]()
96
+ },
97
+ }
98
+ }
@@ -1,14 +1,23 @@
1
- import { negotiateContentType } from "./negotiateContentType.js"
2
- import { negotiateContentLanguage } from "./negotiateContentLanguage.js"
3
- import { negotiateContentEncoding } from "./negotiateContentEncoding.js"
1
+ import { pickContentType } from "@jsenv/server/src/content_negotiation/pick_content_type.js"
2
+ import { pickContentLanguage } from "@jsenv/server/src/content_negotiation/pick_content_language.js"
3
+ import { pickContentEncoding } from "@jsenv/server/src/content_negotiation/pick_content_encoding.js"
4
4
 
5
- export const checkContentNegotiation = (request, response, { warn }) => {
5
+ export const jsenvServiceResponseAcceptanceCheck = () => {
6
+ return {
7
+ name: "jsenv:response_acceptance_check",
8
+ inspectResponse: (request, { response, warn }) => {
9
+ checkResponseAcceptance(request, response, { warn })
10
+ },
11
+ }
12
+ }
13
+
14
+ const checkResponseAcceptance = (request, response, { warn }) => {
6
15
  const requestAcceptHeader = request.headers.accept
7
16
  const responseContentTypeHeader = response.headers["content-type"]
8
17
  if (
9
18
  requestAcceptHeader &&
10
19
  responseContentTypeHeader &&
11
- !negotiateContentType(request, [responseContentTypeHeader])
20
+ !pickContentType(request, [responseContentTypeHeader])
12
21
  ) {
13
22
  warn(`response content type is not in the request accepted content types.
14
23
  --- response content-type header ---
@@ -22,7 +31,7 @@ ${requestAcceptHeader}`)
22
31
  if (
23
32
  requestAcceptLanguageHeader &&
24
33
  responseContentLanguageHeader &&
25
- !negotiateContentLanguage(request, [responseContentLanguageHeader])
34
+ !pickContentLanguage(request, [responseContentLanguageHeader])
26
35
  ) {
27
36
  warn(`response language is not in the request accepted language.
28
37
  --- response content-language header ---
@@ -36,7 +45,7 @@ ${requestAcceptLanguageHeader}`)
36
45
  if (
37
46
  requestAcceptLanguageHeader &&
38
47
  responseContentLanguageHeader &&
39
- !negotiateContentEncoding(request, [responseContentLanguageHeader])
48
+ !pickContentEncoding(request, [responseContentLanguageHeader])
40
49
  ) {
41
50
  warn(`response encoding is not in the request accepted encoding.
42
51
  --- response content-encoding header ---
@@ -0,0 +1,40 @@
1
+ import { URL_META } from "@jsenv/url-meta"
2
+
3
+ export const jsenvServiceRessourceAliases = (ressourceAliases) => {
4
+ const aliases = {}
5
+ Object.keys(ressourceAliases).forEach((key) => {
6
+ aliases[asFileUrl(key)] = asFileUrl(ressourceAliases[key])
7
+ })
8
+ return {
9
+ name: "jsenv:ressource_aliases",
10
+ redirectRequest: (request) => {
11
+ const ressourceBeforeAlias = request.ressource
12
+ const urlAfterAliasing = URL_META.applyAliases({
13
+ url: asFileUrl(request.pathname),
14
+ aliases,
15
+ })
16
+ const ressourceAfterAlias = urlAfterAliasing.slice("file://".length)
17
+ if (ressourceBeforeAlias === ressourceAfterAlias) {
18
+ return null
19
+ }
20
+ const ressource = replaceRessource(
21
+ ressourceBeforeAlias,
22
+ ressourceAfterAlias,
23
+ )
24
+ return { ressource }
25
+ },
26
+ }
27
+ }
28
+
29
+ const asFileUrl = (specifier) => new URL(specifier, "file:///").href
30
+
31
+ const replaceRessource = (ressourceBeforeAlias, newValue) => {
32
+ const urlObject = new URL(ressourceBeforeAlias, "file://")
33
+ const searchSeparatorIndex = newValue.indexOf("?")
34
+ if (searchSeparatorIndex > -1) {
35
+ return newValue // let new value override search params
36
+ }
37
+ urlObject.pathname = newValue
38
+ const ressource = `${urlObject.pathname}${urlObject.search}`
39
+ return ressource
40
+ }
@@ -57,6 +57,7 @@ export const createSSERoom = ({
57
57
  const client = {
58
58
  next,
59
59
  complete,
60
+ request,
60
61
  }
61
62
  if (clients.size === 0) {
62
63
  const effectReturnValue = effect()
@@ -94,7 +95,9 @@ export const createSSERoom = ({
94
95
 
95
96
  // send to everyone
96
97
  if (welcomeEventPublic) {
97
- write(stringifySourceEvent(welcomeEvent))
98
+ sendEventToAllClients(welcomeEvent, {
99
+ history: false,
100
+ })
98
101
  }
99
102
  // send only to this client
100
103
  else {
@@ -148,14 +151,17 @@ export const createSSERoom = ({
148
151
  eventHistory.add(event)
149
152
  }
150
153
 
151
- const sendEvent = (event) => {
152
- if (event.type !== "comment") {
154
+ const sendEventToAllClients = (event, { history = true } = {}) => {
155
+ if (history) {
153
156
  addEventToHistory(event)
154
157
  }
155
158
  logger.debug(
156
159
  `send "${event.type}" event to ${clients.size} client in the room`,
157
160
  )
158
- write(stringifySourceEvent(event))
161
+ const eventString = stringifySourceEvent(event)
162
+ clients.forEach((client) => {
163
+ client.next(eventString)
164
+ })
159
165
  }
160
166
 
161
167
  const getAllEventSince = (id) => {
@@ -166,21 +172,18 @@ export const createSSERoom = ({
166
172
  return events
167
173
  }
168
174
 
169
- const write = (data) => {
170
- clients.forEach((client) => {
171
- client.next(data)
172
- })
173
- }
174
-
175
175
  const keepAlive = () => {
176
176
  // maybe that, when an event occurs, we can delay the keep alive event
177
177
  logger.debug(
178
178
  `send keep alive event, number of client listening event source: ${clients.size}`,
179
179
  )
180
- sendEvent({
181
- type: "comment",
182
- data: new Date().toLocaleTimeString(),
183
- })
180
+ sendEventToAllClients(
181
+ {
182
+ type: "comment",
183
+ data: new Date().toLocaleTimeString(),
184
+ },
185
+ { history: false },
186
+ )
184
187
  }
185
188
 
186
189
  const open = () => {
@@ -209,7 +212,7 @@ export const createSSERoom = ({
209
212
  // - ability to sendEvent to clients in the room
210
213
  // - ability to join the room
211
214
  // - ability to leave the room
212
- sendEvent,
215
+ sendEventToAllClients,
213
216
  join,
214
217
  leave,
215
218
 
@@ -256,7 +259,6 @@ const disconnectRequestFromRoom = (request, room) => {
256
259
  if (!requestInfo) {
257
260
  return
258
261
  }
259
-
260
262
  const { sseProducer, roomObservableMap } = requestInfo
261
263
  const roomObservable = roomObservableMap.get(room)
262
264
  roomObservableMap.delete(room)