@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
@@ -1150,6 +1150,62 @@ const stringifyHeaderProperty = ({ name, value }) => {
1150
1150
  return `${name}=${value}`;
1151
1151
  };
1152
1152
 
1153
+ /*
1154
+ * A request target is the text between the method and the http version on the
1155
+ * request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
1156
+ * It is a path, not a url reference, and the two must never be confused:
1157
+ * "//wp-includes/x" is an origin-form target whose first path segment is
1158
+ * empty, but `new URL("//wp-includes/x", origin)` reads it as a
1159
+ * protocol-relative reference and turns "wp-includes" into the host. So the
1160
+ * path is assigned onto the origin instead, and what a client sends can never
1161
+ * replace that origin.
1162
+ *
1163
+ * The one form that legitimately names an origin is absolute-form
1164
+ * ("GET http://host/path", sent by proxies and open-proxy scanners); it is
1165
+ * read as such so the origin it names goes through the allowedHosts check
1166
+ * like any other host, instead of reaching the routes unchecked.
1167
+ */
1168
+
1169
+ const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
1170
+
1171
+ /**
1172
+ * @param {string} target the raw request target
1173
+ * @returns {{ origin: string|null, host: string|null, resource: string }|null}
1174
+ * null when the target cannot be read; the server answers 400
1175
+ */
1176
+ const readRequestTarget = (target) => {
1177
+ if (!ABSOLUTE_FORM_REGEX.test(target)) {
1178
+ // origin-form ("/path?query"), asterisk-form ("*") or authority-form
1179
+ return { origin: null, host: null, resource: target };
1180
+ }
1181
+ if (!URL.canParse(target)) {
1182
+ return null;
1183
+ }
1184
+ const urlObject = new URL(target);
1185
+ if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
1186
+ return null;
1187
+ }
1188
+ return {
1189
+ origin: urlObject.origin,
1190
+ host: urlObject.host,
1191
+ resource: `${urlObject.pathname}${urlObject.search}`,
1192
+ };
1193
+ };
1194
+
1195
+ // the search of baseUrl is dropped: the resource carries its own
1196
+ const resourceToUrlObject = (resource, baseUrl) => {
1197
+ const urlObject = new URL(baseUrl);
1198
+ const searchSeparatorIndex = resource.indexOf("?");
1199
+ if (searchSeparatorIndex === -1) {
1200
+ urlObject.pathname = resource;
1201
+ urlObject.search = "";
1202
+ } else {
1203
+ urlObject.pathname = resource.slice(0, searchSeparatorIndex);
1204
+ urlObject.search = resource.slice(searchSeparatorIndex);
1205
+ }
1206
+ return urlObject;
1207
+ };
1208
+
1153
1209
  // https://wicg.github.io/observable/#core-infrastructure
1154
1210
 
