@jsenv/server 17.6.1 → 17.6.3

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 +1336 -1270
  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 +18 -80
  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
@@ -1,12 +1,14 @@
1
1
  /*
2
- * This function returns response properties in a plain object like
3
- * { status: 200, body: "Hello world" }.
4
- * It is meant to be used inside "requestToResponse"
2
+ * Turns a request for a file into response properties: content type, client
3
+ * cache (etag or mtime), compression, directory listing or main file. The
4
+ * response is a plain object so that it composes with what the router and
5
+ * the plugins add.
5
6
  */
6
7
 
7
8
  import { urlIsOrIsInsideOf, urlToExtension, urlToPathname } from "@jsenv/urls";
8
9
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
9
- import { createReadStream, readFile, statSync } from "node:fs";
10
+ import { createReadStream } from "node:fs";
11
+ import { readFile, stat } from "node:fs/promises";
10
12
 
11
13
  import { pickContentEncoding } from "../../content_negotiation/pick_content_encoding.js";
12
14
  import { composeTwoResponses } from "../../internal/response_composition.js";
@@ -18,12 +20,59 @@ import {
18
20
  isFileSystemPath,
19
21
  } from "./filesystem_path_and_url.js";
20
22
 
23
+ /**
24
+ * A route `fetch` serving the files of a directory. The part of the url
25
+ * captured by the endpoint `*` (or the whole resource when there is none) is
26
+ * resolved inside `directoryUrl`; a url escaping it is answered with 403.
27
+ *
28
+ * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
29
+ * @param {Object} [options] - See {@link fetchFileSystem}.
30
+ * @returns {(request: Object, helpers: Object) => Promise<Object|null>}
31
+ *
32
+ * @example
33
+ * {
34
+ * endpoint: "GET /assets/*",
35
+ * fetch: createFileSystemFetch(import.meta.resolve("./assets/"), { etagEnabled: true }),
36
+ * }
37
+ */
21
38
  export const createFileSystemFetch = (directoryUrl, options) => {
22
39
  return (request, helpers) => {
23
40
  return fetchFileSystem(request, helpers, directoryUrl, options);
24
41
  };
25
42
  };
26
43
 
