@jsenv/server 17.6.6 → 17.6.8

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 (46) hide show
  1. package/dist/jsenv_server.js +148 -55
  2. package/docs/AI_INSTRUCTIONS.md +29 -0
  3. package/docs/cluster.md +122 -0
  4. package/docs/content_negotiation.md +78 -0
  5. package/docs/cors.md +32 -0
  6. package/docs/demos/cluster/cluster_demo_primary.mjs +31 -0
  7. package/docs/demos/cluster/cluster_demo_primary_watch.mjs +52 -0
  8. package/docs/demos/cluster/cluster_demo_server.mjs +10 -0
  9. package/docs/demos/cors/cors_error_demo.mjs +29 -0
  10. package/docs/demos/https/https_file_demo.mjs +21 -0
  11. package/docs/demos/https/server.crt +24 -0
  12. package/docs/demos/https/server.key +27 -0
  13. package/docs/demos/node_fetch_demo.mjs +14 -0
  14. package/docs/demos/server_sent_events/client.js +6 -0
  15. package/docs/demos/server_sent_events/run.js +2 -0
  16. package/docs/demos/server_sent_events/server.js +20 -0
  17. package/docs/demos/websocket/client.html +16 -0
  18. package/docs/demos/websocket/server.js +26 -0
  19. package/docs/handling_errors.md +75 -0
  20. package/docs/handling_requests.md +201 -0
  21. package/docs/https.md +77 -0
  22. package/docs/lifecycle.md +52 -0
  23. package/docs/plugins.md +53 -0
  24. package/docs/screenshots/500.png +0 -0
  25. package/docs/screenshots/500_expanded.png +0 -0
  26. package/docs/screenshots/500_expanded_and_details_enabled.png +0 -0
  27. package/docs/screenshots/devtools_TTFB.png +0 -0
  28. package/docs/screenshots/devtools_server_timing.png +0 -0
  29. package/docs/screenshots/sse-screencast.gif +0 -0
  30. package/docs/screenshots/sse-screencast.mp4 +0 -0
  31. package/docs/security.md +32 -0
  32. package/docs/server_sent_events.md +64 -0
  33. package/docs/server_timing.md +70 -0
  34. package/docs/serving_files.md +64 -0
  35. package/docs/websocket.md +64 -0
  36. package/package.json +4 -3
  37. package/src/interfacing_with_node/from_node_request.js +13 -5
  38. package/src/internal/request_target.js +55 -0
  39. package/src/internal/track_server_pending_requests.js +14 -9
  40. package/src/plugins/cors/server_plugin_cors.js +8 -6
  41. package/src/plugins/filesystem/fetch_file.js +7 -3
  42. package/src/plugins/server_plugins_controller.js +2 -2
  43. package/src/progressive_response.js +1 -1
  44. package/src/router/router.js +9 -5
  45. package/src/server_events.js +5 -3
  46. package/src/start_server.js +29 -21
