@jsenv/server 17.6.0 → 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 (54) hide show
  1. package/README.md +47 -48
  2. package/dist/client/default_body_4xx_5xx/4xx.html +1 -1
  3. package/dist/jsenv_server.js +1332 -1269
  4. package/dist/ws/ws.js +3 -1
  5. package/package.json +5 -5
  6. package/src/content_negotiation/pick_content_encoding.js +11 -1
  7. package/src/content_negotiation/pick_content_language.js +11 -1
  8. package/src/content_negotiation/pick_content_type.js +11 -1
  9. package/src/content_negotiation/pick_content_version.js +1 -1
  10. package/src/interfacing_with_node/from_node_request.js +154 -178
  11. package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
  12. package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
  13. package/src/interfacing_with_node/write_node_response.js +59 -103
  14. package/src/internal/allowed_hosts.js +85 -0
  15. package/src/internal/escape_html.js +11 -0
  16. package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
  17. package/src/internal/listen.js +32 -5
  18. package/src/internal/{listenRequest.js → listen_request.js} +1 -1
  19. package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
  20. package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
  21. package/src/internal/reason_phrase.js +17 -0
  22. package/src/internal/response_composition.js +11 -2
  23. package/src/internal/secure_server.js +22 -0
  24. package/src/internal/server_origins.js +78 -0
  25. package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
  26. package/src/internal/track_server_pending_connections.js +50 -0
  27. package/src/internal/track_server_pending_requests.js +47 -0
  28. package/src/plugins/cors/server_plugin_cors.js +39 -6
  29. package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
  30. package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
  31. package/src/plugins/filesystem/fetch_directory.js +16 -3
  32. package/src/plugins/filesystem/fetch_file.js +80 -21
  33. package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
  34. package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
  35. package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
  36. package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
  37. package/src/plugins/server_plugins_controller.js +27 -13
  38. package/src/progressive_response.js +24 -0
  39. package/src/router/permissions.js +52 -0
  40. package/src/router/router.js +14 -79
  41. package/src/server_events.js +3 -1
  42. package/src/server_timing/timing_header.js +1 -1
  43. package/src/start_server.js +266 -416
  44. package/src/internal/headersFromString.js +0 -21
  45. package/src/internal/headersToObject.js +0 -7
  46. package/src/internal/headersToString.js +0 -16
  47. package/src/internal/listenClientError.js +0 -27
  48. package/src/internal/trackServerPendingConnections.js +0 -145
  49. package/src/internal/trackServerPendingRequests.js +0 -68
  50. package/src/request_body_handling.js +0 -22
  51. /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
  52. /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
  53. /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
  54. /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 };
@@ -650,25 +633,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
650
633
  return router;
651
634
  };
652
635
 
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
- */
636
+ // Turns a route descriptor (documented on startServer's `routes` param) into
637
+ // a route: matchers for the method, the resource and the headers, plus what
638
+ // the inspector shows. Throws on a malformed descriptor.
667
639
  const createRoute = ({
668
640
  endpoint,
669
641
  description,
670
642
  headers,
671
- service,
672
643
  serverPlugin,
673
644
  permissionsRequired,
674
645
  permissionsToSee,
@@ -680,7 +651,6 @@ const createRoute = ({
680
651
  fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
681
652
  clientCodeExample,
682
653
  isFallback,
683
- subroutes,
684
654
  declarationSource,
685
655
  }) => {
686
656
  if (!endpoint || typeof endpoint !== "string") {
@@ -720,7 +690,6 @@ const createRoute = ({
720
690
  method,
721
691
  resource,
722
692
  description,
723
- service,
724
693
  serverPlugin,
725
694
  permissionsRequired,
726
695
  permissionsToSee,
@@ -803,14 +772,10 @@ const createRoute = ({
803
772
  resourcePattern,
804
773
  isForWebSocket,
805
774
  isFallback,
806
- subroutes,
807
775
  };
808
776
  return route;
809
777
  };
810
778
 
811
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
812
- const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
813
-
814
779
  // Returns a denied response if the request does not satisfy route access/visible config.
815
780
  // Returns null if access is granted.
816
781
  const checkRouteAccess = async (
@@ -847,15 +812,6 @@ const checkRouteAccess = async (
847
812
  return createRouteNotFoundResponse(request);
848
813
  };
849
814
 
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
815
  const createForbiddenResponse = () => {
860
816
  return {
861
817
  status: 403,
@@ -931,7 +887,6 @@ const createNotAcceptableResponse = (
931
887
  ) => {
932
888
  const unsupported = [];
933
889
  const headers = {};
934
- const data = {};
935
890
 
936
891
  if (availableMediaTypes.length) {
937
892
  const requestAcceptHeader = request.headers["accept"];
@@ -939,11 +894,6 @@ const createNotAcceptableResponse = (
939
894
  // Use a non-standard but semantic header name
940
895
  headers["available-media-types"] = availableMediaTypes.join(", ");
941
896
 
942
- Object.assign(data, {
943
- requestAcceptHeader,
944
- availableMediaTypes,
945
- });
946
-
947
897
  unsupported.push({
948
898
  type: "content-type",
949
899
  message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
@@ -956,11 +906,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
956
906
  // Use a non-standard but semantic header name
957
907
  headers["available-languages"] = availableLanguages.join(", ");
958
908
 
959
- Object.assign(data, {
960
- requestAcceptLanguageHeader,
961
- availableLanguages,
962
- });
963
-
964
909
  unsupported.push({
965
910
  type: "language",
966
911
  message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
@@ -973,11 +918,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
973
918
  // Use a non-standard but semantic header name
974
919
  headers["available-versions"] = availableVersions.join(", ");
975
920
 
976
- Object.assign(data, {
977
- requestAcceptVersionHeader,
978
- availableLanguages,
979
- });
980
-
981
921
  unsupported.push({
982
922
  type: "version",
983
923
  message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
@@ -990,11 +930,6 @@ Available versions: ${availableVersions.join(", ")}.`,
990
930
  // Use a non-standard but semantic header name
991
931
  headers["available-encodings"] = availableEncodings.join(", ");
992
932
 
993
- Object.assign(data, {
994
- requestAcceptEncodingHeader,
995
- availableEncodings,
996
- });
997
-
998
933
  unsupported.push({
999
934
  type: "encoding",
1000
935
  message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
@@ -1031,7 +966,7 @@ const createMethodNotAllowedResponse = (
1031
966
  return {
1032
967
  status: 405,
1033
968
  statusText: "Method Not Allowed",
1034
- statusmessage: `The HTTP method "${request.method}" is not supported for this resource.
969
+ statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
1035
970
  Allowed methods: ${allowedMethods.join(", ")}`,
1036
971
  headers: {
1037
972
  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