@jsenv/server 17.6.1 → 17.6.2

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 (52) hide show
  1. package/README.md +47 -48
  2. package/dist/jsenv_server.js +1332 -1269
  3. package/package.json +2 -2
  4. package/src/content_negotiation/pick_content_encoding.js +11 -1
  5. package/src/content_negotiation/pick_content_language.js +11 -1
  6. package/src/content_negotiation/pick_content_type.js +11 -1
  7. package/src/content_negotiation/pick_content_version.js +1 -1
  8. package/src/interfacing_with_node/from_node_request.js +154 -178
  9. package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
  10. package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
  11. package/src/interfacing_with_node/write_node_response.js +59 -103
  12. package/src/internal/allowed_hosts.js +85 -0
  13. package/src/internal/escape_html.js +11 -0
  14. package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
  15. package/src/internal/listen.js +32 -5
  16. package/src/internal/{listenRequest.js → listen_request.js} +1 -1
  17. package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
  18. package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
  19. package/src/internal/reason_phrase.js +17 -0
  20. package/src/internal/response_composition.js +11 -2
  21. package/src/internal/secure_server.js +22 -0
  22. package/src/internal/server_origins.js +78 -0
  23. package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
  24. package/src/internal/track_server_pending_connections.js +50 -0
  25. package/src/internal/track_server_pending_requests.js +47 -0
  26. package/src/plugins/cors/server_plugin_cors.js +39 -6
  27. package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
  28. package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
  29. package/src/plugins/filesystem/fetch_directory.js +16 -3
  30. package/src/plugins/filesystem/fetch_file.js +80 -21
  31. package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
  32. package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
  33. package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
  34. package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
  35. package/src/plugins/server_plugins_controller.js +27 -13
  36. package/src/progressive_response.js +24 -0
  37. package/src/router/permissions.js +52 -0
  38. package/src/router/router.js +14 -79
  39. package/src/server_events.js +3 -1
  40. package/src/server_timing/timing_header.js +1 -1
  41. package/src/start_server.js +266 -416
  42. package/src/internal/headersFromString.js +0 -21
  43. package/src/internal/headersToObject.js +0 -7
  44. package/src/internal/headersToString.js +0 -16
  45. package/src/internal/listenClientError.js +0 -27
  46. package/src/internal/trackServerPendingConnections.js +0 -145
  47. package/src/internal/trackServerPendingRequests.js +0 -68
  48. package/src/request_body_handling.js +0 -22
  49. /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
  50. /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
  51. /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
  52. /package/src/internal/{normalizeHeaderValue.js → normalize_header_value.js} +0 -0
@@ -2,27 +2,28 @@ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
2
2
  import { createDetailedMessage, createLogger } from "@jsenv/humanize";
3
3
  import { memoize } from "@jsenv/utils/src/memoize/memoize.js";
4
4
  import cluster from "node:cluster";
5
- import { isIP } from "node:net";
6
5
 
7
6
  import {
8
7
  applyRedirectionToRequest,
9
- createPushRequest,
10
8
  fromNodeRequest,
11
9
  } from "./interfacing_with_node/from_node_request.js";
12
10
  import { writeNodeResponse } from "./interfacing_with_node/write_node_response.js";
13
- import { websocketSuffixColorized } from "./internal/colorizeResponseStatus.js";
11
+ import { websocketSuffixColorized } from "./internal/colorize_response_status.js";
14
12
  import {
15
13
  composeTwoHeaders,
16
14
  composeTwoHeaderValues,
17
15
  } from "./internal/headers_composition.js";
16
+ import { createHostChecker } from "./internal/allowed_hosts.js";
18
17
  import { listen, stopListening } from "./internal/listen.js";
19
- import { listenEvent } from "./internal/listenEvent.js";
20
- import { listenRequest } from "./internal/listenRequest.js";
21
- import { listenServerConnectionError } from "./internal/listenServerConnectionError.js";
18
+ import { listenEvent } from "./internal/listen_event.js";
19
+ import { listenRequest } from "./internal/listen_request.js";
20
+ import { listenServerConnectionError } from "./internal/listen_server_connection_error.js";
22
21
  import { composeTwoResponses } from "./internal/response_composition.js";
23
- import { createPolyglotServer } from "./internal/server-polyglot.js";
24
- import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js";
25
- import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js";
22
+ import { createSecureServer } from "./internal/secure_server.js";
23
+ import { resolveServerOrigins } from "./internal/server_origins.js";
24
+ import { createPolyglotServer } from "./internal/server_polyglot.js";
25
+ import { trackServerPendingConnections } from "./internal/track_server_pending_connections.js";
26
+ import { trackServerPendingRequests } from "./internal/track_server_pending_requests.js";
26
27
  import { serverPluginAutoreloadOnRestart } from "./plugins/autoreload_on_server_restart/server_plugin_autoreload_on_server_restart.js";
27
28
  import { serverPluginResponseCookies } from "./plugins/cookies/server_plugin_response_cookies.js";
28
29
  import { serverPluginDefaultBody4xx5xx } from "./plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js";
@@ -30,6 +31,7 @@ import { serverPluginInternalClientFiles } from "./plugins/internal_client_files
30
31
  import { serverPluginOpenFile } from "./plugins/open_file/server_plugin_open_file.js";