@@ -21,14 +21,6 @@ export const trackServerPendingRequests = (nodeServer) => {
21
21
  pendingClients.clear();
22
22
  await Promise.all(
23
23
  pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
24
- if (nodeResponse.headersSent === false) {
25
- if (nodeRequest.stream) {
26
- // http2 has no reason phrase
27
- nodeResponse.writeHead(status);
28
- } else {
29
- nodeResponse.writeHead(status, asReasonPhrase(reason));
30
- }
31
- }
32
24
  return new Promise((resolve) => {
33
25
  if (nodeResponse.destroyed) {
34
26
  resolve();
@@ -37,7 +29,20 @@ export const trackServerPendingRequests = (nodeServer) => {
37
29
  nodeResponse.once("close", () => {
38
30
  resolve();
39
31
  });
40
- nodeResponse.destroy();
32
+ if (nodeResponse.headersSent) {
33
+ // a status already sent cannot be replaced: the response is cut
34
+ nodeResponse.destroy();
35
+ return;
36
+ }
37
+ if (nodeRequest.stream) {
38
+ // http2 has no reason phrase, nor a connection header
39
+ nodeResponse.writeHead(status);
40
+ } else {
41
+ nodeResponse.writeHead(status, asReasonPhrase(reason), {
42
+ connection: "close",
43
+ });
44
+ }
45
+ nodeResponse.end();
41
46
  });
42
47
  }),
43
48
  );
@@ -115,9 +115,10 @@ const createAllowedOriginChecker = (allowedOrigins) => {
115
115
  }
116
116
 
117
117
  return {
118
- // when the request origin cannot be reflected back we must still send a
119
- // single valid origin, never a pattern
120
- defaultOrigin: literalOrigins[0] ?? "*",
118
+ // sent when the request origin cannot be reflected back: a single valid
119
+ // origin, never a pattern, and never "*" which would allow every origin.
120
+ // With only patterns there is nothing to send.
121
+ defaultOrigin: literalOrigins[0] ?? null,
121
122
  isAllowed: (origin) => {
122
123
  if (literalOrigins.includes(origin)) {
123
124
  return true;
@@ -161,7 +162,6 @@ const generateAccessControlHeaders = ({
161
162
 
162
163
  // Access-Control-Allow-Origin must be a single value (not a list).
163
164
  // We reflect back the request's origin if it is in the allowed list.
164
- // If no origin matches we fall back to "*" (only when not using credentials).
165
165
  let allowOrigin = null;
166
166
 
167
167
  const requestOrigin = readRequestOrigin(headers);
@@ -213,14 +213,16 @@ const generateAccessControlHeaders = ({
213
213
  }
214
214
 
215
215
  return {
216
- "access-control-allow-origin": allowOrigin,
216
+ ...(allowOrigin === null
217
+ ? {}
218
+ : { "access-control-allow-origin": allowOrigin }),
217
219
  "access-control-allow-methods": allowedMethodArray.join(", "),
218
220
  "access-control-allow-headers": allowedHeaderArray.join(", "),
219
221
  ...(accessControlAllowCredentials
220
222
  ? { "access-control-allow-credentials": true }
221
223
  : {}),
222
224
  "access-control-max-age": accessControlMaxAge,
223
- ...(timingAllowOriginEnabled(request)
225
+ ...(allowOrigin !== null && timingAllowOriginEnabled(request)
224
226
  ? { "timing-allow-origin": allowOrigin }
225
227
  : {}),
226
228
  ...(vary.length ? { vary: vary.join(", ") } : {}),
@@ -50,8 +50,9 @@ export const createFileSystemFetch = (directoryUrl, options) => {
50
50
  * @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
51
51
  * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
52
52
  * @param {Object} [options]
53
- * @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
54
- * as a fallback, for extension-less urls that do not exist (client side routing).
53
+ * @param {string} [options.mainFileRelativeUrl] - File served for the root of `directoryUrl`
54
+ * and, as a fallback, for extension-less urls that do not exist (client side routing).
55
+ * A subdirectory still gets 403 unless `canReadDirectory`.
55
56
  * @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
56
57
  * answer 304 to a matching `if-none-match`.
57
58
  * @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
@@ -112,8 +113,11 @@ export const fetchFileSystem = async (
112
113
  if (request.params && "0" in request.params) {
113
114
  resource = request.params["0"];
114
115
  } else {
115
- resource = request.resource.slice(1);
116
+ resource = request.resource;
116
117
  }
118
+ // the resource is resolved against the served directory, so it must stay a
119
+ // relative reference: a leading slash would resolve to the filesystem root
120
+ resource = resource.replace(/^\/+/, "");
117
121
  const filesystemUrl = new URL(resource, directoryUrlString);
118
122
  const urlString = asUrlString(filesystemUrl);
119
123
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
@@ -14,10 +14,10 @@ const SERVER_PLUGIN_PROPERTIES = {
14
14
  grantPermissions: { type: "hook" },
15
15
  // async (error, { request }) => response | null
16
16
  handleError: { type: "hook" },
17
- // (request, { response, warn }) to look at the response before it is sent
18
- inspectResponse: { type: "hook" },
19
17
  // (request, response) => responseToCompose | null
20
18
  injectResponseProperties: { type: "hook" },
19
+ // (request, { response, warn }) to look at the final response before it is sent
20
+ inspectResponse: { type: "hook" },
21
21
  // ({ reason }) once the server is stopped
22
22
  serverStopped: { type: "hook" },
23
23
  // route descriptors, appended after the routes given to startServer
@@ -8,7 +8,7 @@ import { createObservable } from "./interfacing_with_node/observable.js";
8
8
  *
9
9
  * @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
10
10
  * Receives `write` and `end`. If it returns a function, that function runs
11
- * when the client disconnects before `end` was called (cleanup).
11
+ * once the response is over: after `end()`, or when the client disconnects.
12
12
  * @param {Object} [init]
13
13
  * @param {number} [init.status=200]
14
14
  * @param {string} [init.statusText]
@@ -356,9 +356,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
356
356
  }
357
357
  if (!route.matchMethod(request.method)) {
358
358
  // a 405 asserts the resource exists with other methods: a route
359
- // matching any resource ("GET *") cannot assert that, so it does
360
- // not turn an unknown resource into a 405
361
- if (!route.isFallback && route.resource !== "*") {
359
+ // matching every resource ("GET *", "GET /") names no resource, so
360
+ // it cannot make that assertion and an unknown address stays a 404
361
+ if (!route.isFallback && !route.matchesEveryResource) {
362
362
  wouldHaveMatched.methodSet.add(route.method);
363
363
  }
364
364
  continue;
@@ -659,7 +659,8 @@ const createRoute = ({
659
659
  if (!endpoint || typeof endpoint !== "string") {
660
660
  throw new TypeError(`endpoint must be a string, received ${endpoint}`);
661
661
  }
662
- const [method, resource] = endpoint === "*" ? ["* *"] : endpoint.split(" ");
662
+ const [method, resource] =
663
+ endpoint === "*" ? ["*", "*"] : endpoint.split(" ");
663
664
  if (method !== "*" && !HTTP_METHODS.includes(method)) {
664
665
  throw new TypeError(`"${method}" is not an HTTP method`);
665
666
  }
@@ -709,6 +710,8 @@ const createRoute = ({
709
710
  : (requestResource) => {
710
711
  return resourcePattern.match(requestResource);
711
712
  },
713
+ matchesEveryResource:
714
+ resource === "*" || resourcePattern.matchesEveryResource,
712
715
  matchHeaders:
713
716
  headers === undefined
714
717
  ? () => true
@@ -876,8 +879,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
876
879
  * @param {Object} params - Content negotiation parameters
877
880
  * @param {Array<string>} params.availableMediaTypes - Content types the server can produce
878
881
  * @param {Array<string>} params.availableLanguages - Languages the server can respond with
882
+ * @param {Array<string|number|Function>} params.availableVersions - Versions the server can respond with
879
883
  * @param {Array<string>} params.availableEncodings - Encodings the server supports
880
- * @returns {Response} A 406 Not Acceptable response
884
+ * @returns {Object} 406 response properties
881
885
  */
882
886
  const createNotAcceptableResponse = (
883
887
  request,
@@ -14,7 +14,7 @@ import { WebSocketResponse } from "./web_socket_response.js";
14
14
  * @class
15
15
  * @param {Object} options - Configuration options for the SSE controller
16
16
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
17
- * @param {Boolean} [options.keepProcessAlive=false] - If true, prevents Node.js from exiting while SSE connections are active
17
+ * @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
18
18
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
19
19
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
20
20
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -70,7 +70,7 @@ export class ServerEvents {
70
70
  * @param {Function} producer - Function called when first client connects
71
71
  * @param {Object} [options] - Configuration options for the SSE controller
72
72
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
73
- * @param {Boolean} [options.keepProcessAlive=false] - If true, prevents Node.js from exiting while SSE connections are active
73
+ * @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
74
74
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
75
75
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
76
76
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -139,8 +139,9 @@ const createServerEvents = ({
139
139
  } = {}) => {
140
140
  const logger = createLogger({ logLevel });
141
141
 
142
+ // starts closed: open() below installs the keepalive interval
142
143
  const serverEventSource = {
143
- closed: false,
144
+ closed: true,
144
145
  };
145
146
  const clientArray = [];
146
147
  const eventHistory = createEventHistory(historyLength);
@@ -382,6 +383,7 @@ const createServerEvents = ({
382
383
  close,
383
384
  open,
384
385
  });
386
+ open();
385
387
  return serverEventSource;
386
388
  };
387
389
 
@@ -18,6 +18,7 @@ import { listen, stopListening } from "./internal/listen.js";
18
18
  import { listenEvent } from "./internal/listen_event.js";
19
19
  import { listenRequest } from "./internal/listen_request.js";
20
20
  import { listenServerConnectionError } from "./internal/listen_server_connection_error.js";
21
+ import { readRequestTarget } from "./internal/request_target.js";
21
22
  import { composeTwoResponses } from "./internal/response_composition.js";
22
23
  import { createSecureServer } from "./internal/secure_server.js";
23
24
  import { resolveServerOrigins } from "./internal/server_origins.js";
@@ -116,8 +117,9 @@ const TIMING_NOOP = () => {
116
117
  * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
117
118
  * inside a cluster worker where the primary process is in charge.
118
119
  * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
119
- * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
120
- * "handleError" plugins answered).
120
+ * @param {boolean} [params.stopOnInternalError=false] - Stop as soon as a route throws. The
121
+ * request that threw and every other pending request are answered 500 with no body, the
122
+ * "handleError" plugins' response is not sent.
121
123
  * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
122
124
  * the process alive.
123
125
  * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
@@ -149,8 +151,9 @@ const TIMING_NOOP = () => {
149
151
  * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
150
152
  * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
151
153
  * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
152
- * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
153
- * and defaults to `STOP_REASON_NOT_SPECIFIED`.
154
+ * - `stop(reason)`: answers the pending requests with 503, closes every connection and
155
+ * resolves once done; `reason` can be anything, becomes the 503 status text and
156
+ * defaults to `STOP_REASON_NOT_SPECIFIED`.
154
157
  * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
155
158
  * `STOP_REASON_*` exports or what was given to `stop`).
156
159
  * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
@@ -441,16 +444,16 @@ export const startServer = async ({
441
444
  stopCallbackSet.add(removeConnectionErrorListener);
442
445
 
443
446
  const connectionsTracker = trackServerPendingConnections(nodeServer);
444
- // opened connection must be shutdown before the close event is emitted
445
- stopCallbackSet.add(connectionsTracker.stop);
446
-
447
447
  const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
448
- // ensure pending requests got a response from the server
449
- stopCallbackSet.add((reason) => {
450
- pendingRequestsTracker.stop({
448
+ // The pending requests are answered before any connection is destroyed:
449
+ // destroyed first, their clients would only see a socket error. The
450
+ // connections must still be shut down for the close event to be emitted.
451
+ stopCallbackSet.add(async ({ reason }) => {
452
+ await pendingRequestsTracker.stop({
451
453
  status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
452
454
  reason,
453
455
  });
456
+ await connectionsTracker.stop(reason);
454
457
  });
455
458
 
456
459
  const applyRequestInternalRedirection = (request) => {
@@ -792,7 +795,14 @@ export const startServer = async ({
792
795
 
793
796
  request: {
794
797
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
795
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
798
+ const requestTarget = readRequestTarget(nodeRequest.url);
799
+ if (!requestTarget) {
800
+ nodeResponse.writeHead(400, "Request url is not supported");
801
+ nodeResponse.end();
802
+ return;
803
+ }
804
+ const requestHost =
805
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
796
806
  if (!isHostAllowed(requestHost)) {
797
807
  logger.warn(
798
808
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -803,19 +813,11 @@ export const startServer = async ({
803
813
  }
804
814
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
805
815
  nodeResponse.writeHead(301, {
806
- location: `${serverOrigin}${nodeRequest.url}`,
816
+ location: `${serverOrigin}${requestTarget.resource}`,
807
817
  });
808
818
  nodeResponse.end();
809
819
  return;
810
820
  }
811
- try {
812
- // eslint-disable-next-line no-new
813
- new URL(nodeRequest.url, "http://example.com");
814
- } catch {
815
- nodeResponse.writeHead(400, "Request url is not supported");
816
- nodeResponse.end();
817
- return;
818
- }
819
821
 
820
822
  const [receiveRequestOperation, sendResponseOperation] =
821
823
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -897,7 +899,13 @@ export const startServer = async ({
897
899
  };
898
900
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
899
901
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
900
- const requestHost = nodeRequest.headers.host;
902
+ const requestTarget = readRequestTarget(nodeRequest.url);
903
+ if (!requestTarget) {
904
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
905
+ socket.destroy();
906
+ return;
907
+ }
908
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
901
909
  if (!isHostAllowed(requestHost)) {
902
910
  logger.warn(
903
911
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,