@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
@@ -0,0 +1,64 @@
1
+ # Server-Sent Events
2
+
3
+ `ServerEvents` keeps track of the connected clients and broadcasts events to them. A client connects with an `EventSource` (`accept: text/event-stream`) or with a `WebSocket`: `serverEvents.fetch` handles both.
4
+
5
+ _server.js_
6
+
7
+ ```js
8
+ import { ServerEvents, startServer } from "@jsenv/server";
9
+
10
+ const serverEvents = new ServerEvents();
11
+
12
+ setInterval(() => {
13
+ serverEvents.sendEventToAllClients({
14
+ type: "ping",
15
+ data: JSON.stringify({ ts: Date.now() }),
16
+ });
17
+ }, 1000);
18
+
19
+ await startServer({
20
+ port: 3456,
21
+ routes: [
22
+ {
23
+ endpoint: "GET /events",
24
+ fetch: serverEvents.fetch,
25
+ },
26
+ ],
27
+ });
28
+ ```
29
+
30
+ _client.js_
31
+
32
+ ```js
33
+ const eventSource = new EventSource("http://localhost:3456/events");
34
+ eventSource.addEventListener("ping", (event) => {
35
+ console.log("ping from server", event.lastEventId, JSON.parse(event.data));
36
+ });
37
+ ```
38
+
39
+ ![Screencast of server sent events execution in a terminal](./screenshots/sse-screencast.gif)
40
+
41
+ An event is `{ type, data, id, retry }`; `data` is sent as is (stringify objects yourself) and must hold no line break: the event stream is line based, a line break ends the event. `JSON.stringify` without indentation is safe. Every event gets an incrementing `id` and is kept (`historyLength`, 1000 by default): a client reconnecting with a `last-event-id` header, or a `?last-event-id=` search param, receives what it missed. A comment is sent every `keepaliveDuration` (30s) so that proxies keep the connection open.
42
+
43
+ A websocket client receives each event as one message, the whole event as json: `JSON.parse(message.data)` gives `{ type, data, id }`.
44
+
45
+ Past `maxClientAllowed` (100) a new client is refused with 503, or the oldest one is disconnected with `actionOnClientLimitReached: "kick-oldest"`. `close()` disconnects everyone and answers 204 until `open()`. `getClientCount()` and `getAllEventSince(id)` tell where things stand.
46
+
47
+ ## Producing events only when someone listens
48
+
49
+ `LazyServerEvents` runs a producer when the first client connects and its cleanup when the last one leaves — a file watcher, a database subscription, a timer. Only `fetch` is exposed: events can only come from the producer.
50
+
51
+ ```js
52
+ import { LazyServerEvents } from "@jsenv/server";
53
+
54
+ const ticks = new LazyServerEvents(({ sendEvent }) => {
55
+ const interval = setInterval(() => {
56
+ sendEvent({ type: "tick", data: new Date().toISOString() });
57
+ }, 1000);
58
+ return () => {
59
+ clearInterval(interval);
60
+ };
61
+ });
62
+
63
+ // route: { endpoint: "GET /ticks", fetch: ticks.fetch }
64
+ ```
@@ -0,0 +1,70 @@
1
+ # Server timing
2
+
3
+ `serverTiming: true` sends a `server-timing` response header telling what the server spent its time on. Devtools show it next to the TTFB of each request.
4
+
5
+ _Chrome devtools, TTFB:_
6
+
7
+ ![screenshot of chrome devtools TTFB](./screenshots/devtools_TTFB.png)
8
+
9
+ _Chrome devtools, server timing:_
10
+
11
+ ![screenshot of chrome devtools server timing](./screenshots/devtools_server_timing.png)
12
+
13
+ ```js
14
+ import { startServer } from "@jsenv/server";
15
+
16
+ await startServer({
17
+ serverTiming: true,
18
+ routes: [
19
+ {
20
+ endpoint: "GET *",
21
+ fetch: async (request, { timing }) => {
22
+ const waitTiming = timing("waiting 50ms");
23
+ await new Promise((resolve) => {
24
+ setTimeout(resolve, 50);
25
+ });
26
+ waitTiming.end();
27
+ return new Response("Hello");
28
+ },
29
+ },
30
+ ],
31
+ });
32
+ ```
33
+
34
+ ```console
35
+ server-timing: a;desc="time to start responding";dur=51.2, b;desc="routing";dur=0.1, c;desc="waiting 50ms";dur=50.7
36
+ ```
37
+
38
+ The header holds:
39
+
40
+ - `time to start responding`, from the request to the response headers;
41
+ - the routing of each group of routes (`routing`, or `<plugin>.routing` for the routes a plugin brought);
42
+ - what the route measured with `helpers.timing(name)`; `createFileSystemFetch` measures its stat and etag;
43
+ - the `timing` property a plain response object hands back: `{ "db query": 12.3, "served from cache": null }` — a `null` duration is a marker, something to say with nothing to measure.
44
+
45
+ Entries are named `a`, `b`, `c`… because devtools sort them alphabetically; the `desc` carries the name.
46
+
47
+ `serverTiming: { minDuration: 0.5 }` drops the entries that took less than that many milliseconds: a request crosses every route, and a wall of sub-millisecond `routing` entries buries the measures worth reading. `time to start responding` and the markers always stay.
48
+
49
+ ## Measuring without telling everyone
50
+
51
+ The header describes what the server does internally; an api usually wants it for itself, a bench or a probe, and for nobody else. Give a function instead of `true` and it is asked once per request, on what the client sent:
52
+
53
+ ```js
54
+ const PERF_TIMING_TOKEN = process.env.PERF_TIMING_TOKEN;
55
+ const isPerfTimingClient = (request) =>
56
+ request.headers["x-perf-timing"] === PERF_TIMING_TOKEN;
57
+
58
+ await startServer({
59
+ serverTiming: isPerfTimingClient,
60
+ plugins: [
61
+ serverPluginCORS({
62
+ accessControlAllowedOrigins: ["https://app.example.com"],
63
+ // without it a page cannot read the timings of a cross-origin response
64
+ timingAllowOrigin: isPerfTimingClient,
65
+ }),
66
+ ],
67
+ });
68
+ ```
69
+
70
+ `{ enabled: isPerfTimingClient, minDuration: 1 }` says the same thing with a `minDuration`. When the predicate says no nothing is measured for that request and no header is composed; routes still call `helpers.timing(name)` unconditionally, it costs them nothing.
@@ -0,0 +1,64 @@
1
+ # Serving files
2
+
3
+ ```js
4
+ import { createFileSystemFetch, startServer } from "@jsenv/server";
5
+
6
+ await startServer({
7
+ routes: [
8
+ {
9
+ endpoint: "GET /assets/*",
10
+ fetch: createFileSystemFetch(import.meta.resolve("./assets/")),
11
+ },
12
+ {
13
+ endpoint: "GET *",
14
+ fetch: createFileSystemFetch(import.meta.resolve("./public/"), {
15
+ mainFileRelativeUrl: "./index.html",
16
+ }),
17
+ },
18
+ ],
19
+ });
20
+ ```
21
+
22
+ The part of the url captured by `*` (the whole resource when the endpoint has none) is resolved inside the directory. A url leaving the directory (`..`) is answered 403. Any method other than GET and HEAD is declined: the next route is tried. The `content-type` comes from the file extension.
23
+
24
+ The options are in the JSDoc of `createFileSystemFetch`; what follows is how they combine.
25
+
26
+ ## Client cache
27
+
28
+ By default every request gets 200 with the content. Two ways to unlock 304:
29
+
30
+ - `etagEnabled` — an `etag` computed from the content, remembered per file until its stats change (`etagMemory`, `etagMemoryMaxSize`); a matching `if-none-match` gets 304. Robust.
31
+ - `mtimeEnabled` — a `last-modified` header from the filesystem date, second precision; a recent enough `if-modified-since` gets 304. Relies on filesystem dates being meaningful.
32
+
33
+ When both are set, etag wins.
34
+
35
+ `cacheControl` becomes the `cache-control` header. By default a versioned url (`isVersioned`: the url has a `v` search param) is `private,max-age=2592000,immutable` and anything else `private,max-age=0,must-revalidate`. It can be a function of the request:
36
+
37
+ ```js
38
+ createFileSystemFetch(import.meta.resolve("./"), {
39
+ etagEnabled: true,
40
+ cacheControl: (request) =>
41
+ request.resource === "/"
42
+ ? `private,max-age=0,must-revalidate`
43
+ : `private,max-age=3600,immutable`,
44
+ });
45
+ ```
46
+
47
+ `"no-store"` disables etag and mtime, they would be pointless.
48
+
49
+ ## Compression
50
+
51
+ `compressionEnabled` compresses textual files bigger than `compressionSizeThreshold` (1024 bytes) with brotli, gzip or deflate, whichever `accept-encoding` prefers. The compression happens for every request (brotli at quality 4 to keep it cheap) and the response has no `content-length`: for production, compress the files once at build time instead.
52
+
53
+ ## Directories
54
+
55
+ A url pointing to a directory gets 403, unless:
56
+
57
+ - `canReadDirectory: true` — the listing is sent, as json or as an html page of links (see `fetchDirectory`),
58
+ - `mainFileRelativeUrl` — that file is sent for the root of the served directory, and for any extension-less url that does not exist (client side routing needs the same page for `/users/42`). A subdirectory still gets 403.
59
+
60
+ `ENOENTFallback: () => fileUrl` serves another file when the requested one does not exist.
61
+
62
+ ## What a 404 says
63
+
64
+ The status text is `ENOENT: File not found`; the file path is added only when the server runs with `canExposeSensitiveData` (see [security](./security.md)). Same for the other filesystem errors (EACCES → 403, EBUSY → 503 with `retry-after`, …).
@@ -0,0 +1,64 @@
1
+ # WebSocket
2
+
3
+ A route accepts a websocket by returning a `WebSocketResponse`:
4
+
5
+ _server.js_
6
+
7
+ ```js
8
+ import {
9
+ createFileSystemFetch,
10
+ startServer,
11
+ WebSocketResponse,
12
+ } from "@jsenv/server";
13
+
14
+ await startServer({
15
+ port: 3000,
16
+ routes: [
17
+ {
18
+ endpoint: "GET /chat.websocket",
19
+ fetch: () => {
20
+ return new WebSocketResponse((websocket) => {
21
+ websocket.send("Hello world");
22
+ websocket.on("message", (data) => {
23
+ websocket.send(data);
24
+ });
25
+ return () => {
26
+ // the client is gone
27
+ };
28
+ });
29
+ },
30
+ },
31
+ {
32
+ endpoint: "GET *",
33
+ fetch: createFileSystemFetch(import.meta.resolve("./")),
34
+ },
35
+ ],
36
+ });
37
+ ```
38
+
39
+ _client.html_
40
+
41
+ ```html
42
+ <!doctype html>
43
+ <html lang="en">
44
+ <head>
45
+ <title>Title</title>
46
+ <link rel="icon" href="data:," />
47
+ </head>
48
+
49
+ <body>
50
+ <script>
51
+ const websocket = new WebSocket("ws://localhost:3000/chat.websocket");
52
+ websocket.onmessage = (message) => {
53
+ document.body.appendChild(document.createTextNode(message.data));
54
+ };
55
+ </script>
56
+ </body>
57
+ </html>
58
+ ```
59
+
60
+ A route is a websocket route when its endpoint ends with `.websocket` or its `headers` pattern has `upgrade: "websocket"`; a plain request to it is answered 426. The handler receives the [ws](https://github.com/websockets/ws/blob/master/doc/ws.md) socket once the upgrade is done; if it returns a function, that function runs when the socket closes.
61
+
62
+ A websocket route can still refuse the upgrade by returning a regular response (a 401 for instance). A 101 response that is not a `WebSocketResponse` is an error, and so is a `WebSocketResponse` returned to a request that did not ask for an upgrade.
63
+
64
+ `server.webSocketOrigin` is the `ws://` (or `wss://`) origin of the server. To broadcast to many clients, see [server sent events](./server_sent_events.md): `ServerEvents` accepts websocket clients too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.6.7",
3
+ "version": "17.6.8",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "files": [
30
30
  "/dist/",
31
+ "/docs/",
31
32
  "/index.js",
32
33
  "/src/"
33
34
  ],
@@ -54,10 +55,10 @@
54
55
  "@jsenv/url-pattern": "../../tooling/url-pattern",
55
56
  "@jsenv/urls": "../../tooling/urls",
56
57
  "@jsenv/utils": "../../tooling/utils",
57
- "eventsource": "5.1.1",
58
+ "eventsource": "5.1.2",
58
59
  "formidable": "3.5.4",
59
60
  "playwright": "1.63.0",
60
- "undici": "8.10.2",
61
+ "undici": "8.11.2",
61
62
  "ws": "8.21.3"
62
63
  },