1155
1211
  if ("observable" in Symbol === false) {
@@ -1329,8 +1385,13 @@ const fromNodeRequest = (
1329
1385
  nodeRequest.pause();
1330
1386
  const body = observableFromNodeStream(nodeRequest);
1331
1387
 
1388
+ // an absolute-form target names the origin the request is for; the host
1389
+ // header is ignored in that case (RFC 9112 section 3.3)
1390
+ const requestTarget = readRequestTarget(nodeRequest.url);
1332
1391
  let requestOrigin;
1333
- if (nodeRequest.upgrade) {
1392
+ if (requestTarget.origin) {
1393
+ requestOrigin = requestTarget.origin;
1394
+ } else if (nodeRequest.upgrade) {
1334
1395
  requestOrigin = serverOrigin;
1335
1396
  } else if (nodeRequest.authority) {
1336
1397
  requestOrigin = nodeRequest.connection.encrypted
@@ -1463,7 +1524,7 @@ const fromNodeRequest = (
1463
1524
  http2: Boolean(nodeRequest.stream),
1464
1525
  origin: requestOrigin,
1465
1526
  ...getPropertiesFromResource({
1466
- resource: nodeRequest.url,
1527
+ resource: requestTarget.resource,
1467
1528
  baseUrl: requestOrigin,
1468
1529
  }),
1469
1530
  method: nodeRequest.method,
@@ -1690,13 +1751,12 @@ const applyRedirectionToRequest = (
1690
1751
  };
1691
1752
  };
1692
1753
  const getPropertiesFromResource = ({ resource, baseUrl }) => {
1693
- const urlObject = new URL(resource, baseUrl);
1694
- let pathname = urlObject.pathname;
1754
+ const urlObject = resourceToUrlObject(resource, baseUrl);
1695
1755
 
1696
1756
  return {
1697
1757
  url: String(urlObject),
1698
1758
  searchParams: urlObject.searchParams,
1699
- pathname,
1759
+ pathname: urlObject.pathname,
1700
1760
  resource,
1701
1761
  };
1702
1762
  };
@@ -2974,14 +3034,6 @@ const trackServerPendingRequests = (nodeServer) => {
2974
3034
  pendingClients.clear();
2975
3035
  await Promise.all(
2976
3036
  pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
2977
- if (nodeResponse.headersSent === false) {
2978
- if (nodeRequest.stream) {
2979
- // http2 has no reason phrase
2980
- nodeResponse.writeHead(status);
2981
- } else {
2982
- nodeResponse.writeHead(status, asReasonPhrase(reason));
2983
- }
2984
- }
2985
3037
  return new Promise((resolve) => {
2986
3038
  if (nodeResponse.destroyed) {
2987
3039
  resolve();
@@ -2990,7 +3042,20 @@ const trackServerPendingRequests = (nodeServer) => {
2990
3042
  nodeResponse.once("close", () => {
2991
3043
  resolve();
2992
3044
  });
2993
- nodeResponse.destroy();
3045
+ if (nodeResponse.headersSent) {
3046
+ // a status already sent cannot be replaced: the response is cut
3047
+ nodeResponse.destroy();
3048
+ return;
3049
+ }
3050
+ if (nodeRequest.stream) {
3051
+ // http2 has no reason phrase, nor a connection header
3052
+ nodeResponse.writeHead(status);
3053
+ } else {
3054
+ nodeResponse.writeHead(status, asReasonPhrase(reason), {
3055
+ connection: "close",
3056
+ });
3057
+ }
3058
+ nodeResponse.end();
2994
3059
  });
2995
3060
  }),
2996
3061
  );
@@ -3083,7 +3148,7 @@ const getWebSocketHandler = (responseProperties) => {
3083
3148
  * @class
3084
3149
  * @param {Object} options - Configuration options for the SSE controller
3085
3150
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
3086
- * @param {Boolean} [options.keepProcessAlive=false] - If true, prevents Node.js from exiting while SSE connections are active
3151
+ * @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
3087
3152
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
3088
3153
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
3089
3154
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -3139,7 +3204,7 @@ class ServerEvents {
3139
3204
  * @param {Function} producer - Function called when first client connects
3140
3205
  * @param {Object} [options] - Configuration options for the SSE controller
3141
3206
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
3142
- * @param {Boolean} [options.keepProcessAlive=false] - If true, prevents Node.js from exiting while SSE connections are active
3207
+ * @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
3143
3208
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
3144
3209
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
3145
3210
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -3208,8 +3273,9 @@ const createServerEvents = ({
3208
3273
  } = {}) => {
3209
3274
  const logger = createLogger({ logLevel });
3210
3275
 
3276
+ // starts closed: open() below installs the keepalive interval
3211
3277
  const serverEventSource = {
3212
- closed: false,
3278
+ closed: true,
3213
3279
  };
3214
3280
  const clientArray = [];
3215
3281
  const eventHistory = createEventHistory(historyLength);
@@ -3451,6 +3517,7 @@ const createServerEvents = ({
3451
3517
  close,
3452
3518
  open,
3453
3519
  });
3520
+ open();
3454
3521
  return serverEventSource;
3455
3522
  };
3456
3523
 
@@ -4621,10 +4688,10 @@ const SERVER_PLUGIN_PROPERTIES = {
4621
4688
  grantPermissions: { type: "hook" },
4622
4689
  // async (error, { request }) => response | null
4623
4690
  handleError: { type: "hook" },
4624
- // (request, { response, warn }) to look at the response before it is sent
4625
- inspectResponse: { type: "hook" },
4626
4691
  // (request, response) => responseToCompose | null
4627
4692
  injectResponseProperties: { type: "hook" },
4693
+ // (request, { response, warn }) to look at the final response before it is sent
4694
+ inspectResponse: { type: "hook" },
4628
4695
  // ({ reason }) once the server is stopped
4629
4696
  serverStopped: { type: "hook" },
4630
4697
  // route descriptors, appended after the routes given to startServer
@@ -5833,8 +5900,17 @@ const createResourcePattern = (pattern) => {
5833
5900
  }
5834
5901
 
5835
5902
  const patternEndsWithSlash = pathnamePatternString.endsWith("/");
5903
+ // a pattern constraining nothing: every resource matches it, so whoever uses
5904
+ // it cannot name the resource that matched
5905
+ const matchesEveryResource =
5906
+ !searchPattern &&
5907
+ !hashPattern &&
5908
+ (pathnamePatternString === "*" ||
5909
+ pathnamePatternString === "/" ||
5910
+ pathnamePatternString === "/*");
5836
5911
 
5837
5912
  return {
5913
+ matchesEveryResource,
5838
5914
  match: (resource) => {
5839
5915
  const [pathname, search, hash] = resourceToParts(resource);
5840
5916
  let decodedPathname = decodeURIComponent(pathname);
@@ -6477,9 +6553,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6477
6553
  }
6478
6554
  if (!route.matchMethod(request.method)) {
6479
6555
  // a 405 asserts the resource exists with other methods: a route
6480
- // matching any resource ("GET *") cannot assert that, so it does
6481
- // not turn an unknown resource into a 405
6482
- if (!route.isFallback && route.resource !== "*") {
6556
+ // matching every resource ("GET *", "GET /") names no resource, so
6557
+ // it cannot make that assertion and an unknown address stays a 404
6558
+ if (!route.isFallback && !route.matchesEveryResource) {
6483
6559
  wouldHaveMatched.methodSet.add(route.method);
6484
6560
  }
6485
6561
  continue;
@@ -6777,7 +6853,8 @@ const createRoute = ({
6777
6853
  if (!endpoint || typeof endpoint !== "string") {
6778
6854
  throw new TypeError(`endpoint must be a string, received ${endpoint}`);
6779
6855
  }
6780
- const [method, resource] = endpoint === "*" ? ["* *"] : endpoint.split(" ");
6856
+ const [method, resource] =
6857
+ endpoint === "*" ? ["*", "*"] : endpoint.split(" ");
6781
6858
  if (method !== "*" && !HTTP_METHODS.includes(method)) {
6782
6859
  throw new TypeError(`"${method}" is not an HTTP method`);
6783
6860
  }
@@ -6827,6 +6904,8 @@ const createRoute = ({
6827
6904
  : (requestResource) => {
6828
6905
  return resourcePattern.match(requestResource);
6829
6906
  },
6907
+ matchesEveryResource:
6908
+ resource === "*" || resourcePattern.matchesEveryResource,
6830
6909
  matchHeaders:
6831
6910
  headers === undefined
6832
6911
  ? () => true
@@ -6994,8 +7073,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
6994
7073
  * @param {Object} params - Content negotiation parameters
6995
7074
  * @param {Array<string>} params.availableMediaTypes - Content types the server can produce
6996
7075
  * @param {Array<string>} params.availableLanguages - Languages the server can respond with
7076
+ * @param {Array<string|number|Function>} params.availableVersions - Versions the server can respond with
6997
7077
  * @param {Array<string>} params.availableEncodings - Encodings the server supports
6998
- * @returns {Response} A 406 Not Acceptable response
7078
+ * @returns {Object} 406 response properties
6999
7079
  */
7000
7080
  const createNotAcceptableResponse = (
7001
7081
  request,
@@ -7296,8 +7376,9 @@ const TIMING_NOOP = () => {
7296
7376
  * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
7297
7377
  * inside a cluster worker where the primary process is in charge.
7298
7378
  * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
7299
- * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
7300
- * "handleError" plugins answered).
7379
+ * @param {boolean} [params.stopOnInternalError=false] - Stop as soon as a route throws. The
7380
+ * request that threw and every other pending request are answered 500 with no body, the
7381
+ * "handleError" plugins' response is not sent.
7301
7382
  * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
7302
7383
  * the process alive.
7303
7384
  * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
@@ -7329,8 +7410,9 @@ const TIMING_NOOP = () => {
7329
7410
  * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
7330
7411
  * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
7331
7412
  * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
7332
- * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
7333
- * and defaults to `STOP_REASON_NOT_SPECIFIED`.
7413
+ * - `stop(reason)`: answers the pending requests with 503, closes every connection and
7414
+ * resolves once done; `reason` can be anything, becomes the 503 status text and
7415
+ * defaults to `STOP_REASON_NOT_SPECIFIED`.
7334
7416
  * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
7335
7417
  * `STOP_REASON_*` exports or what was given to `stop`).
7336
7418
  * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
@@ -7621,16 +7703,16 @@ const startServer = async ({
7621
7703
  stopCallbackSet.add(removeConnectionErrorListener);
7622
7704
 
7623
7705
  const connectionsTracker = trackServerPendingConnections(nodeServer);
7624
- // opened connection must be shutdown before the close event is emitted
7625
- stopCallbackSet.add(connectionsTracker.stop);
7626
-
7627
7706
  const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
7628
- // ensure pending requests got a response from the server
7629
- stopCallbackSet.add((reason) => {
7630
- pendingRequestsTracker.stop({
7707
+ // The pending requests are answered before any connection is destroyed:
7708
+ // destroyed first, their clients would only see a socket error. The
7709
+ // connections must still be shut down for the close event to be emitted.
7710
+ stopCallbackSet.add(async ({ reason }) => {
7711
+ await pendingRequestsTracker.stop({
7631
7712
  status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
7632
7713
  reason,
7633
7714
  });
7715
+ await connectionsTracker.stop(reason);
7634
7716
  });
7635
7717
 
7636
7718
  const applyRequestInternalRedirection = (request) => {
@@ -7972,7 +8054,14 @@ const startServer = async ({
7972
8054
 
7973
8055
  {
7974
8056
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
7975
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
8057
+ const requestTarget = readRequestTarget(nodeRequest.url);
8058
+ if (!requestTarget) {
8059
+ nodeResponse.writeHead(400, "Request url is not supported");
8060
+ nodeResponse.end();
8061
+ return;
8062
+ }
8063
+ const requestHost =
8064
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
7976
8065
  if (!isHostAllowed(requestHost)) {
7977
8066
  logger.warn(
7978
8067
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -7983,19 +8072,11 @@ const startServer = async ({
7983
8072
  }
7984
8073
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
7985
8074
  nodeResponse.writeHead(301, {
7986
- location: `${serverOrigin}${nodeRequest.url}`,
8075
+ location: `${serverOrigin}${requestTarget.resource}`,
7987
8076
  });
7988
8077
  nodeResponse.end();
7989
8078
  return;
7990
8079
  }
7991
- try {
7992
- // eslint-disable-next-line no-new
7993
- new URL(nodeRequest.url, "http://example.com");
7994
- } catch {
7995
- nodeResponse.writeHead(400, "Request url is not supported");
7996
- nodeResponse.end();
7997
- return;
7998
- }
7999
8080
 
8000
8081
  const [receiveRequestOperation, sendResponseOperation] =
8001
8082
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -8072,7 +8153,13 @@ const startServer = async ({
8072
8153
  };
8073
8154
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
8074
8155
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
8075
- const requestHost = nodeRequest.headers.host;
8156
+ const requestTarget = readRequestTarget(nodeRequest.url);
8157
+ if (!requestTarget) {
8158
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
8159
+ socket.destroy();
8160
+ return;
8161
+ }
8162
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
8076
8163
  if (!isHostAllowed(requestHost)) {
8077
8164
  logger.warn(
8078
8165
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -8399,7 +8486,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8399
8486
  *
8400
8487
  * @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
8401
8488
  * Receives `write` and `end`. If it returns a function, that function runs
8402
- * when the client disconnects before `end` was called (cleanup).
8489
+ * once the response is over: after `end()`, or when the client disconnects.
8403
8490
  * @param {Object} [init]
8404
8491
  * @param {number} [init.status=200]
8405
8492
  * @param {string} [init.statusText]
@@ -8569,9 +8656,10 @@ const createAllowedOriginChecker = (allowedOrigins) => {
8569
8656
  }
8570
8657
 
8571
8658
  return {
8572
- // when the request origin cannot be reflected back we must still send a
8573
- // single valid origin, never a pattern
8574
- defaultOrigin: literalOrigins[0] ?? "*",
8659
+ // sent when the request origin cannot be reflected back: a single valid
8660
+ // origin, never a pattern, and never "*" which would allow every origin.
8661
+ // With only patterns there is nothing to send.
8662
+ defaultOrigin: literalOrigins[0] ?? null,
8575
8663
  isAllowed: (origin) => {
8576
8664
  if (literalOrigins.includes(origin)) {
8577
8665
  return true;
@@ -8615,7 +8703,6 @@ const generateAccessControlHeaders = ({
8615
8703
 
8616
8704
  // Access-Control-Allow-Origin must be a single value (not a list).
8617
8705
  // We reflect back the request's origin if it is in the allowed list.
8618
- // If no origin matches we fall back to "*" (only when not using credentials).
8619
8706
  let allowOrigin = null;
8620
8707
 
8621
8708
  const requestOrigin = readRequestOrigin(headers);
@@ -8667,14 +8754,16 @@ const generateAccessControlHeaders = ({
8667
8754
  }
8668
8755
 
8669
8756
  return {
8670
- "access-control-allow-origin": allowOrigin,
8757
+ ...(allowOrigin === null
8758
+ ? {}
8759
+ : { "access-control-allow-origin": allowOrigin }),
8671
8760
  "access-control-allow-methods": allowedMethodArray.join(", "),
8672
8761
  "access-control-allow-headers": allowedHeaderArray.join(", "),
8673
8762
  ...(accessControlAllowCredentials
8674
8763
  ? { "access-control-allow-credentials": true }
8675
8764
  : {}),
8676
8765
  "access-control-max-age": accessControlMaxAge,
8677
- ...(timingAllowOriginEnabled(request)
8766
+ ...(allowOrigin !== null && timingAllowOriginEnabled(request)
8678
8767
  ? { "timing-allow-origin": allowOrigin }
8679
8768
  : {}),
8680
8769
  ...(vary.length ? { vary: vary.join(", ") } : {}),
@@ -8925,8 +9014,9 @@ const createFileSystemFetch = (directoryUrl, options) => {
8925
9014
  * @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
8926
9015
  * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
8927
9016
  * @param {Object} [options]
8928
- * @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
8929
- * as a fallback, for extension-less urls that do not exist (client side routing).
9017
+ * @param {string} [options.mainFileRelativeUrl] - File served for the root of `directoryUrl`
9018
+ * and, as a fallback, for extension-less urls that do not exist (client side routing).
9019
+ * A subdirectory still gets 403 unless `canReadDirectory`.
8930
9020
  * @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
8931
9021
  * answer 304 to a matching `if-none-match`.
8932
9022
  * @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
@@ -8987,8 +9077,11 @@ const fetchFileSystem = async (
8987
9077
  if (request.params && "0" in request.params) {
8988
9078
  resource = request.params["0"];
8989
9079
  } else {
8990
- resource = request.resource.slice(1);
9080
+ resource = request.resource;
8991
9081
  }
9082
+ // the resource is resolved against the served directory, so it must stay a
9083
+ // relative reference: a leading slash would resolve to the filesystem root
9084
+ resource = resource.replace(/^\/+/, "");
8992
9085
  const filesystemUrl = new URL(resource, directoryUrlString);
8993
9086
  const urlString = asUrlString(filesystemUrl);
8994
9087
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
@@ -0,0 +1,29 @@
1
+ # @jsenv/server for AI agents
2
+
3
+ An http server for Node.js: `startServer({ routes, plugins })` where a route is `{ endpoint: "GET /users/:id", fetch: (request, helpers) => response }`.
4
+
5
+ ## What to read, in order
6
+
7
+ 1. The JSDoc of the export you use — `startServer` documents every option and the shape of a route; `createFileSystemFetch`, `serverPluginCORS`, `serverPluginErrorHandler`, `ServerEvents`, `WebSocketResponse`, `ProgressiveResponse`, `pickContentType`… document theirs. In a project both `node_modules/@jsenv/server/src/` (one file per concern, `index.js` lists the exports) and the bundle `dist/jsenv_server.js` are published with their JSDoc.
8
+ 2. The guideline docs of this directory, one per area:
9
+ - [handling_requests.md](./handling_requests.md) — routes, the `request` object, `helpers`, response formats, what the router answers by itself
10
+ - [handling_errors.md](./handling_errors.md) — a route that throws, `serverPluginErrorHandler`, timeouts
11
+ - [plugins.md](./plugins.md) — the hooks, the built-in plugins
12
+ - [serving_files.md](./serving_files.md) — `createFileSystemFetch`
13
+ - [content_negotiation.md](./content_negotiation.md) — `availableMediaTypes` & co
14
+ - [websocket.md](./websocket.md), [server_sent_events.md](./server_sent_events.md) — real time
15
+ - [cors.md](./cors.md), [https.md](./https.md), [server_timing.md](./server_timing.md), [lifecycle.md](./lifecycle.md), [security.md](./security.md), [cluster.md](./cluster.md)
16
+ 3. The tests in `tests/` of the package (only in the jsenv/core repository): one directory per feature, each test starts a real server and requests it.
17
+
18
+ ## Things that are easy to get wrong
19
+
20
+ - A route responds with its `fetch` function (not `response`, not `handleRequest`). Returning `null`/`undefined` lets the next route try.
21
+ - The request `headers` object has lowercased keys; `request.cookies` is a `Map`; `request.params` holds what `:name` and `*` captured.
22
+ - A plain response object must give `status` (it defaults to 404). `statusMessage` is not `statusText`: it feeds the body of 4xx/5xx responses.
23
+ - Without a plugin implementing `handleError` (`serverPluginErrorHandler`), a route that throws makes the process exit.
24
+ - A plugin property that is not a hook name is ignored with a console warning (`Unexpected "handleRequest" property`): when a hook seems dead, read the console.
25
+ - `ServerEvents` broadcasts to every connected client; there is no per-client send. A client → server message goes through a plain route (POST), or a `WebSocketResponse` when both directions are needed.
26
+ - `canExposeSensitiveData: true` is for development on the developer's machine only (see [security.md](./security.md)).
27
+ - A request whose `host` header is not a name of the server gets 403: a custom hostname goes in `hostname` or `allowedHosts`.
28
+ - `request.json()` and the other readers answer 413 past 1 MiB (`requestBodyMaxSize`, or `{ maxSize }` per call).
29
+ - Running from the jsenv/core repository: `node --conditions=dev:jsenv <file>`, otherwise the stale `dist/` bundle is used.
@@ -0,0 +1,122 @@
1
+ # Cluster
2
+
3
+ One server per CPU core, with Node's cluster module: the workers share the port.
4
+
5
+ _cluster_demo_server.mjs:_
6
+
7
+ ```js
8
+ import { startServer, createFileSystemFetch } from "@jsenv/server";
9
+
10
+ await startServer({
11
+ routes: [
12
+ {
13
+ endpoint: "GET *",
14
+ fetch: createFileSystemFetch(import.meta.resolve("./")),
15
+ },
16
+ ],
17
+ });
18
+ ```
19
+
20
+ _cluster_demo_primary.mjs_
21
+
22
+ ```js
23
+ import cluster from "node:cluster";
24
+ import { cpus } from "node:os";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const numCPUs = cpus().length;
28
+
29
+ cluster.setupPrimary({
30
+ exec: fileURLToPath(new URL("./cluster_demo_server.mjs", import.meta.url)),
31
+ });
32
+
33
+ console.log(`Primary ${process.pid} is running`);
34
+ for (let i = 0; i < numCPUs; i++) {
35
+ cluster.fork();
36
+ }
37
+
38
+ cluster.on("online", (worker) => {
39
+ console.log(`Worker ${worker.process.pid} is online`);
40
+ });
41
+
42
+ cluster.on("exit", (worker, code, signal) => {
43
+ // https://nodejs.org/api/cluster.html#workerexitedafterdisconnect
44
+ if (worker.exitedAfterDisconnect) {
45
+ console.log(
46
+ `Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
47
+ );
48
+ console.log(`Starting a new worker`);
49
+ cluster.fork();
50
+ }
51
+ });
52
+ ```
53
+
54
+ ```console
55
+ ❯ node ./docs/demos/cluster/cluster_demo_primary.mjs
56
+ Primary 42398 is running
57
+ Worker 42399 is online
58
+ Worker 42400 is online
59
+ Worker 42401 is online
60
+ Worker 42402 is online
61
+ server started at http://localhost:60000 (http://192.168.1.15:60000)
62
+ server started at http://localhost:60000 (http://192.168.1.15:60000)
63
+ server started at http://localhost:60000 (http://192.168.1.15:60000)
64
+ server started at http://localhost:60000 (http://192.168.1.15:60000)
65
+ ```
66
+
67
+ ## Restart on file change
68
+
69
+ ```diff
70
+ import cluster from "node:cluster"
71
+ import { cpus } from "node:os"
72
+ import { fileURLToPath } from "node:url"
73
+ + import { registerFileLifecycle } from "@jsenv/filesystem"
74
+
75
+ const numCPUs = cpus().length
76
+
77
+ const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url)
78
+
79
+ cluster.setupPrimary({
80
+ exec: fileURLToPath(workerFileUrl),
81
+ })
82
+
83
+ console.log(`Primary ${process.pid} is running`)
84
+ for (let i = 0; i < numCPUs; i++) {
85
+ cluster.fork()
86
+ }
87
+
88
+ cluster.on("online", (worker) => {
89
+ console.log(`Worker ${worker.process.pid} is online`)
90
+ })
91
+
92
+ cluster.on("exit", (worker, code, signal) => {
93
+ // https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
94
+ if (worker.exitedAfterDisconnect) {
95
+ console.log(
96
+ `Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
97
+ )
98
+ console.log(`Starting a new worker`)
99
+ cluster.fork()
100
+ }
101
+ })
102
+
103
+ + registerFileLifecycle(workerFileUrl, {
104
+ + updated: () => {
105
+ + console.log("Server file updated, restarting servers...")
106
+ +
107
+ + Object.keys(cluster.workers).forEach((workerId) => {
108
+ + const worker = cluster.workers[workerId]
109
+ +
110
+ + // force kill if graceful exit fails
111
+ + const timeout = setTimeout(() => {
112
+ + worker.kill("SIGKILL")
113
+ + }, 5000)
114
+ + worker.once("exit", () => {
115
+ + clearTimeout(timeout)
116
+ + })
117
+ + // try gracefull exit
118
+ + worker.kill()
119
+ + })
120
+ + },
121
+ + })
122
+ ```
@@ -0,0 +1,78 @@
1
+ # Content negotiation
2
+
3
+ A route declares what it can produce; the router picks what the request prefers, sets the `vary` header and answers 406 when nothing fits.
4
+
5
+ ## Media type
6
+
7
+ ```js
8
+ import { startServer } from "@jsenv/server";
9
+
10
+ await startServer({
11
+ routes: [
12
+ {
13
+ endpoint: "GET /hello",
14
+ availableMediaTypes: ["application/json", "text/plain"],
15
+ fetch: (request, { contentNegotiation }) => {
16
+ if (contentNegotiation.mediaType === "application/json") {
17
+ return Response.json({ data: "Hello world" });
18
+ }
19
+ return new Response("Hello world");
20
+ },
21
+ },
22
+ ],
23
+ });
24
+ ```
25
+
26
+ `Accept: text/*` gets text, `Accept: application/json` gets json, `Accept: image/png` gets 406. Without an `accept` header the first media type wins. The media type of a route whose endpoint has an extension (`GET /data.json`) is inferred.
27
+
28
+ The same can be done by hand with `pickContentType(request, availableMediaTypes)`, which returns the media type to use or `null` — then the 406 and the `vary` header are up to the route. Unlike the router, it also returns `null` when the request has no `accept` header.
29
+
30
+ ## Language, version, encoding
31
+
32
+ `availableLanguages` (`accept-language`), `availableVersions` (`accept-version`) and `availableEncodings` (`accept-encoding`) work the same way, and combine:
33
+
34
+ ```js
35
+ {
36
+ endpoint: "GET /hello",
37
+ availableMediaTypes: ["application/json", "text/plain"],
38
+ availableLanguages: ["fr", "en"],
39
+ fetch: (request, { contentNegotiation }) => {
40
+ const message =
41
+ contentNegotiation.language === "fr"
42
+ ? "Bonjour tout le monde"
43
+ : "Hello world";
44
+ const headers = { "content-language": contentNegotiation.language };
45
+ if (contentNegotiation.mediaType === "application/json") {
46
+ return Response.json({ data: message }, { headers });
47
+ }
48
+ return new Response(message, { headers });
49
+ },
50
+ }
51
+ ```
52
+
53
+ ```js
54
+ import { gzipSync } from "node:zlib";
55
+
56
+ {
57
+ endpoint: "GET /hello",
58
+ availableEncodings: ["gzip", "identity"],
59
+ fetch: (request, { contentNegotiation }) => {
60
+ if (contentNegotiation.encoding === "gzip") {
61
+ return new Response(gzipSync(Buffer.from("Hello world!")), {
62
+ headers: { "content-encoding": "gzip" },
63
+ });
64
+ }
65
+ return new Response("Hello world!");
66
+ },
67
+ }
68
+ ```
69
+
70
+ A version can also be a function, `availableVersions: [(version) => version.startsWith("1.")]`.
71
+
72
+ ## When several negotiations fail
73
+
74
+ The 406 explains each failure (media types, languages, versions, encodings) and lists what is available in `available-media-types`, `available-languages`, `available-versions`, `available-encodings` response headers.
75
+
76
+ ## Checking the response
77
+
78
+ The router warns (at the request log level) when the response `content-type`, `content-language`, `content-version` or `content-encoding` is not among what the route declared. `serverPluginResponseAcceptanceCheck` goes further and warns when a response does not honor what the request accepted, whatever the route declared.