31
32
  import { serverPluginRouteInspector } from "./plugins/route_inspector/server_plugin_route_inspector.js";
32
33
  import { createServerPluginsController } from "./plugins/server_plugins_controller.js";
34
+ import { createPermissionHelpers } from "./router/permissions.js";
33
35
  import { createRouter } from "./router/router.js";
34
36
  import { timingToServerTimingResponseHeaders } from "./server_timing/timing_header.js";
35
37
  import {
@@ -43,75 +45,114 @@ import {
43
45
  } from "./stop_reasons.js";
44
46
  import { getWebSocketHandler } from "./web_socket_response.js";
45
47
 
46
- import { applyDnsResolution } from "./internal/dns_resolution.js";
47
- import { parseHostname } from "./internal/hostname_parser.js";
48
- import { createIpGetters } from "./internal/server_ips.js";
49
-
50
48
  const TIMING_NOOP = () => {
51
49
  return { end: () => {} };
52
50
  };
53
51
 
54
- const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
55
- for (const p of permissionsRequired) {
56
- if (!permissionsSet.has(p)) {
57
- return false;
58
- }
59
- }
60
- return true;
61
- };
62
-
63
52
  /**
64
- * Starts an HTTP (or HTTPS/HTTP2) server.
65
- *
66
- * @param {Object} params
67
- *
68
- * @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
69
- * Each route is an object with the following properties:
70
- * - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
71
- * and the resource path. Use `"*"` as the method to match all methods, and URL patterns
72
- * such as `:param` for named segments or `*` for wildcards.
73
- * - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
74
- * a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
75
- * - `description` {string} — Optional human-readable description shown in the route inspector.
76
- * - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
77
- * access the route. An empty array means anyone can access. When omitted the route is hidden
78
- * by default (404 for everyone).
79
- * - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
80
- * (403 instead of 404 when access is denied). An empty array means the route is visible to
81
- * everyone even when access is denied.
82
- * - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
83
- * `Accept` content negotiation).
84
- * - `availableLanguages` {Array<string>} — Languages this route can respond with.
85
- * - `availableEncodings` {Array<string>} — Encodings this route supports.
86
- * - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
87
- * (POST/PATCH/PUT).
88
- * - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
89
- * inspector as a usage example.
90
- * - `declarationSource` {string} — Optional file URL of where the route is declared, shown
91
- * in the route inspector when `canExposeSensitiveData` is enabled.
92
- * - `headers` {Object} — Optional header pattern that must match for the route to be selected.
93
- *
94
- * @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
95
- * as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
96
- * the exact shape; plugins are not described here in detail.
53
+ * Start an http server (https and http2 optional) answering each request with
54
+ * the first route producing a response.
97
55
  *
98
- * @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
99
- * a random available port — useful in tests to avoid port conflicts. A fixed port such as
100
- * `3000` can be passed for a predictable address.
56
+ * @param {Object} [params={}]
57
+ * @param {Array<Object>} [params.routes=[]] - Route descriptors, tried in order. Each has:
58
+ * - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
59
+ * and a resource pattern. `:name` captures a segment and `*` a run of segments, both
60
+ * land in `request.params`; `?page=:page` captures a search param. `"*"` alone
61
+ * matches everything. An endpoint ending in `.websocket` marks a websocket route.
62
+ * - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
63
+ * Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
64
+ * timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
65
+ * responses), a `WebSocketResponse`, or `null`/`undefined` to let the next route try.
66
+ * `request` is described in docs/handling_requests.md. `helpers` holds `timing(name)`,
67
+ * `injectResponseHeader(name, value)`, `contentNegotiation` (`{ mediaType, language,
68
+ * version, encoding }` picked from the `available*` lists below), `responseCookies`
69
+ * (`set`/`delete`), `hasPermissions`, `getAllPermissions`, `router`,
70
+ * `canExposeSensitiveData`, plus whatever plugins add with "augmentRouteFetchSecondArg".
71
+ * - `headers` {Object} — Header pattern the request must match
72
+ * (`{ upgrade: "websocket" }` marks a websocket route).
73
+ * - `availableMediaTypes`, `availableLanguages`, `availableVersions`, `availableEncodings`
74
+ * {Array} — What the route can produce, in order of preference. Drives content
75
+ * negotiation, the `vary` header and 406 responses. Media types are inferred from the
76
+ * endpoint extension when omitted.
77
+ * - `acceptedMediaTypes` {Array<string>} — Request body media types accepted by
78
+ * POST/PATCH/PUT (415 otherwise).
79
+ * - `permissionsRequired` {Array<string>} — Permissions (granted by "grantPermissions"
80
+ * plugins) needed to access the route; `[]` opens it to everyone. Once any route
81
+ * declares permissions, a route without them is hidden (404) from everyone.
82
+ * - `permissionsToSee` {Array<string>} — Permissions needed to learn the route exists
83
+ * (403 instead of 404 when access is denied); `[]` makes it visible to everyone.
84
+ * - `description` {string}, `clientCodeExample` {string|Function}, `declarationSource`
85
+ * {string} — Shown by the route inspector at `/.internal/route_inspector`.
101
86
  *
102
- * @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
103
- * interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
104
- * When `false` (default) it only listens on the configured `hostname`.
87
+ * @param {Array<Object>} [params.plugins=[]] - Server plugins, see docs/plugins.md.
88
+ * @param {number} [params.port=0] - `0` lets the OS pick a free port.
89
+ * @param {number} [params.portHint] - With `port: 0`, try this port first, then the next ones.
90
+ * @param {string} [params.hostname="localhost"] - Hostname or ip to listen to; `server.origin` is built from it.
91
+ * @param {boolean} [params.acceptAnyIp=false] - Listen on every interface (`0.0.0.0` or `::`)
92
+ * so that other machines on the network can reach the server; `server.origins.externalip`
93
+ * then tells the address to use from there.
94
+ * @param {boolean} [params.preferIpv6] - Favor ipv6 addresses in `server.origins`.
95
+ * @param {Array<string>|true} [params.allowedHosts=[]] - Names the server may be requested
96
+ * with (the `host` header), on top of localhost, `hostname`, the machine name and ips,
97
+ * and every ip when `acceptAnyIp`. `".example.com"` covers the subdomains. Any other
98
+ * host is answered 403: a page served elsewhere cannot read the responses by rebinding
99
+ * its DNS name to this machine. `true` disables the check.
100
+ * @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
101
+ * @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests with a 301
102
+ * to the https origin. Defaults to true unless `allowHttpRequestOnHttps` is set.
103
+ * @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
104
+ * requests on the same port (`request.origin` tells them apart).
105
+ * @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
106
+ * accepted). Brings nothing on localhost; a page loading many modules from another machine
107
+ * loads several times faster. Http2 has no reason phrase: `statusText` then only reaches the
108
+ * logs and the body of 4xx/5xx responses.
109
+ * @param {boolean} [params.http1Allowed=true] - With http2, still accept http/1.1 clients.
110
+ * @param {string} [params.logLevel] - `"debug"`, `"info"` (default), `"warn"`, `"error"` or `"off"`.
111
+ * @param {string} [params.routerLogLevel] - Same, for the router logs (which route matched).
112
+ * @param {boolean} [params.startLog=true] - Log "server started at …" once listening.
113
+ * @param {string} [params.serverName="server"] - Name used in the logs.
114
+ * @param {AbortSignal} [params.signal] - Cancels the start (an AbortError is thrown). Once
115
+ * listening the server is stopped with `stop`.
116
+ * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
117
+ * inside a cluster worker where the primary process is in charge.
118
+ * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
119
+ * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
120
+ * "handleError" plugins answered).
121
+ * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
122
+ * the process alive.
123
+ * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
124
+ * belongs to the machine it runs on. Development only, never in production. It unlocks:
125
+ * - file paths in status texts (a 404 from `createFileSystemFetch` says which path);
126
+ * - the declaration source of every route in the route inspector, where every route is
127
+ * listed whatever its permissions;
128
+ * - `GET /.internal/open_file/*`, opening a file of the machine in the editor;
129
+ * - `GET /@jsenv/server/*`, serving this package's own client files;
130
+ * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
131
+ * subscribes to in order to reload when the server restarts.
132
+ * @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
133
+ * `server-timing` response headers: the time to start responding, the routing of each
134
+ * plugin, what routes measure with `helpers.timing` and the `timing` a response hands
135
+ * back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
136
+ * test wants), a human reading devtools usually wants the sub-millisecond noise gone.
137
+ * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
138
+ * still has no response after that many ms (0 disables).
139
+ * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
140
+ * @param {number} [params.responseTimeout=600000] - Ms a route can take to start responding
141
+ * before a 504 is sent instead (10 minutes).
142
+ * @param {number} [params.requestBodyMaxSize=1048576] - Bytes `request.json()`, `text()`,
143
+ * `buffer()` and `queryString()` accept before answering 413 (1 MiB). A route can pass
144
+ * its own `{ maxSize }` to these readers; `request.body` is never limited.
105
145
  *
106
- * @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
107
- * should never be enabled in production:
108
- * - Declaration source links in the route inspector (shows where each route is defined).
109
- * - The `/.internal/open_file` endpoint that can open files on the server machine.
110
- * - Auto-reload client notification when the server restarts.
111
- * - All routes are visible in the route inspector regardless of permission configuration.
112
- * Set to `true` during local development for a better DX.
113
- *
114
- * @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
146
+ * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
147
+ * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
148
+ * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
149
+ * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
150
+ * and defaults to `STOP_REASON_NOT_SPECIFIED`.
151
+ * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
152
+ * `STOP_REASON_*` exports or what was given to `stop`).
153
+ * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
154
+ * - `addEffect(callback)`: runs `callback` right away; the function it returns runs
155
+ * when the server stops.
115
156
  */