44
+ /**
45
+ * Serve a file of `directoryUrl` for `request`. Declines (`null`) any method
46
+ * other than GET and HEAD. File paths appear in the status text only when the
47
+ * server runs with `canExposeSensitiveData`.
48
+ *
49
+ * @param {Object} request - The request given to a route `fetch`.
50
+ * @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
51
+ * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
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).
55
+ * @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
56
+ * answer 304 to a matching `if-none-match`.
57
+ * @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
58
+ * file stats change) so that a file is hashed once.
59
+ * @param {number} [options.etagMemoryMaxSize=1000] - Files remembered at most.
60
+ * @param {boolean} [options.mtimeEnabled=false] - Send `last-modified` and answer 304 to
61
+ * `if-modified-since` (second precision). Ignored when `etagEnabled` is set.
62
+ * @param {boolean} [options.compressionEnabled=false] - Compress textual files with brotli,
63
+ * gzip or deflate according to `accept-encoding`.
64
+ * @param {number} [options.compressionSizeThreshold=1024] - Files smaller than that (bytes) are not compressed.
65
+ * @param {string|((request: Object) => string)} [options.cacheControl] - The `cache-control`
66
+ * response header. Defaults to 30 days immutable for versioned urls (see `isVersioned`) and
67
+ * `private,max-age=0,must-revalidate` otherwise. `"no-store"` disables etag and mtime.
68
+ * @param {(request: Object) => boolean} [options.isVersioned] - Tells if a url is versioned
69
+ * (its content never changes). Defaults to "has a `v` search param".
70
+ * @param {boolean} [options.canReadDirectory=false] - Answer a directory with its listing
71
+ * (json or html, see {@link fetchDirectory}) instead of 403.
72
+ * @param {() => string|URL|null} [options.ENOENTFallback] - Called when the file does not exist;
73
+ * the file url it returns is served instead.
74
+ * @returns {Promise<Object|null>} Response properties, `null` when the method is not GET/HEAD.
75
+ */
27
76
  export const fetchFileSystem = async (
28
77
  request,
29
78
  helpers,
@@ -56,6 +105,9 @@ export const fetchFileSystem = async (
56
105
  if (!directoryUrlString.endsWith("/")) {
57
106
  directoryUrlString = `${directoryUrlString}/`;
58
107
  }
108
+ const canExposeSensitiveData = Boolean(
109
+ helpers && helpers.canExposeSensitiveData,
110
+ );
59
111
  let resource;
60
112
  if (request.params && "0" in request.params) {
61
113
  resource = request.params["0"];
@@ -106,15 +158,14 @@ export const fetchFileSystem = async (
106
158
  const serveFile = async (fileUrl) => {
107
159
  try {
108
160
  const readStatTiming = helpers?.timing("file service>read file stat");
109
- const fileStat = statSync(new URL(fileUrl));
161
+ const fileStat = await stat(new URL(fileUrl));
110
162
  readStatTiming?.end();
111
163
 
112
164
  if (fileStat.isDirectory()) {
113
165
  if (canReadDirectory) {
114
166
  return fetchDirectory(fileUrl, {
115
167
  headers: request.headers,
116
- canReadDirectory,
117
- rootDirectoryUrl: directoryUrl,
168
+ rootDirectoryUrl: directoryUrlString,
118
169
  });
119
170
  }
120
171
  if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
@@ -210,7 +261,9 @@ export const fetchFileSystem = async (
210
261
  ...(cacheControl ? { "cache-control": cacheControl } : {}),
211
262
  },
212
263
  },
213
- convertFileSystemErrorToResponseProperties(e) || {},
264
+ convertFileSystemErrorToResponseProperties(e, {
265
+ canExposeSensitiveData,
266
+ }) || {},
214
267
  );
215
268
  }
216
269
  };
@@ -312,20 +365,13 @@ const computeEtag = async ({
312
365
  return etagMemoryEntry.eTag;
313
366
  }
314
367
  }
315
- const fileContentAsBuffer = await new Promise((resolve, reject) => {
316
- readFile(new URL(fileUrl), (error, buffer) => {
317
- if (error) {
318
- reject(error);
319
- } else {
320
- resolve(buffer);
321
- }
322
- });
323
- });
368
+ const fileContentAsBuffer = await readFile(new URL(fileUrl));
324
369
  const eTag = bufferToEtag(fileContentAsBuffer);
325
370
  if (etagMemory) {
326
371
  if (ETAG_MEMORY_MAP.size >= etagMemoryMaxSize) {
327
- const firstKey = Array.from(ETAG_MEMORY_MAP.keys())[0];
328
- ETAG_MEMORY_MAP.delete(firstKey);
372
+ // a Map iterates in insertion order: the first key is the oldest entry
373
+ const oldestKey = ETAG_MEMORY_MAP.keys().next().value;
374
+ ETAG_MEMORY_MAP.delete(oldestKey);
329
375
  }
330
376
  ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
331
377
  }
@@ -417,10 +463,23 @@ const fileUrlToReadableStream = (fileUrl) => {
417
463
  });
418
464
  };
419
465
 
466
+ // Brotli's default quality (11) is meant for compressing assets once, ahead
467
+ // of time: it is an order of magnitude slower than gzip. For compression at
468
+ // request time, quality 4 costs about as much cpu as gzip while still
469
+ // compressing better.
470
+ const BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION = 4;
471
+
420
472
  const availableCompressionFormats = {
421
473
  br: async (fileReadableStream) => {
422
- const { createBrotliCompress } = await import("node:zlib");
423
- return fileReadableStream.pipe(createBrotliCompress());
474
+ const { constants, createBrotliCompress } = await import("node:zlib");
475
+ return fileReadableStream.pipe(
476
+ createBrotliCompress({
477
+ params: {
478
+ [constants.BROTLI_PARAM_QUALITY]:
479
+ BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION,
480
+ },
481
+ }),
482
+ );
424
483
  },
425
484
  deflate: async (fileReadableStream) => {
426
485
  const { createDeflate } = await import("node:zlib");
@@ -1,21 +1,27 @@
1
- export const convertFileSystemErrorToResponseProperties = (error) => {
1
+ // The file path belongs to the server machine: it joins the status text only
2
+ // when the server was started with canExposeSensitiveData.
3
+ export const convertFileSystemErrorToResponseProperties = (
4
+ error,
5
+ { canExposeSensitiveData },
6
+ ) => {
7
+ const location = canExposeSensitiveData ? ` at ${error.path}` : "";
2
8
  // https://iojs.org/api/errors.html#errors_eacces_permission_denied
3
9
  if (isErrorWithCode(error, "EACCES")) {
4
10
  return {
5
11
  status: 403,
6
- statusText: `EACCES: No permission to read file at ${error.path}`,
12
+ statusText: `EACCES: No permission to read file${location}`,
7
13
  };
8
14
  }
9
15
  if (isErrorWithCode(error, "EPERM")) {
10
16
  return {
11
17
  status: 403,
12
- statusText: `EPERM: No permission to read file at ${error.path}`,
18
+ statusText: `EPERM: No permission to read file${location}`,
13
19
  };
14
20
  }
15
21
  if (isErrorWithCode(error, "ENOENT")) {
16
22
  return {
17
23
  status: 404,
18
- statusText: `ENOENT: File not found at ${error.path}`,
24
+ statusText: `ENOENT: File not found${location}`,
19
25
  };
20
26
  }
21
27
  // file access may be temporarily blocked
@@ -23,7 +29,7 @@ export const convertFileSystemErrorToResponseProperties = (error) => {
23
29
  if (isErrorWithCode(error, "EBUSY")) {
24
30
  return {
25
31
  status: 503,
26
- statusText: `EBUSY: File is busy ${error.path}`,
32
+ statusText: `EBUSY: File is busy${location}`,
27
33
  headers: {
28
34
  "retry-after": 0.01, // retry in 10ms
29
35
  },
@@ -42,7 +48,7 @@ export const convertFileSystemErrorToResponseProperties = (error) => {
42
48
  if (isErrorWithCode(error, "EISDIR")) {
43
49
  return {
44
50
  status: 500,
45
- statusText: `EISDIR: Unexpected directory operation at ${error.path}`,
51
+ statusText: `EISDIR: Unexpected directory operation${location}`,
46
52
  };
47
53
  }
48
54
  return null;
@@ -1,5 +1,13 @@
1
1
  import { URL_META } from "@jsenv/url-meta";
2
2
 
3
+ /**
4
+ * Server plugin rewriting request resources before routing, from url
5
+ * patterns to their replacement: `{ "/old/*": "/new/*", "/index": "/" }`
6
+ * (patterns as in @jsenv/url-meta). The search params of the request are
7
+ * kept unless the replacement has its own.
8
+ *
9
+ * @param {Object<string, string>} resourceAliases
10
+ */
3
11
  export const serverPluginRequestAliases = (resourceAliases) => {
4
12
  const aliases = {};
5
13
  Object.keys(resourceAliases).forEach((key) => {
@@ -1,7 +1,12 @@
1
- import { pickContentEncoding } from "@jsenv/server/src/content_negotiation/pick_content_encoding.js";
2
- import { pickContentLanguage } from "@jsenv/server/src/content_negotiation/pick_content_language.js";
3
- import { pickContentType } from "@jsenv/server/src/content_negotiation/pick_content_type.js";
1
+ import { pickContentEncoding } from "../../content_negotiation/pick_content_encoding.js";
2
+ import { pickContentLanguage } from "../../content_negotiation/pick_content_language.js";
3
+ import { pickContentType } from "../../content_negotiation/pick_content_type.js";
4
4
 
5
+ /**
6
+ * Server plugin logging a warning when a response does not honor what the
7
+ * request accepts (`accept`, `accept-language`, `accept-encoding`). A
8
+ * development aid: it changes nothing in the response.
9
+ */
5
10
  export const serverPluginResponseAcceptanceCheck = () => {
6
11
  return {
7
12
  name: "jsenv:response_acceptance_check",
@@ -43,9 +48,9 @@ ${requestAcceptLanguageHeader}`);
43
48
  const requestAcceptEncodingHeader = request.headers["accept-encoding"];
44
49
  const responseContentEncodingHeader = response.headers["content-encoding"];
45
50
  if (
46
- requestAcceptLanguageHeader &&
47
- responseContentLanguageHeader &&
48
- !pickContentEncoding(request, [responseContentLanguageHeader])
51
+ requestAcceptEncodingHeader &&
52
+ responseContentEncodingHeader &&
53
+ !pickContentEncoding(request, [responseContentEncodingHeader])
49
54
  ) {
50
55
  warn(`response encoding is not in the request accepted encoding.
51
56
  --- response content-encoding header ---
@@ -2,6 +2,16 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  const routeInspectorHtmlFileUrl = import.meta
4
4
  .resolve("./client/route_inspector.html");
5
+ let routeInspectorHtml;
6
+ const readRouteInspectorHtml = () => {
7
+ if (routeInspectorHtml === undefined) {
8
+ routeInspectorHtml = readFileSync(
9
+ new URL(routeInspectorHtmlFileUrl),
10
+ "utf8",
11
+ );
12
+ }
13
+ return routeInspectorHtml;
14
+ };
5
15
 
6
16
  export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
7
17
  return {
@@ -14,11 +24,7 @@ export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
14
24
  availableMediaTypes: ["text/html"],
15
25
  declarationSource: import.meta.url,
16
26
  fetch: () => {
17
- const inspectorHtml = readFileSync(
18
- new URL(routeInspectorHtmlFileUrl),
19
- "utf8",
20
- );
21
- return new Response(inspectorHtml, {
27
+ return new Response(readRouteInspectorHtml(), {
22
28
  headers: { "content-type": "text/html" },
23
29
  });
24
30
  },
@@ -1,22 +1,36 @@
1
1
  import { createPluginsController } from "../plugins_controller.js";
2
2
 
3
+ // The hooks a server plugin can implement, in the order they run for a
4
+ // request. See docs/plugins.md for what each one is meant for.
5
+ const SERVER_PLUGIN_PROPERTIES = {
6
+ // ({ port }) once the server listens
7
+ serverListening: { type: "hook" },
8
+ // (request) => { resource | pathname, ...requestProperties } | null
9
+ redirectRequest: { type: "hook" },
10
+ // async (request, helpers) => { ...helpersToAdd } | null
11
+ augmentRouteFetchSecondArg: { type: "hook" },
12
+ // async (request) => ["permission", ...] | null, called lazily and at most
13
+ // once per request when a route declares permissions
14
+ grantPermissions: { type: "hook" },
15
+ // async (error, { request }) => response | null
16
+ handleError: { type: "hook" },
17
+ // (request, { response, warn }) to look at the response before it is sent
18
+ inspectResponse: { type: "hook" },
19
+ // (request, response) => responseToCompose | null
20
+ injectResponseProperties: { type: "hook" },
21
+ // ({ reason }) once the server is stopped
22
+ serverStopped: { type: "hook" },
23
+ // route descriptors, appended after the routes given to startServer
24
+ routes: {},
25
+ };
26
+
3
27
  export const createServerPluginsController = async (serverPlugins) => {
4
- const jsenvServerPluginsController = await createPluginsController({
28
+ const serverPluginsController = await createPluginsController({
5
29
  plugins: serverPlugins,
6
30
  pluginDescription: {
7
31
  name: "server plugin",
8
- properties: {
9
- serverListening: { type: "hook" },
10
- redirectRequest: { type: "hook" },
11
- augmentRouteFetchSecondArg: { type: "hook" },
12
- grantPermissions: { type: "hook" },
13
- handleError: { type: "hook" },
14
- onResponsePush: { type: "hook" },
15
- injectResponseProperties: { type: "hook" },
16
- serverStopped: { type: "hook" },
17
- routes: {}, // routes are handled separately (used to populate the router)
18
- },
32
+ properties: SERVER_PLUGIN_PROPERTIES,
19
33
  },
20
34
  });
21
- return jsenvServerPluginsController;
35
+ return serverPluginsController;
22
36
  };
@@ -1,6 +1,30 @@
1
1
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
2
2
  import { createObservable } from "./interfacing_with_node/observable.js";
3
3
 
4
+ /**
5
+ * A response whose body is written over time (long polling, progress
6
+ * reporting). Return it from a route `fetch`: the headers are sent right away
7
+ * and the handler writes the body chunks whenever it wants.
8
+ *
9
+ * @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
10
+ * Receives `write` and `end`. If it returns a function, that function runs
11
+ * when the client disconnects before `end` was called (cleanup).
12
+ * @param {Object} [init]
13
+ * @param {number} [init.status=200]
14
+ * @param {string} [init.statusText]
15
+ * @param {Object} [init.headers] - `content-type` decides whether the first
16
+ * (empty) chunk is text or binary; defaults to text/plain.
17
+ *
18
+ * @example
19
+ * {
20
+ * endpoint: "GET /progress",
21
+ * fetch: () => new ProgressiveResponse(({ write, end }) => {
22
+ * const interval = setInterval(() => write("."), 100);
23
+ * setTimeout(() => { clearInterval(interval); end(); }, 1000);
24
+ * return () => clearInterval(interval);
25
+ * }),
26
+ * }
27
+ */
4
28
  export class ProgressiveResponse {
5
29
  constructor(responseBodyHandler, { status = 200, statusText, headers } = {}) {
6
30
  const contentType = headers ? headers["content-type"] : "text/plain";
@@ -0,0 +1,52 @@
1
+ export const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
2
+ for (const permission of permissionsRequired) {
3
+ if (!permissionsSet.has(permission)) {
4
+ return false;
5
+ }
6
+ }
7
+ return true;
8
+ };
9
+
10
+ // Both helpers share one cursor over the "grantPermissions" hooks so that each
11
+ // plugin runs at most once per request: hasPermissions stops as soon as the
12
+ // required permissions are all granted, getAllPermissions drains the plugins
13
+ // left.
14
+ export const createPermissionHelpers = (serverPluginsController, request) => {
15
+ const permissionsSet = new Set();
16
+ const nextPermissionsHook = serverPluginsController.createAsyncHookIterator(
17
+ "grantPermissions",
18
+ request,
19
+ );
20
+ const drainPermissionsUntil = async (permissionsRequired) => {
21
+ for (;;) {
22
+ if (
23
+ permissionsRequired !== undefined &&
24
+ permissionsSatisfy(permissionsSet, permissionsRequired)
25
+ ) {
26
+ return true;
27
+ }
28
+ const { done, value } = await nextPermissionsHook();
29
+ if (done) {
30
+ break;
31
+ }
32
+ if (Array.isArray(value)) {
33
+ for (const permission of value) {
34
+ permissionsSet.add(permission);
35
+ }
36
+ }
37
+ }
38
+ return false;
39
+ };
40
+
41
+ const getAllPermissions = async () => {
42
+ await drainPermissionsUntil(undefined);
43
+ return permissionsSet;
44
+ };
45
+ const hasPermissions = async (permissionsRequired) => {
46
+ if (permissionsRequired.length === 0) {
47
+ return true;
48
+ }
49
+ return drainPermissionsUntil(permissionsRequired);
50
+ };
51
+ return { getAllPermissions, hasPermissions };
52
+ };
@@ -13,6 +13,8 @@ import { pickContentEncoding } from "../content_negotiation/pick_content_encodin
13
13
  import { pickContentLanguage } from "../content_negotiation/pick_content_language.js";
14
14
  import { pickContentType } from "../content_negotiation/pick_content_type.js";
15
15
  import { pickContentVersion } from "../content_negotiation/pick_content_version.js";
16
+ import { asReasonPhrase } from "../internal/reason_phrase.js";
17
+ import { permissionsSatisfy } from "./permissions.js";
16
18
 
17
19
  const routeInspectorUrl = `/.internal/route_inspector`;
18
20
 
@@ -28,7 +30,7 @@ const HTTP_METHODS = [
28
30
 
29
31
  export const createRouter = (
30
32
  routeDescriptionArray,
31
- { optionsFallback, logLevel, redirect = "manual" } = {},
33
+ { optionsFallback, logLevel } = {},
32
34
  ) => {
33
35
  const logger = createLogger({ logLevel });
34
36
  const routeSet = new Set();
@@ -285,7 +287,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
285
287
 
286
288
  if (fetchReturnValue instanceof Response) {
287
289
  status = fetchReturnValue.status;
288
- statusText = fetchReturnValue.statusText;
290
+ // a Response built without statusText has an empty one: the standard
291
+ // phrase for that status is used instead
292
+ statusText = fetchReturnValue.statusText || undefined;
289
293
  headers = Object.fromEntries(fetchReturnValue.headers);
290
294
  body = fetchReturnValue.body;
291
295
  } else if (
@@ -293,7 +297,10 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
293
297
  typeof fetchReturnValue === "object"
294
298
  ) {
295
299
  status = fetchReturnValue.status || 404;
296
- statusText = fetchReturnValue.statusText;
300
+ statusText =
301
+ fetchReturnValue.statusText === undefined
302
+ ? undefined
303
+ : asReasonPhrase(fetchReturnValue.statusText);
297
304
  statusMessage = fetchReturnValue.statusMessage;
298
305
  headers = fetchReturnValue.headers || {};
299
306
  body = fetchReturnValue.body;
@@ -304,30 +311,6 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
304
311
  );
305
312
  }
306
313
 
307
- if (
308
- redirect === "follow" &&
309
- isRedirectStatus(status) &&
310
- headers["location"]
311
- ) {
312
- const redirectUrl = new URL(headers["location"], request.url);
313
- if (redirectUrl.origin === new URL(request.url).origin) {
314
- const redirectRequest = {
315
- ...request,
316
- url: redirectUrl.href,
317
- resource:
318
- redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
319
- // GET for 301/302/303, preserve method for 307/308
320
- method: status === 307 || status === 308 ? request.method : "GET",
321
- params: {},
322
- };
323
- const redirectedResponse = await matchRoutes(redirectRequest);
324
- onRouteMatch(route);
325
- onResponseHeaders(request, route, redirectedResponse.headers || {});
326
- return redirectedResponse;
327
- }
328
- // redirect to other origins are left to the client to handle
329
- }
330
-
331
314
  onRouteMatch(route);
332
315
  onResponseHeaders(request, route, headers);
333
316
  return { status, statusText, statusMessage, headers, body, timing };
@@ -372,7 +355,10 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
372
355
  }
373
356
  }
374
357
  if (!route.matchMethod(request.method)) {
375
- if (!route.isFallback) {
358
+ // a 405 asserts the resource exists with other methods: a route
359
+ // matching any resource ("GET *") cannot assert that, so it does
360
+ // not turn an unknown resource into a 405
361
+ if (!route.isFallback && route.resource !== "*") {
376
362
  wouldHaveMatched.methodSet.add(route.method);
377
363
  }
378
364
  continue;
@@ -650,25 +636,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
650
636
  return router;
651
637
  };
652
638
 
653
- /**
654
- * Adds a route to the router.
655
- *
656
- * @param {Object} params - Route configuration object
657
- * @param {string} params.endpoint - String in format "METHOD /resource/path" (e.g. "GET /users/:id")
658
- * @param {Object} [params.headers] - Optional headers pattern to match
659
- * @param {Array<string>} [params.availableMediaTypes=[]] - Content types this route can produce
660
- * @param {Array<string>} [params.availableLanguages=[]] - Languages this route can respond with
661
- * @param {Array<string>} [params.availableEncodings=[]] - Encodings this route supports
662
- * @param {Array<string>} [params.acceptedMediaTypes=[]] - Content types this route accepts (for POST/PATCH/PUT)
663
- * @param {Function} params.fetch - Function to generate response for matching requests
664
- * @throws {TypeError} If endpoint is not a string
665
- * @returns {void}
666
- */
639
+ // Turns a route descriptor (documented on startServer's `routes` param) into
640
+ // a route: matchers for the method, the resource and the headers, plus what
641
+ // the inspector shows. Throws on a malformed descriptor.
667
642
  const createRoute = ({
668
643
  endpoint,
669
644
  description,
670
645
  headers,
671
- service,
672
646
  serverPlugin,
673
647
  permissionsRequired,
674
648
  permissionsToSee,
@@ -680,7 +654,6 @@ const createRoute = ({
680
654
  fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
681
655
  clientCodeExample,
682
656
  isFallback,
683
- subroutes,
684
657
  declarationSource,
685
658
  }) => {
686
659
  if (!endpoint || typeof endpoint !== "string") {
@@ -720,7 +693,6 @@ const createRoute = ({
720
693
  method,
721
694
  resource,
722
695
  description,
723
- service,
724
696
  serverPlugin,
725
697
  permissionsRequired,
726
698
  permissionsToSee,
@@ -803,14 +775,10 @@ const createRoute = ({
803
775
  resourcePattern,
804
776
  isForWebSocket,
805
777
  isFallback,
806
- subroutes,
807
778
  };
808
779
  return route;
809
780
  };
810
781
 
811
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
812
- const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
813
-
814
782
  // Returns a denied response if the request does not satisfy route access/visible config.
815
783
  // Returns null if access is granted.
816
784
  const checkRouteAccess = async (
@@ -847,15 +815,6 @@ const checkRouteAccess = async (
847
815
  return createRouteNotFoundResponse(request);
848
816
  };
849
817
 
850
- const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
851
- for (const p of permissionsRequired) {
852
- if (!permissionsSet.has(p)) {
853
- return false;
854
- }
855
- }
856
- return true;
857
- };
858
-
859
818
  const createForbiddenResponse = () => {
860
819
  return {
861
820
  status: 403,
@@ -931,7 +890,6 @@ const createNotAcceptableResponse = (
931
890
  ) => {
932
891
  const unsupported = [];
933
892
  const headers = {};
934
- const data = {};
935
893
 
936
894
  if (availableMediaTypes.length) {
937
895
  const requestAcceptHeader = request.headers["accept"];
@@ -939,11 +897,6 @@ const createNotAcceptableResponse = (
939
897
  // Use a non-standard but semantic header name
940
898
  headers["available-media-types"] = availableMediaTypes.join(", ");
941
899
 
942
- Object.assign(data, {
943
- requestAcceptHeader,
944
- availableMediaTypes,
945
- });
946
-
947
900
  unsupported.push({
948
901
  type: "content-type",
949
902
  message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
@@ -956,11 +909,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
956
909
  // Use a non-standard but semantic header name
957
910
  headers["available-languages"] = availableLanguages.join(", ");
958
911
 
959
- Object.assign(data, {
960
- requestAcceptLanguageHeader,
961
- availableLanguages,
962
- });
963
-
964
912
  unsupported.push({
965
913
  type: "language",
966
914
  message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
@@ -973,11 +921,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
973
921
  // Use a non-standard but semantic header name
974
922
  headers["available-versions"] = availableVersions.join(", ");
975
923
 
976
- Object.assign(data, {
977
- requestAcceptVersionHeader,
978
- availableLanguages,
979
- });
980
-
981
924
  unsupported.push({
982
925
  type: "version",
983
926
  message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
@@ -990,11 +933,6 @@ Available versions: ${availableVersions.join(", ")}.`,
990
933
  // Use a non-standard but semantic header name
991
934
  headers["available-encodings"] = availableEncodings.join(", ");
992
935
 
993
- Object.assign(data, {
994
- requestAcceptEncodingHeader,
995
- availableEncodings,
996
- });
997
-
998
936
  unsupported.push({
999
937
  type: "encoding",
1000
938
  message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
@@ -1031,7 +969,7 @@ const createMethodNotAllowedResponse = (
1031
969
  return {
1032
970
  status: 405,
1033
971
  statusText: "Method Not Allowed",
1034
- statusmessage: `The HTTP method "${request.method}" is not supported for this resource.
972
+ statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
1035
973
  Allowed methods: ${allowedMethods.join(", ")}`,
1036
974
  headers: {
1037
975
  allow: allowedMethods.join(", "),
@@ -311,7 +311,9 @@ const createServerEvents = ({
311
311
  if (history) {
312
312
  addEventToHistory(event);
313
313
  }
314
- logger.debug(`send "${event.type}" event to ${clientArray.size} client(s)`);
314
+ logger.debug(
315
+ `send "${event.type}" event to ${clientArray.length} client(s)`,
316
+ );
315
317
  for (const client of clientArray) {
316
318
  client.sendEvent(event);
317
319
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseMultipleHeader,
3
3
  stringifyMultipleHeader,
4
- } from "../internal/multiple-header.js";
4
+ } from "../internal/multiple_header.js";
5
5
 
6
6
  // to predict order in chrome devtools we should put a,b,c,d,e or something
7
7
  // because in chrome dev tools they are shown in alphabetic order