@jsenv/server 17.6.7 → 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 (44) hide show
  1. package/dist/jsenv_server.js +49 -35
  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/internal/track_server_pending_requests.js +14 -9
  38. package/src/plugins/cors/server_plugin_cors.js +8 -6
  39. package/src/plugins/filesystem/fetch_file.js +3 -2
  40. package/src/plugins/server_plugins_controller.js +2 -2
  41. package/src/progressive_response.js +1 -1
  42. package/src/router/router.js +4 -2
  43. package/src/server_events.js +5 -3
  44. package/src/start_server.js +12 -10
@@ -3034,14 +3034,6 @@ const trackServerPendingRequests = (nodeServer) => {
3034
3034
  pendingClients.clear();
3035
3035
  await Promise.all(
3036
3036
  pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
3037
- if (nodeResponse.headersSent === false) {
3038
- if (nodeRequest.stream) {
3039
- // http2 has no reason phrase
3040
- nodeResponse.writeHead(status);
3041
- } else {
3042
- nodeResponse.writeHead(status, asReasonPhrase(reason));
3043
- }
3044
- }
3045
3037
  return new Promise((resolve) => {
3046
3038
  if (nodeResponse.destroyed) {
3047
3039
  resolve();
@@ -3050,7 +3042,20 @@ const trackServerPendingRequests = (nodeServer) => {
3050
3042
  nodeResponse.once("close", () => {
3051
3043
  resolve();
3052
3044
  });
3053
- 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();
3054
3059
  });
3055
3060
  }),
3056
3061
  );
@@ -3143,7 +3148,7 @@ const getWebSocketHandler = (responseProperties) => {
3143
3148
  * @class
3144
3149
  * @param {Object} options - Configuration options for the SSE controller
3145
3150
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
3146
- * @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()`
3147
3152
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
3148
3153
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
3149
3154
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -3199,7 +3204,7 @@ class ServerEvents {
3199
3204
  * @param {Function} producer - Function called when first client connects
3200
3205
  * @param {Object} [options] - Configuration options for the SSE controller
3201
3206
  * @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
3202
- * @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()`
3203
3208
  * @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
3204
3209
  * @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
3205
3210
  * @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
@@ -3268,8 +3273,9 @@ const createServerEvents = ({
3268
3273
  } = {}) => {
3269
3274
  const logger = createLogger({ logLevel });
3270
3275
 
3276
+ // starts closed: open() below installs the keepalive interval
3271
3277
  const serverEventSource = {
3272
- closed: false,
3278
+ closed: true,
3273
3279
  };
3274
3280
  const clientArray = [];
3275
3281
  const eventHistory = createEventHistory(historyLength);
@@ -3511,6 +3517,7 @@ const createServerEvents = ({
3511
3517
  close,
3512
3518
  open,
3513
3519
  });
3520
+ open();
3514
3521
  return serverEventSource;
3515
3522
  };
3516
3523
 
@@ -4681,10 +4688,10 @@ const SERVER_PLUGIN_PROPERTIES = {
4681
4688
  grantPermissions: { type: "hook" },
4682
4689
  // async (error, { request }) => response | null
4683
4690
  handleError: { type: "hook" },
4684
- // (request, { response, warn }) to look at the response before it is sent
4685
- inspectResponse: { type: "hook" },
4686
4691
  // (request, response) => responseToCompose | null
4687
4692
  injectResponseProperties: { type: "hook" },
4693
+ // (request, { response, warn }) to look at the final response before it is sent
4694
+ inspectResponse: { type: "hook" },
4688
4695
  // ({ reason }) once the server is stopped
4689
4696
  serverStopped: { type: "hook" },
4690
4697
  // route descriptors, appended after the routes given to startServer
@@ -6846,7 +6853,8 @@ const createRoute = ({
6846
6853
  if (!endpoint || typeof endpoint !== "string") {
6847
6854
  throw new TypeError(`endpoint must be a string, received ${endpoint}`);
6848
6855
  }
6849
- const [method, resource] = endpoint === "*" ? ["* *"] : endpoint.split(" ");
6856
+ const [method, resource] =
6857
+ endpoint === "*" ? ["*", "*"] : endpoint.split(" ");
6850
6858
  if (method !== "*" && !HTTP_METHODS.includes(method)) {
6851
6859
  throw new TypeError(`"${method}" is not an HTTP method`);
6852
6860
  }
@@ -7065,8 +7073,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
7065
7073
  * @param {Object} params - Content negotiation parameters
7066
7074
  * @param {Array<string>} params.availableMediaTypes - Content types the server can produce
7067
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
7068
7077
  * @param {Array<string>} params.availableEncodings - Encodings the server supports
7069
- * @returns {Response} A 406 Not Acceptable response
7078
+ * @returns {Object} 406 response properties
7070
7079
  */
7071
7080
  const createNotAcceptableResponse = (
7072
7081
  request,
@@ -7367,8 +7376,9 @@ const TIMING_NOOP = () => {
7367
7376
  * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
7368
7377
  * inside a cluster worker where the primary process is in charge.
7369
7378
  * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
7370
- * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
7371
- * "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.
7372
7382
  * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
7373
7383
  * the process alive.
7374
7384
  * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
@@ -7400,8 +7410,9 @@ const TIMING_NOOP = () => {
7400
7410
  * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
7401
7411
  * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
7402
7412
  * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
7403
- * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
7404
- * 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`.
7405
7416
  * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
7406
7417
  * `STOP_REASON_*` exports or what was given to `stop`).
7407
7418
  * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
@@ -7692,16 +7703,16 @@ const startServer = async ({
7692
7703
  stopCallbackSet.add(removeConnectionErrorListener);
7693
7704
 
7694
7705
  const connectionsTracker = trackServerPendingConnections(nodeServer);
7695
- // opened connection must be shutdown before the close event is emitted
7696
- stopCallbackSet.add(connectionsTracker.stop);
7697
-
7698
7706
  const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
7699
- // ensure pending requests got a response from the server
7700
- stopCallbackSet.add((reason) => {
7701
- 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({
7702
7712
  status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
7703
7713
  reason,
7704
7714
  });
7715
+ await connectionsTracker.stop(reason);
7705
7716
  });
7706
7717
 
7707
7718
  const applyRequestInternalRedirection = (request) => {
@@ -8475,7 +8486,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8475
8486
  *
8476
8487
  * @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
8477
8488
  * Receives `write` and `end`. If it returns a function, that function runs
8478
- * when the client disconnects before `end` was called (cleanup).
8489
+ * once the response is over: after `end()`, or when the client disconnects.
8479
8490
  * @param {Object} [init]
8480
8491
  * @param {number} [init.status=200]
8481
8492
  * @param {string} [init.statusText]
@@ -8645,9 +8656,10 @@ const createAllowedOriginChecker = (allowedOrigins) => {
8645
8656
  }
8646
8657
 
8647
8658
  return {
8648
- // when the request origin cannot be reflected back we must still send a
8649
- // single valid origin, never a pattern
8650
- 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,
8651
8663
  isAllowed: (origin) => {
8652
8664
  if (literalOrigins.includes(origin)) {
8653
8665
  return true;
@@ -8691,7 +8703,6 @@ const generateAccessControlHeaders = ({
8691
8703
 
8692
8704
  // Access-Control-Allow-Origin must be a single value (not a list).
8693
8705
  // We reflect back the request's origin if it is in the allowed list.
8694
- // If no origin matches we fall back to "*" (only when not using credentials).
8695
8706
  let allowOrigin = null;
8696
8707
 
8697
8708
  const requestOrigin = readRequestOrigin(headers);
@@ -8743,14 +8754,16 @@ const generateAccessControlHeaders = ({
8743
8754
  }
8744
8755
 
8745
8756
  return {
8746
- "access-control-allow-origin": allowOrigin,
8757
+ ...(allowOrigin === null
8758
+ ? {}
8759
+ : { "access-control-allow-origin": allowOrigin }),
8747
8760
  "access-control-allow-methods": allowedMethodArray.join(", "),
8748
8761
  "access-control-allow-headers": allowedHeaderArray.join(", "),
8749
8762
  ...(accessControlAllowCredentials
8750
8763
  ? { "access-control-allow-credentials": true }
8751
8764
  : {}),
8752
8765
  "access-control-max-age": accessControlMaxAge,
8753
- ...(timingAllowOriginEnabled(request)
8766
+ ...(allowOrigin !== null && timingAllowOriginEnabled(request)
8754
8767
  ? { "timing-allow-origin": allowOrigin }
8755
8768
  : {}),
8756
8769
  ...(vary.length ? { vary: vary.join(", ") } : {}),
@@ -9001,8 +9014,9 @@ const createFileSystemFetch = (directoryUrl, options) => {
9001
9014
  * @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
9002
9015
  * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
9003
9016
  * @param {Object} [options]
9004
- * @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
9005
- * 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`.
9006
9020
  * @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
9007
9021
  * answer 304 to a matching `if-none-match`.
9008
9022
  * @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
@@ -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.
package/docs/cors.md ADDED
@@ -0,0 +1,32 @@
1
+ # CORS
2
+
3
+ Cross-origin requests are refused by browsers unless the response carries the access control headers. `serverPluginCORS` adds them to every response, including errors — a 500 without them shows up as a CORS failure in the browser, which hides the actual problem.
4
+
5
+ ```js
6
+ import {
7
+ serverPluginCORS,
8
+ serverPluginErrorHandler,
9
+ startServer,
10
+ } from "@jsenv/server";
11
+
12
+ await startServer({
13
+ plugins: [
14
+ serverPluginErrorHandler(),
15
+ serverPluginCORS({
16
+ accessControlAllowedOrigins: ["https://my-app.example"],
17
+ accessControlAllowRequestMethod: true,
18
+ accessControlAllowRequestHeaders: true,
19
+ accessControlAllowCredentials: true,
20
+ }),
21
+ ],
22
+ });
23
+ ```
24
+
25
+ The plugin stays off (returns no plugin) unless `accessControlAllowedOrigins` lists an origin or `accessControlAllowRequestOrigin` is true. The options are in its JSDoc; the ones worth a word:
26
+
27
+ - `accessControlAllowedOrigins` — the origins allowed to read the responses. `*` stands for any run of characters except `/`, so `"https://pr-*-my-app.fly.dev"` covers every preview deployment. The request origin is reflected back when it is allowed, with `vary: origin`. An origin that is not allowed gets the first literal origin of the list, which the browser rejects, or no `access-control-allow-origin` at all when the list only has patterns.
28
+ - `accessControlAllowRequestOrigin` — reflect any origin. Fine for a public API, not with credentials.
29
+ - `accessControlAllowRequestMethod`, `accessControlAllowRequestHeaders` — also allow whatever a preflight asks for, on top of `accessControlAllowedMethods` (GET, POST, PUT, DELETE, OPTIONS) and `accessControlAllowedHeaders` (`x-requested-with`).
30
+ - `accessControlMaxAge` — seconds a browser may cache a preflight (600).
31
+
32
+ Preflight `OPTIONS` requests are answered by the router itself (see [handling requests](./handling_requests.md)); the plugin only adds the headers.
@@ -0,0 +1,31 @@
1
+ import cluster from "node:cluster";
2
+ import { cpus } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const numCPUs = cpus().length;
6
+
7
+ const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url);
8
+
9
+ cluster.setupPrimary({
10
+ exec: fileURLToPath(workerFileUrl),
11
+ });
12
+
13
+ console.log(`Primary ${process.pid} is running`);
14
+ for (let i = 0; i < numCPUs; i++) {
15
+ cluster.fork();
16
+ }
17
+
18
+ cluster.on("online", (worker) => {
19
+ console.log(`Worker ${worker.process.pid} is online`);
20
+ });
21
+
22
+ cluster.on("exit", (worker, code, signal) => {
23
+ // https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
24
+ if (worker.exitedAfterDisconnect) {
25
+ console.log(
26
+ `Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
27
+ );
28
+ console.log(`Starting a new worker`);
29
+ cluster.fork();
30
+ }
31
+ });
@@ -0,0 +1,52 @@
1
+ import { registerFileLifecycle } from "@jsenv/filesystem";
2
+ import cluster from "node:cluster";
3
+ import { cpus } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const numCPUs = cpus().length;
7
+
8
+ const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url);
9
+
10
+ cluster.setupPrimary({
11
+ exec: fileURLToPath(workerFileUrl),
12
+ });
13
+
14
+ console.log(`Primary ${process.pid} is running`);
15
+ for (let i = 0; i < numCPUs; i++) {
16
+ cluster.fork();
17
+ }
18
+
19
+ cluster.on("online", (worker) => {
20
+ console.log(`Worker ${worker.process.pid} is online`);
21
+ });
22
+
23
+ cluster.on("exit", (worker, code, signal) => {
24
+ // https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
25
+ if (worker.exitedAfterDisconnect) {
26
+ console.log(
27
+ `Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
28
+ );
29
+ console.log(`Starting a new worker`);
30
+ cluster.fork();
31
+ }
32
+ });
33
+
34
+ registerFileLifecycle(workerFileUrl, {
35
+ updated: () => {
36
+ console.log("Server file updated, restarting servers...");
37
+
38
+ Object.keys(cluster.workers).forEach((workerId) => {
39
+ const worker = cluster.workers[workerId];
40
+
41
+ // force kill if graceful exit fails
42
+ const timeout = setTimeout(() => {
43
+ worker.kill("SIGKILL");
44
+ }, 5000);
45
+ worker.once("exit", () => {
46
+ clearTimeout(timeout);
47
+ });
48
+ // try gracefull exit
49
+ worker.kill();
50
+ });
51
+ },
52
+ });
@@ -0,0 +1,10 @@
1
+ import { createFileSystemFetch, startServer } from "@jsenv/server";
2
+
3
+ await startServer({
4
+ routes: [
5
+ {
6
+ endpoint: "GET *",
7
+ fetch: createFileSystemFetch(import.meta.resolve("./")),
8
+ },
9
+ ],
10
+ });
@@ -0,0 +1,29 @@
1
+ import {
2
+ serverPluginCORS,
3
+ serverPluginErrorHandler,
4
+ startServer,
5
+ } from "@jsenv/server";
6
+
7
+ const server = await startServer({
8
+ plugins: [
9
+ serverPluginErrorHandler(),
10
+ serverPluginCORS({
11
+ accessControlAllowRequestOrigin: true,
12
+ accessControlAllowRequestMethod: true,
13
+ accessControlAllowRequestHeaders: true,
14
+ accessControlAllowCredentials: true,
15
+ }),
16
+ ],
17
+ routes: [
18
+ {
19
+ endpoint: "GET *",
20
+ fetch: () => {
21
+ throw new Error("test");
22
+ },
23
+ },
24
+ ],
25
+ });
26
+
27
+ const response = await fetch(server.origin);
28
+ console.log(response.status); // 500
29
+ console.log(response.headers.has("access-control-allow-origin")); // true
@@ -0,0 +1,21 @@
1
+ import { startServer } from "@jsenv/server";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ await startServer({
5
+ https: {
6
+ certificate: readFileSync(new URL("./server.crt", import.meta.url), "utf8"),
7
+ privateKey: readFileSync(new URL("./server.key", import.meta.url), "utf8"),
8
+ },
9
+ allowHttpRequestOnHttps: true,
10
+ routes: [
11
+ {
12
+ endpoint: "GET *",
13
+ fetch: (request) => {
14
+ const clientUsesHttp = request.origin.startsWith("http:");
15
+ return new Response(
16
+ clientUsesHttp ? `Welcome http user` : `Welcome https user`,
17
+ );
18
+ },
19
+ },
20
+ ],
21
+ });
@@ -0,0 +1,24 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIECjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBkTEuMCwGA1UEAxMlaHR0
3
+ cHM6Ly9naXRodWIuY29tL2pzZW52L2pzZW52LXNlcnZlcjELMAkGA1UEBhMCRlIx
4
+ GDAWBgNVBAgTD0FscGVzIE1hcml0aW1lczERMA8GA1UEBxMIVmFsYm9ubmUxDjAM
5
+ BgNVBAoTBWpzZW52MRUwEwYDVQQLEwxqc2VudiBzZXJ2ZXIwHhcNMjEwNjEwMDgz
6
+ NDM4WhcNMjIwNzExMDgzNDM5WjCBkTEuMCwGA1UEAxMlaHR0cHM6Ly9naXRodWIu
7
+ Y29tL2pzZW52L2pzZW52LXNlcnZlcjELMAkGA1UEBhMCRlIxGDAWBgNVBAgTD0Fs
8
+ cGVzIE1hcml0aW1lczERMA8GA1UEBxMIVmFsYm9ubmUxDjAMBgNVBAoTBWpzZW52
9
+ MRUwEwYDVQQLEwxqc2VudiBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
10
+ ggEKAoIBAQDDaJLXCSyLm5zWi/J2vcNrhRfHpDcBnydRMxEqqmc+zazd80pqeGEy
11
+ znVQDnjrJ0cymFXVvelw15UnB5kKhRpsJMzh/5c1rIXU2Ti5cpnWDb599I44vPdJ
12
+ APf8L7d2z0keLLn6gQMdIQKFv68jIM1rWer3FFyA3W4RP16UhtYCDNwO4WeHvryE
13
+ 4bBkYInYo6CKTTUR3pv0BB9oNdQdiSGDgDy/JX6n/DF2y6QfyznICW0rtITG52Gg
14
+ NLAS/0SY//xmE7sApv/fdy8IHtZ1WB7tbvZPjpwcTy4ISAmxUbSs8ratcCD8JEhP
15
+ OSKWybp22EFssdDuF8mRApJ46Wbu+VWrAgMBAAGjazBpMB8GA1UdIwQYMBaAFDs2
16
+ JcFL/Bn0mqi7rt7DZuNnpPajMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
17
+ BgEFBQcDATAhBgNVHREEGjAYhwR/AAABgglsb2NhbGhvc3SCBWpzZW52MA0GCSqG
18
+ SIb3DQEBCwUAA4IBAQB7OabCeian7zIrW2uQc66VI/jO1Ol6vPugtmywPtYiSKkC
19
+ EXG3xuXFi8klPy7tPMJucMPCTZmBy9ymNkaxzrhrZqNvFcdfrJanEaSltu96Yoti
20
+ PTwoqxX0ymAbVTwnulxLiaJqu0z3rQkSogIdeo4InPxqqTA1gYAakMZPkS5JNFgz
21
+ aSlzvxK6rKXD+b2PxclMBvD9CL5eq9StHppD9rYgY39islxRcaQPazXSCTNhU/TN
22
+ Y1gUngqE7PJoBgjJjsVp4MmIjDv9snR12RbIVpm+o2szg2rSN0D0SamjW2hH3MtN
23
+ si/Ce9UYvwS3Bijm/CuxxZX5JKMAGQzLvGhMu1KT
24
+ -----END CERTIFICATE-----