116
157
  export const startServer = async ({
117
158
  signal = new AbortController().signal,
@@ -127,6 +168,7 @@ export const startServer = async ({
127
168
  allowHttpRequestOnHttps = false,
128
169
  acceptAnyIp = false,
129
170
  preferIpv6,
171
+ allowedHosts = [],
130
172
  hostname = "localhost",
131
173
  port = 0, // assign a random available port
132
174
  portHint,
@@ -142,17 +184,7 @@ export const startServer = async ({
142
184
  keepProcessAlive = true,
143
185
  routes = [],
144
186
  plugins = [],
145
- // When enabled, the server gives more power:
146
- // - each route show the source where it was declared
147
- // - server can be requested to open a file on the machine
148
- // - client can subscribe to server to detect when it restarts
149
- // This param should be enabled ONLY during development on your machine
150
187
  canExposeSensitiveData = false,
151
- nagle = true,
152
- // false | true | { minDuration }: server-timing response headers.
153
- // minDuration (ms) drops entries that took less — 0 keeps everything, which
154
- // is what a test wants; a human reading devtools usually wants the noise
155
- // gone (the jsenv dev server passes 0.5 for that).
156
188
  serverTiming = false,
157
189
  requestWaitingMs = 0,
158
190
  requestWaitingCallback = ({ request, requestWaitingMs }) => {
@@ -166,13 +198,8 @@ export const startServer = async ({
166
198
  ),
167
199
  );
168
200
  },
169
- // timeAllocated to start responding to a request
170
- // after this delay the server will respond with 504
171
- responseTimeout = 60_000 * 10, // 10s
172
- // time allocated to server code to start reading the request body
173
- // after this delay the underlying stream is destroyed, attempting to read it would throw
174
- // if used the stream stays opened, it's only if the stream is not read at all that it gets destroyed
175
- requestBodyLifetime = 60_000 * 2, // 2s
201
+ responseTimeout = 60_000 * 10, // 10 minutes
202
+ requestBodyMaxSize = 1024 * 1024,
176
203
  ...rest
177
204
  } = {}) => {
178
205
  // param validations
@@ -197,6 +224,11 @@ export const startServer = async ({
197
224
  if (http2 && !https) {
198
225
  throw new Error(`http2 needs https`);
199
226
  }
227
+ if (allowedHosts !== true && !Array.isArray(allowedHosts)) {
228
+ throw new TypeError(
229
+ `allowedHosts must be an array of hosts or true, got ${allowedHosts}`,
230
+ );
231
+ }
200
232
  }
201
233
  const logger = createLogger({ logLevel });
202
234
  // param warnings and normalization
@@ -268,9 +300,7 @@ export const startServer = async ({
268
300
  let nodeServer;
269
301
  const startServerOperation = Abort.startOperation();
270
302
  const stopCallbackSet = new Set();
271
- const serverOrigins = {
272
- local: "", // favors hostname when possible
273
- };
303
+ let serverOrigins;
274
304
 
275
305
  try {
276
306
  startServerOperation.addAbortSignal(signal);
@@ -295,63 +325,15 @@ export const startServer = async ({
295
325
  nodeServer.unref();
296
326
  }
297
327
 
298
- const createOrigin = (hostname) => {
299
- const protocol = https ? "https" : "http";
300
- if (isIP(hostname) === 6) {
301
- return `${protocol}://[${hostname}]`;
302
- }
303
- return `${protocol}://${hostname}`;
304
- };
305
-
306
- const ipGetters = createIpGetters();
307
- let hostnameToListen;
308
- if (acceptAnyIp) {
309
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
310
- serverOrigins.local = createOrigin(firstInternalIp);
311
- serverOrigins.localip = createOrigin(firstInternalIp);
312
- const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
313
- serverOrigins.externalip = createOrigin(firstExternalIp);
314
- hostnameToListen = preferIpv6 ? "::" : "0.0.0.0";
315
- } else {
316
- hostnameToListen = hostname;
317
- }
318
- const hostnameInfo = parseHostname(hostname);
319
- if (hostnameInfo.type === "ip") {
320
- if (acceptAnyIp) {
321
- throw new Error(
322
- `hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
323
- );
324
- }
325
-
326
- preferIpv6 = hostnameInfo.version === 6;
327
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
328
- serverOrigins.local = createOrigin(firstInternalIp);
329
- serverOrigins.localip = createOrigin(firstInternalIp);
330
- if (hostnameInfo.label === "unspecified") {
331
- const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
332
- serverOrigins.externalip = createOrigin(firstExternalIp);
333
- } else if (hostnameInfo.label === "loopback") {
334
- // nothing
335
- } else {
336
- serverOrigins.local = createOrigin(hostname);
337
- }
338
- } else {
339
- const hostnameDnsResolution = await applyDnsResolution(hostname, {
340
- verbatim: true,
341
- });
342
- if (hostnameDnsResolution) {
343
- const hostnameIp = hostnameDnsResolution.address;
344
- serverOrigins.localip = createOrigin(hostnameIp);
345
- serverOrigins.local = createOrigin(hostname);
346
- } else {
347
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
348
- // fallback to internal ip because there is no ip
349
- // associated to this hostname on operating system (in hosts file)
350
- hostname = firstInternalIp;
351
- hostnameToListen = firstInternalIp;
352
- serverOrigins.local = createOrigin(firstInternalIp);
353
- }
354
- }
328
+ const resolved = await resolveServerOrigins({
329
+ https,
330
+ hostname,
331
+ acceptAnyIp,
332
+ preferIpv6,
333
+ });
334
+ hostname = resolved.hostname;
335
+ serverOrigins = resolved.serverOrigins;
336
+ const hostnameToListen = resolved.hostnameToListen;
355
337
 
356
338
  port = await listen({
357
339
  signal: startServerOperation.signal,
@@ -386,6 +368,12 @@ export const startServer = async ({
386
368
  // so we prefer https://locahost or https://local_hostname
387
369
  // over the ip
388
370
  const serverOrigin = serverOrigins.local;
371
+ const isHostAllowed = createHostChecker({
372
+ allowedHosts,
373
+ hostname,
374
+ serverOrigins,
375
+ acceptAnyIp,
376
+ });
389
377
 
390
378
  // now the server is started (listening) it cannot be aborted anymore
391
379
  // (otherwise an AbortError is thrown to the code calling "startServer")
@@ -449,15 +437,11 @@ export const startServer = async ({
449
437
  );
450
438
  stopCallbackSet.add(removeConnectionErrorListener);
451
439
 
452
- const connectionsTracker = trackServerPendingConnections(nodeServer, {
453
- http2,
454
- });
440
+ const connectionsTracker = trackServerPendingConnections(nodeServer);
455
441
  // opened connection must be shutdown before the close event is emitted
456
442
  stopCallbackSet.add(connectionsTracker.stop);
457
443
 
458
- const pendingRequestsTracker = trackServerPendingRequests(nodeServer, {
459
- http2,
460
- });
444
+ const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
461
445
  // ensure pending requests got a response from the server
462
446
  stopCallbackSet.add((reason) => {
463
447
  pendingRequestsTracker.stop({
@@ -510,7 +494,7 @@ export const startServer = async ({
510
494
  serverTiming && typeof serverTiming === "object"
511
495
  ? serverTiming.minDuration || 0
512
496
  : 0;
513
- const getResponseProperties = async (request, { pushResponse }) => {
497
+ const getResponseProperties = async (request) => {
514
498
  const timings = {};
515
499
  const timing = serverTiming
516
500
  ? (name) => {
@@ -531,9 +515,7 @@ export const startServer = async ({
531
515
  request.logger.info(
532
516
  request.headers["upgrade"] === "websocket"
533
517
  ? `GET ${request.url} ${websocketSuffixColorized}`
534
- : request.parent
535
- ? `Push ${request.resource}`
536
- : `${request.method} ${request.url}`,
518
+ : `${request.method} ${request.url}`,
537
519
  );
538
520
  let requestWaitingTimeout;
539
521
  if (requestWaitingMs) {
@@ -613,23 +595,22 @@ export const startServer = async ({
613
595
  );
614
596
  },
615
597
  );
616
- // the node request readable stream is never closed because
617
- // the response headers contains "connection: keep-alive"
618
- // In this scenario we want to disable READABLE_STREAM_TIMEOUT warning
619
- if (
620
- responseProperties.headers.connection === "keep-alive" &&
621
- request.body
622
- ) {
623
- clearTimeout(request.body.timeout);
624
- }
598
+ serverPluginsController.callHooks("inspectResponse", request, {
599
+ response: responseProperties,
600
+ warn: (message) => {
601
+ request.logger.warn(message);
602
+ },
603
+ });
625
604
  return responseProperties;
626
605
  };
627
606
 
628
607
  let timeout;
608
+ let timedOut = false;
629
609
  try {
630
610
  request = applyRequestInternalRedirection(request);
631
611
  const timeoutResponsePropertiesPromise = new Promise((resolve) => {
632
612
  timeout = setTimeout(() => {
613
+ timedOut = true;
633
614
  resolve({
634
615
  // the correct status code should be 500 because it's
635
616
  // we don't really know what takes time
@@ -645,7 +626,7 @@ export const startServer = async ({
645
626
  const routerResponsePropertiesPromise = (async () => {
646
627
  const fetchSecondArg = {
647
628
  timing,
648
- pushResponse,
629
+ canExposeSensitiveData,
649
630
  injectResponseHeader: (name, value) => {
650
631
  if (!headersToInject) {
651
632
  headersToInject = {};
@@ -667,54 +648,28 @@ export const startServer = async ({
667
648
  }
668
649
  },
669
650
  );
670
- // Build permission helpers, lazily per request.
671
- // A single shared iterator ensures each plugin is called at most once.
672
- // hasPermissions stops early once the rule is satisfied;
673
- // getAllPermissions drains all remaining plugins.
674
- const permissionsSet = new Set();
675
- const nextPermissionsHook =
676
- serverPluginsController.createAsyncHookIterator(
677
- "grantPermissions",
678
- request,
679
- );
680
- const drainPermissionsUntil = async (permissionsRequired) => {
681
- for (;;) {
682
- if (
683
- permissionsRequired !== undefined &&
684
- permissionsSatisfy(permissionsSet, permissionsRequired)
685
- ) {
686
- return true;
687
- }
688
- const { done, value } = await nextPermissionsHook();
689
- if (done) {
690
- break;
691
- }
692
- if (Array.isArray(value)) {
693
- for (const p of value) {
694
- permissionsSet.add(p);
695
- }
696
- }
697
- }
698
- return false;
699
- };
700
- const getAllPermissions = async () => {
701
- await drainPermissionsUntil(undefined);
702
- return permissionsSet;
703
- };
704
- const hasPermissions = async (permissionsRequired) => {
705
- if (permissionsRequired.length === 0) {
706
- return true;
707
- }
708
- return drainPermissionsUntil(permissionsRequired);
709
- };
710
- fetchSecondArg.getAllPermissions = getAllPermissions;
711
- fetchSecondArg.hasPermissions = hasPermissions;
651
+ Object.assign(
652
+ fetchSecondArg,
653
+ createPermissionHelpers(serverPluginsController, request),
654
+ );
712
655
  const routerResponseProperties = await router.match(
713
656
  request,
714
657
  fetchSecondArg,
715
658
  );
716
659
  return routerResponseProperties;
717
660
  })();
661
+ // once the 504 is sent the route keeps running: what it throws
662
+ // afterwards would have nobody to reject to
663
+ routerResponsePropertiesPromise.catch((e) => {
664
+ if (timedOut) {
665
+ logger.error(
666
+ createDetailedMessage(`error after the 504 timeout response`, {
667
+ "request url": request.url,
668
+ "error stack": e.stack,
669
+ }),
670
+ );
671
+ }
672
+ });
718
673
  const responseProperties = await Promise.race([
719
674
  timeoutResponsePropertiesPromise,
720
675
  routerResponsePropertiesPromise,
@@ -727,6 +682,21 @@ export const startServer = async ({
727
682
  // let it propagate to the caller that should catch this
728
683
  throw e;
729
684
  }
685
+ // an error carrying its own response (a request body too large for
686
+ // instance) is not an internal error: it is answered as such, whatever
687
+ // the plugins
688
+ if (e && typeof e.asResponse === "function") {
689
+ const responseProperties = composeTwoResponses(
690
+ {
691
+ status: 500,
692
+ headers: {
693
+ "cache-control": "no-store",
694
+ },
695
+ },
696
+ await e.asResponse(),
697
+ );
698
+ return finalizeResponseProperties(responseProperties);
699
+ }
730
700
  // internal error, create 500 response
731
701
  if (
732
702
  // stopOnInternalError stops server only if requestToResponse generated
@@ -764,20 +734,28 @@ export const startServer = async ({
764
734
  return finalizeResponseProperties(responseProperties);
765
735
  }
766
736
  };
737
+ // When a response is sent before the request body was read (a 413, a 404
738
+ // on an upload...) node keeps draining what the client sends so that the
739
+ // client gets to read the response, as nginx does with its lingering close.
740
+ // A client that has not finished sending after that delay is cut off:
741
+ // draining a body forever is what the 413 was meant to avoid.
742
+ const lingerAfterEarlyResponse = (nodeRequest) => {
743
+ if (nodeRequest.complete) {
744
+ return;
745
+ }
746
+ const lingerTimeout = setTimeout(() => {
747
+ nodeRequest.socket.destroy();
748
+ }, REQUEST_BODY_LINGER_MS).unref();
749
+ nodeRequest.once("close", () => {
750
+ clearTimeout(lingerTimeout);
751
+ });
752
+ };
767
753
  const sendResponse = async (
768
754
  responseStream,
769
755
  responseProperties,
770
756
  { signal, request },
771
757
  ) => {
772
- // When "pushResponse" is called and the parent response has no body
773
- // the parent response is immediatly ended. It means child responses (pushed streams)
774
- // won't get a chance to be pushed.
775
- // To let a chance to pushed streams we wait a little before sending the response
776
758
  const ignoreBody = request.method === "HEAD";
777
- const bodyIsEmpty = !responseProperties.body || ignoreBody;
778
- if (bodyIsEmpty && request.logger.hasPushChild) {
779
- await new Promise((resolve) => setTimeout(resolve));
780
- }
781
759
  await writeNodeResponse(responseStream, responseProperties, {
782
760
  signal,
783
761
  ignoreBody,
@@ -808,6 +786,15 @@ export const startServer = async ({
808
786
 
809
787
  request: {
810
788
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
789
+ const requestHost = nodeRequest.authority || nodeRequest.headers.host;
790
+ if (!isHostAllowed(requestHost)) {
791
+ logger.warn(
792
+ `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
793
+ );
794
+ nodeResponse.writeHead(403, { "content-type": "text/plain" });
795
+ nodeResponse.end("Host not allowed");
796
+ return;
797
+ }
811
798
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
812
799
  nodeResponse.writeHead(301, {
813
800
  location: `${serverOrigin}${nodeRequest.url}`,
@@ -826,164 +813,29 @@ export const startServer = async ({
826
813
 
827
814
  const [receiveRequestOperation, sendResponseOperation] =
828
815
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
829
- const request = fromNodeRequest(nodeRequest, {
830
- signal: stopAbortSignal,
831
- serverOrigin,
832
- requestBodyLifetime,
833
- logger,
834
- nagle,
835
- });
836
-
816
+ let request;
837
817
  try {
838
- const responseProperties = await getResponseProperties(request, {
839
- pushResponse: async ({ path, method }) => {
840
- const pushRequestLogger = request.logger.forPush();
841
- if (typeof path !== "string" || path[0] !== "/") {
842
- pushRequestLogger.warn(
843
- `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
844
- );
845
- return;
846
- }
847
- if (!request.http2) {
848
- pushRequestLogger.warn(
849
- `response push ignored because request is not http2`,
850
- );
851
- return;
852
- }
853
- const canPushStream = testCanPushStream(nodeResponse.stream);
854
- if (!canPushStream.can) {
855
- pushRequestLogger.debug(
856
- `response push ignored because ${canPushStream.reason}`,
857
- );
858
- return;
859
- }
860
-
861
- let preventedByPlugin = null;
862
- const prevent = () => {
863
- preventedByPlugin = serverPluginsController.getCurrentPlugin();
864
- };
865
- serverPluginsController.callHooksUntil(
866
- "onResponsePush",
867
- { path, method },
868
- { request, prevent },
869
- () => preventedByPlugin,
870
- );
871
- if (preventedByPlugin) {
872
- pushRequestLogger.debug(
873
- `response push prevented by "${preventedByPlugin.name}" plugin`,
874
- );
875
- return;
876
- }
877
-
878
- const http2Stream = nodeResponse.stream;
879
-
880
- // being able to push a stream is nice to have
881
- // so when it fails it's not critical
882
- const onPushStreamError = (e) => {
883
- pushRequestLogger.error(
884
- createDetailedMessage(
885
- `An error occured while pushing a stream to the response for ${request.resource}`,
886
- {
887
- "error stack": e.stack,
888
- },
889
- ),
890
- );
891
- };
892
-
893
- // not aborted, let's try to push a stream into that response
894
- // https://nodejs.org/docs/latest-v16.x/api/http2.html#http2streampushstreamheaders-options-callback
895
- let pushStream;
896
- try {
897
- pushStream = await new Promise((resolve, reject) => {
898
- http2Stream.pushStream(
899
- {
900
- ":path": path,
901
- ...(method ? { ":method": method } : {}),
902
- },
903
- async (
904
- error,
905
- pushStream,
906
- // headers
907
- ) => {
908
- if (error) {
909
- reject(error);
910
- }
911
- resolve(pushStream);
912
- },
913
- );
914
- });
915
- } catch (e) {
916
- onPushStreamError(e);
917
- return;
918
- }
919
-
920
- const abortController = new AbortController();
921
- // It's possible to get NGHTTP2_REFUSED_STREAM errors here
922
- // https://github.com/nodejs/node/issues/20824
923
- const pushErrorCallback = (error) => {
924
- onPushStreamError(error);
925
- abortController.abort();
926
- };
927
- pushStream.on("error", pushErrorCallback);
928
- sendResponseOperation.addEndCallback(() => {
929
- pushStream.removeListener("error", onPushStreamError);
930
- });
931
-
932
- await sendResponseOperation.withSignal(async (signal) => {
933
- const pushResponseOperation = Abort.startOperation();
934
- pushResponseOperation.addAbortSignal(signal);
935
- pushResponseOperation.addAbortSignal(abortController.signal);
936
-
937
- const pushRequest = createPushRequest(request, {
938
- signal: pushResponseOperation.signal,
939
- pathname: path,
940
- method,
941
- logger: pushRequestLogger,
942
- });
943
-
944
- try {
945
- const responseProperties = await getResponseProperties(
946
- pushRequest,
947
- {
948
- pushResponse: () => {
949
- pushRequest.logger.warn(
950
- `response push ignored because nested push is not supported`,
951
- );
952
- },
953
- },
954
- );
955
- if (!abortController.signal.aborted) {
956
- if (pushStream.destroyed) {
957
- abortController.abort();
958
- } else if (!http2Stream.pushAllowed) {
959
- abortController.abort();
960
- } else if (responseProperties.requestAborted) {
961
- } else {
962
- const responseLength =
963
- responseProperties.headers["content-length"] || 0;
964
- const { effectiveRecvDataLength, remoteWindowSize } =
965
- http2Stream.session.state;
966
- if (
967
- effectiveRecvDataLength + responseLength >
968
- remoteWindowSize
969
- ) {
970
- pushRequest.logger.debug(
971
- `Aborting stream to prevent exceeding remoteWindowSize`,
972
- );
973
- abortController.abort();
974
- }
975
- }
976
- }
977
- await sendResponse(pushStream, responseProperties, {
978
- signal: pushResponseOperation.signal,
979
- request: pushRequest,
980
- });
981
- } finally {
982
- await pushResponseOperation.end();
983
- }
984
- });
985
- },
818
+ request = fromNodeRequest(nodeRequest, {
819
+ signal: stopAbortSignal,
820
+ serverOrigin,
821
+ logger,
822
+ requestBodyMaxSize,
986
823
  });
824
+ } catch (e) {
825
+ // the request cannot even be read: there is nothing to hand to the routes
826
+ logger.error(
827
+ createDetailedMessage(`error while reading request`, {
828
+ "request url": nodeRequest.url,
829
+ "error stack": e.stack,
830
+ }),
831
+ );
832
+ nodeResponse.writeHead(500);
833
+ nodeResponse.end();
834
+ return;
835
+ }
836
+
837
+ try {
838
+ const responseProperties = await getResponseProperties(request);
987
839
  const webSocketHandler = getWebSocketHandler(responseProperties);
988
840
  if (webSocketHandler) {
989
841
  throw new Error(
@@ -997,6 +849,7 @@ export const startServer = async ({
997
849
  signal: sendResponseOperation.signal,
998
850
  request,
999
851
  });
852
+ lingerAfterEarlyResponse(nodeRequest);
1000
853
  } finally {
1001
854
  await sendResponseOperation.end();
1002
855
  }
@@ -1038,22 +891,39 @@ export const startServer = async ({
1038
891
  };
1039
892
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
1040
893
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
1041
- let request = fromNodeRequest(nodeRequest, {
1042
- signal: stopAbortSignal,
1043
- serverOrigin,
1044
- requestBodyLifetime,
1045
- logger,
1046
- nagle,
1047
- });
894
+ const requestHost = nodeRequest.headers.host;
895
+ if (!isHostAllowed(requestHost)) {
896
+ logger.warn(
897
+ `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
898
+ );
899
+ socket.write(
900
+ `HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: 16\r\n\r\nHost not allowed`,
901
+ );
902
+ socket.destroy();
903
+ return;
904
+ }
905
+ let request;
906
+ try {
907
+ request = fromNodeRequest(nodeRequest, {
908
+ signal: stopAbortSignal,
909
+ serverOrigin,
910
+ logger,
911
+ requestBodyMaxSize,
912
+ });
913
+ } catch (e) {
914
+ logger.error(
915
+ createDetailedMessage(`error while reading upgrade request`, {
916
+ "request url": nodeRequest.url,
917
+ "error stack": e.stack,
918
+ }),
919
+ );
920
+ socket.write(`HTTP/1.1 500 Internal Server Error\r\n\r\n`);
921
+ socket.destroy();
922
+ return;
923
+ }
1048
924
  const [receiveRequestOperation, sendResponseOperation] =
1049
925
  prepareHandleRequestOperations(nodeRequest, socket);
1050
- const responseProperties = await getResponseProperties(request, {
1051
- pushResponse: () => {
1052
- request.logger.warn(
1053
- `pushResponse ignored because it's not supported in websocket`,
1054
- );
1055
- },
1056
- });
926
+ const responseProperties = await getResponseProperties(request);
1057
927
  if (receiveRequestOperation.signal.aborted) {
1058
928
  return;
1059
929
  }
@@ -1105,7 +975,7 @@ export const startServer = async ({
1105
975
  }
1106
976
  return;
1107
977
  };
1108
- // see server-polyglot.js, upgrade must be listened on https server when used
978
+ // see server_polyglot.js, upgrade must be listened on https server when used
1109
979
  const facadeServer = nodeServer._tlsServer || nodeServer;
1110
980
  const removeUpgradeCallback = listenEvent(
1111
981
  facadeServer,
@@ -1122,9 +992,9 @@ export const startServer = async ({
1122
992
  }
1123
993
 
1124
994
  if (startLog) {
1125
- if (serverOrigins.network) {
995
+ if (serverOrigins.externalip) {
1126
996
  logger.info(
1127
- `${serverName} started at ${serverOrigins.local} (${serverOrigins.network})`,
997
+ `${serverName} started at ${serverOrigins.local} (${serverOrigins.externalip})`,
1128
998
  );
1129
999
  } else {
1130
1000
  logger.info(`${serverName} started at ${serverOrigins.local}`);
@@ -1167,38 +1037,18 @@ const createNodeServer = async ({
1167
1037
  http1Allowed,
1168
1038
  });
1169
1039
  }
1170
- const { createServer } = await import("node:https");
1171
- return createServer({
1172
- cert: certificate,
1173
- key: privateKey,
1040
+ return createSecureServer({
1041
+ certificate,
1042
+ privateKey,
1043
+ http2,
1044
+ http1Allowed,
1174
1045
  });
1175
1046
  }
1176
1047
  const { createServer } = await import("node:http");
1177
1048
  return createServer();
1178
1049
  };
1179
1050
 
1180
- const testCanPushStream = (http2Stream) => {
1181
- if (!http2Stream.pushAllowed) {
1182
- return {
1183
- can: false,
1184
- reason: `stream.pushAllowed is false`,
1185
- };
1186
- }
1187
-
1188
- // See https://nodejs.org/dist/latest-v16.x/docs/api/http2.html#http2sessionstate
1189
- // And https://github.com/google/node-h2-auto-push/blob/67a36c04cbbd6da7b066a4e8d361c593d38853a4/src/index.ts#L100-L106
1190
- const { remoteWindowSize } = http2Stream.session.state;
1191
- if (remoteWindowSize === 0) {
1192
- return {
1193
- can: false,
1194
- reason: `no more remoteWindowSize`,
1195
- };
1196
- }
1197
-
1198
- return {
1199
- can: true,
1200
- };
1201
- };
1051
+ const REQUEST_BODY_LINGER_MS = 5_000;
1202
1052
 
1203
1053
  const PROCESS_TEARDOWN_EVENTS_MAP = {
1204
1054
  SIGHUP: STOP_REASON_PROCESS_SIGHUP,