63
64
  "publishConfig": {
@@ -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
@@ -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]
@@ -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
  }
@@ -878,8 +879,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
878
879
  * @param {Object} params - Content negotiation parameters
879
880
  * @param {Array<string>} params.availableMediaTypes - Content types the server can produce
880
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
881
883
  * @param {Array<string>} params.availableEncodings - Encodings the server supports
882
- * @returns {Response} A 406 Not Acceptable response
884
+ * @returns {Object} 406 response properties
883
885
  */
884
886
  const createNotAcceptableResponse = (
885
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
 
@@ -117,8 +117,9 @@ const TIMING_NOOP = () => {
117
117
  * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
118
118
  * inside a cluster worker where the primary process is in charge.
119
119
  * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
120
- * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
121
- * "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.
122
123
  * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
123
124
  * the process alive.
124
125
  * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
@@ -150,8 +151,9 @@ const TIMING_NOOP = () => {
150
151
  * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
151
152
  * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
152
153
  * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
153
- * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
154
- * 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`.
155
157
  * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
156
158
  * `STOP_REASON_*` exports or what was given to `stop`).
157
159
  * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
@@ -442,16 +444,16 @@ export const startServer = async ({
442
444
  stopCallbackSet.add(removeConnectionErrorListener);
443
445
 
444
446
  const connectionsTracker = trackServerPendingConnections(nodeServer);
445
- // opened connection must be shutdown before the close event is emitted
446
- stopCallbackSet.add(connectionsTracker.stop);
447
-
448
447
  const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
449
- // ensure pending requests got a response from the server
450
- stopCallbackSet.add((reason) => {
451
- 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({
452
453
  status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
453
454
  reason,
454
455
  });
456
+ await connectionsTracker.stop(reason);
455
457
  });
456
458
 
457
459
  const applyRequestInternalRedirection = (request) => {