@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
@@ -0,0 +1,78 @@
1
+ import { isIP } from "node:net";
2
+ import { applyDnsResolution } from "./dns_resolution.js";
3
+ import { parseHostname } from "./hostname_parser.js";
4
+ import { createIpGetters } from "./server_ips.js";
5
+
6
+ // Decides what the server listens to and the origins it can be reached at:
7
+ // - local: favors the hostname (an https certificate is for a hostname, not an ip)
8
+ // - localip: the ip behind the hostname
9
+ // - externalip: the machine ip on the network, when the server accepts it
10
+ // The port is not known yet: the origins are completed once listening.
11
+ export const resolveServerOrigins = async ({
12
+ https,
13
+ hostname,
14
+ acceptAnyIp,
15
+ preferIpv6,
16
+ }) => {
17
+ const createOrigin = (host) => {
18
+ const protocol = https ? "https" : "http";
19
+ if (isIP(host) === 6) {
20
+ return `${protocol}://[${host}]`;
21
+ }
22
+ return `${protocol}://${host}`;
23
+ };
24
+
25
+ const serverOrigins = {
26
+ local: "",
27
+ };
28
+ const ipGetters = createIpGetters();
29
+ let hostnameToListen;
30
+ if (acceptAnyIp) {
31
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
32
+ serverOrigins.local = createOrigin(firstInternalIp);
33
+ serverOrigins.localip = createOrigin(firstInternalIp);
34
+ const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
35
+ serverOrigins.externalip = createOrigin(firstExternalIp);
36
+ hostnameToListen = preferIpv6 ? "::" : "0.0.0.0";
37
+ } else {
38
+ hostnameToListen = hostname;
39
+ }
40
+ const hostnameInfo = parseHostname(hostname);
41
+ if (hostnameInfo.type === "ip") {
42
+ if (acceptAnyIp) {
43
+ throw new Error(
44
+ `hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
45
+ );
46
+ }
47
+
48
+ preferIpv6 = hostnameInfo.version === 6;
49
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
50
+ serverOrigins.local = createOrigin(firstInternalIp);
51
+ serverOrigins.localip = createOrigin(firstInternalIp);
52
+ if (hostnameInfo.label === "unspecified") {
53
+ const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
54
+ serverOrigins.externalip = createOrigin(firstExternalIp);
55
+ } else if (hostnameInfo.label === "loopback") {
56
+ // nothing
57
+ } else {
58
+ serverOrigins.local = createOrigin(hostname);
59
+ }
60
+ } else {
61
+ const hostnameDnsResolution = await applyDnsResolution(hostname, {
62
+ verbatim: true,
63
+ });
64
+ if (hostnameDnsResolution) {
65
+ const hostnameIp = hostnameDnsResolution.address;
66
+ serverOrigins.localip = createOrigin(hostnameIp);
67
+ serverOrigins.local = createOrigin(hostname);
68
+ } else {
69
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
70
+ // fallback to internal ip because there is no ip
71
+ // associated to this hostname on operating system (in hosts file)
72
+ hostname = firstInternalIp;
73
+ hostnameToListen = firstInternalIp;
74
+ serverOrigins.local = createOrigin(firstInternalIp);
75
+ }
76
+ }
77
+ return { hostname, hostnameToListen, serverOrigins };
78
+ };
@@ -1,12 +1,18 @@
1
- /**
2
-
3
- https://stackoverflow.com/a/42019773/2634179
4
-
5
- */
1
+ /*
2
+ * A net server accepting both http and tls on the same port: the first byte
3
+ * of each connection tells which one it is (0x16 is a TLS handshake) and the
4
+ * socket is handed to the matching http or tls server.
5
+ * https://stackoverflow.com/a/42019773/2634179
6
+ *
7
+ * The http and tls servers are exposed as _httpServer/_tlsServer because
8
+ * "request", "upgrade" and "clientError" are emitted on them, not on the net
9
+ * server (see listen_request.js).
10
+ */
6
11
 
7
12
  import http from "node:http";
8
13
  import net from "node:net";
9
- import { listenEvent } from "./listenEvent.js";
14
+ import { listenEvent } from "./listen_event.js";
15
+ import { createSecureServer } from "./secure_server.js";
10
16
 
11
17
  export const createPolyglotServer = async ({
12
18
  http2 = false,
@@ -60,38 +66,11 @@ export const createPolyglotServer = async ({
60
66
  return netServer;
61
67
  };
62
68
 
63
- // The async part is just to lazyly import "http2" or "https"
64
- // so that these module are parsed only if used.
65
- // https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
66
- const createSecureServer = async ({
67
- certificate,
68
- privateKey,
69
- http2,
70
- http1Allowed,
71
- }) => {
72
- if (http2) {
73
- const { createSecureServer } = await import("node:http2");
74
- return createSecureServer({
75
- cert: certificate,
76
- key: privateKey,
77
- allowHTTP1: http1Allowed,
78
- });
79
- }
80
-
81
- const { createServer } = await import("node:https");
82
- return createServer({
83
- cert: certificate,
84
- key: privateKey,
85
- });
86
- };
87
-
88
69
  const detectSocketProtocol = (socket, protocolDetectedCallback) => {
89
- let removeOnceReadableListener = () => {};
90
-
91
70
  const tryToRead = () => {
92
71
  const buffer = socket.read(1);
93
72
  if (buffer === null) {
94
- removeOnceReadableListener = socket.once("readable", tryToRead);
73
+ socket.once("readable", tryToRead);
95
74
  return;
96
75
  }
97
76
 
@@ -109,8 +88,4 @@ const detectSocketProtocol = (socket, protocolDetectedCallback) => {
109
88
  };
110
89
 
111
90
  tryToRead();
112
-
113
- return () => {
114
- removeOnceReadableListener();
115
- };
116
91
  };
@@ -0,0 +1,50 @@
1
+ import { listenEvent } from "./listen_event.js";
2
+
3
+ export const trackServerPendingConnections = (nodeServer) => {
4
+ const pendingConnections = new Set();
5
+
6
+ const removeConnectionListener = listenEvent(
7
+ nodeServer,
8
+ "connection",
9
+ (connection) => {
10
+ pendingConnections.add(connection);
11
+ listenEvent(
12
+ connection,
13
+ "close",
14
+ () => {
15
+ pendingConnections.delete(connection);
16
+ },
17
+ { once: true },
18
+ );
19
+ },
20
+ );
21
+
22
+ const stop = async (reason) => {
23
+ removeConnectionListener();
24
+ const pendingConnectionsArray = Array.from(pendingConnections);
25
+ pendingConnections.clear();
26
+ await Promise.all(
27
+ pendingConnectionsArray.map((pendingConnection) => {
28
+ return destroyConnection(pendingConnection, reason);
29
+ }),
30
+ );
31
+ };
32
+
33
+ return { stop };
34
+ };
35
+
36
+ const destroyConnection = (connection, reason) => {
37
+ return new Promise((resolve, reject) => {
38
+ connection.destroy(reason, (error) => {
39
+ if (error) {
40
+ if (error === reason || error.code === "ENOTCONN") {
41
+ resolve();
42
+ } else {
43
+ reject(error);
44
+ }
45
+ } else {
46
+ resolve();
47
+ }
48
+ });
49
+ });
50
+ };
@@ -0,0 +1,47 @@
1
+ import { listenRequest } from "./listen_request.js";
2
+ import { asReasonPhrase } from "./reason_phrase.js";
3
+
4
+ export const trackServerPendingRequests = (nodeServer) => {
5
+ const pendingClients = new Set();
6
+
7
+ const removeRequestListener = listenRequest(
8
+ nodeServer,
9
+ (nodeRequest, nodeResponse) => {
10
+ const client = { nodeRequest, nodeResponse };
11
+ pendingClients.add(client);
12
+ nodeResponse.once("close", () => {
13
+ pendingClients.delete(client);
14
+ });
15
+ },
16
+ );
17
+
18
+ const stop = async ({ status, reason }) => {
19
+ removeRequestListener();
20
+ const pendingClientsArray = Array.from(pendingClients);
21
+ pendingClients.clear();
22
+ await Promise.all(
23
+ pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
24
+ if (nodeResponse.headersSent === false) {
25
+ if (nodeRequest.stream) {
26
+ // http2 has no reason phrase
27
+ nodeResponse.writeHead(status);
28
+ } else {
29
+ nodeResponse.writeHead(status, asReasonPhrase(reason));
30
+ }
31
+ }
32
+ return new Promise((resolve) => {
33
+ if (nodeResponse.destroyed) {
34
+ resolve();
35
+ return;
36
+ }
37
+ nodeResponse.once("close", () => {
38
+ resolve();
39
+ });
40
+ nodeResponse.destroy();
41
+ });
42
+ }),
43
+ );
44
+ };
45
+
46
+ return { stop };
47
+ };
@@ -13,6 +13,32 @@ export const jsenvAccessControlAllowedMethods = [
13
13
  "OPTIONS",
14
14
  ];
15
15
 
16
+ /**
17
+ * Server plugin adding the CORS response headers to every response, including
18
+ * errors (a browser treats a 500 without them as a CORS failure). Disabled
19
+ * (returns no plugin) unless `accessControlAllowRequestOrigin` is true or
20
+ * `accessControlAllowedOrigins` is non empty.
21
+ *
22
+ * @param {Object} [params]
23
+ * @param {Array<string>} [params.accessControlAllowedOrigins=[]] - Origins allowed to
24
+ * read responses. `*` stands for any run of characters except "/":
25
+ * `"https://pr-*-my-app.fly.dev"` matches every preview deployment.
26
+ * @param {Array<string>} [params.accessControlAllowedMethods] - Defaults to
27
+ * GET, POST, PUT, DELETE, OPTIONS.
28
+ * @param {Array<string>} [params.accessControlAllowedHeaders] - Defaults to `["x-requested-with"]`.
29
+ * @param {boolean} [params.accessControlAllowRequestOrigin=false] - Reflect any request
30
+ * origin, whatever `accessControlAllowedOrigins` says.
31
+ * @param {boolean} [params.accessControlAllowRequestMethod=false] - Also allow the method a
32
+ * preflight asks for (`access-control-request-method`).
33
+ * @param {boolean} [params.accessControlAllowRequestHeaders=false] - Also allow the headers a
34
+ * preflight asks for (`access-control-request-headers`).
35
+ * @param {boolean} [params.accessControlAllowCredentials=false] - Send
36
+ * `access-control-allow-credentials: true`.
37
+ * @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
38
+ * @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
39
+ * allowed origin can read resource timing.
40
+ * @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
41
+ */
16
42
  export const serverPluginCORS = ({
17
43
  accessControlAllowedOrigins = [],
18
44
  accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
@@ -126,12 +152,7 @@ const generateAccessControlHeaders = ({
126
152
  // If no origin matches we fall back to "*" (only when not using credentials).
127
153
  let allowOrigin = null;
128
154
 
129
- const requestOrigin =
130
- "origin" in headers && headers.origin !== "null"
131
- ? headers.origin
132
- : "referer" in headers
133
- ? new URL(headers.referer).origin
134
- : null;
155
+ const requestOrigin = readRequestOrigin(headers);
135
156
 
136
157
  if (requestOrigin) {
137
158
  if (allowedOriginChecker.isAllowed(requestOrigin)) {
@@ -191,3 +212,15 @@ const generateAccessControlHeaders = ({
191
212
  ...(vary.length ? { vary: vary.join(", ") } : {}),
192
213
  };
193
214
  };
215
+
216
+ // the referer is a fallback for clients not sending "origin"; it comes from
217
+ // the network and may not be a url at all
218
+ const readRequestOrigin = (headers) => {
219
+ if ("origin" in headers && headers.origin !== "null") {
220
+ return headers.origin;
221
+ }
222
+ if ("referer" in headers && URL.canParse(headers.referer)) {
223
+ return new URL(headers.referer).origin;
224
+ }
225
+ return null;
226
+ };
@@ -1,9 +1,22 @@
1
+ import { STATUS_CODES } from "node:http";
1
2
  import { readFileSync } from "node:fs";
2
3
 
3
4
  import { pickContentType } from "../../content_negotiation/pick_content_type.js";
5
+ import { escapeHtml } from "../../internal/escape_html.js";
6
+ import { asReasonPhrase } from "../../internal/reason_phrase.js";
4
7
  import { replacePlaceholdersInHtml } from "../../replace_placeholder_in_html.js";
5
8
 
6
9
  const clientErrorHtmlTemplateFileUrl = import.meta.resolve("./client/4xx.html");
10
+ let clientErrorHtmlTemplate;
11
+ const readClientErrorHtmlTemplate = () => {
12
+ if (clientErrorHtmlTemplate === undefined) {
13
+ clientErrorHtmlTemplate = readFileSync(
14
+ new URL(clientErrorHtmlTemplateFileUrl),
15
+ "utf8",
16
+ );
17
+ }
18
+ return clientErrorHtmlTemplate;
19
+ };
7
20
 
8
21
  export const serverPluginDefaultBody4xx5xx = () => {
9
22
  return {
@@ -13,10 +26,7 @@ export const serverPluginDefaultBody4xx5xx = () => {
13
26
  if (responseProperties.body !== undefined) {
14
27
  return null;
15
28
  }
16
- if (responseProperties.status >= 400 && responseProperties.status < 500) {
17
- return generateBadStatusResponse(request, responseProperties);
18
- }
19
- if (responseProperties.status >= 500 && responseProperties.status < 600) {
29
+ if (responseProperties.status >= 400 && responseProperties.status < 600) {
20
30
  return generateBadStatusResponse(request, responseProperties);
21
31
  }
22
32
  return null;
@@ -28,38 +38,17 @@ const generateBadStatusResponse = (
28
38
  request,
29
39
  { status, statusText, statusMessage },
30
40
  ) => {
41
+ statusText = asReasonPhrase(statusText || STATUS_CODES[status] || "");
31
42
  const contentTypeNegotiated = pickContentType(request, [
32
43
  "text/html",
33
44
  "text/plain",
34
45
  "application/json",
35
46
  ]);
36
47
  if (contentTypeNegotiated === "text/html") {
37
- const htmlTemplate = readFileSync(
38
- new URL(clientErrorHtmlTemplateFileUrl),
39
- "utf8",
40
- );
41
- if (statusMessage) {
42
- statusMessage = statusMessage.replace(/https?:\/\/\S+/g, (url) => {
43
- return `<a href="${url}">${url}</a>`;
44
- });
45
- statusMessage = statusMessage.replace(
46
- /(^|\s)(\/\S+)/g,
47
- (match, startOrSpace, resource) => {
48
- let end = "";
49
- if (resource[resource.length - 1] === ".") {
50
- resource = resource.slice(0, -1);
51
- end = ".";
52
- }
53
- return `${startOrSpace}<a href="${resource}">${resource}</a>${end}`;
54
- },
55
- );
56
- statusMessage = statusMessage.replace(/\r\n|\r|\n/g, "<br />");
57
- }
58
-
59
- const html = replacePlaceholdersInHtml(htmlTemplate, {
48
+ const html = replacePlaceholdersInHtml(readClientErrorHtmlTemplate(), {
60
49
  status,
61
- statusText,
62
- statusMessage: statusMessage || "",
50
+ statusText: escapeHtml(statusText),
51
+ statusMessage: statusMessage ? statusMessageToHtml(statusMessage) : "",
63
52
  });
64
53
  return new Response(html, {
65
54
  headers: { "content-type": "text/html" },
@@ -81,3 +70,22 @@ const generateBadStatusResponse = (
81
70
  },
82
71
  );
83
72
  };
73
+
74
+ // A status message echoes the request (its url for instance): it is escaped
75
+ // before urls and resources are turned into links.
76
+ const statusMessageToHtml = (statusMessage) => {
77
+ let html = escapeHtml(statusMessage);
78
+ html = html.replace(/https?:\/\/\S+/g, (url) => {
79
+ return `<a href="${url}">${url}</a>`;
80
+ });
81
+ html = html.replace(/(^|\s)(\/\S+)/g, (match, startOrSpace, resource) => {
82
+ let end = "";
83
+ if (resource[resource.length - 1] === ".") {
84
+ resource = resource.slice(0, -1);
85
+ end = ".";
86
+ }
87
+ return `${startOrSpace}<a href="${resource}">${resource}</a>${end}`;
88
+ });
89
+ html = html.replace(/\r\n|\r|\n/g, "<br />");
90
+ return html;
91
+ };
@@ -4,7 +4,30 @@ import { pickContentType } from "../../content_negotiation/pick_content_type.js"
4
4
  import { replacePlaceholdersInHtml } from "../../replace_placeholder_in_html.js";
5
5
 
6
6
  const internalErrorHtmlFileUrl = import.meta.resolve("./client/500.html");
7
+ let internalErrorHtmlTemplate;
8
+ const readInternalErrorHtmlTemplate = () => {
9
+ if (internalErrorHtmlTemplate === undefined) {
10
+ internalErrorHtmlTemplate = readFileSync(
11
+ new URL(internalErrorHtmlFileUrl),
12
+ "utf8",
13
+ );
14
+ }
15
+ return internalErrorHtmlTemplate;
16
+ };
7
17
 
18
+ /**
19
+ * Server plugin turning an error thrown while handling a request into a 500
20
+ * response (html, text or json depending on what the request accepts).
21
+ * Without a plugin handling errors, a route that throws makes the process
22
+ * exit. Put it last: it catches every error, so plugins handling a subset of
23
+ * them must come before. (An error exposing `asResponse()` never reaches it:
24
+ * the server answers it directly.)
25
+ *
26
+ * @param {Object} [params]
27
+ * @param {boolean} [params.sendErrorDetails=false] - Put the error stack (and
28
+ * its own properties for json) in the response. Development only: a stack
29
+ * reveals file paths and code.
30
+ */
8
31
  export const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
9
32
  return {
10
33
  name: "jsenv:error_handler",
@@ -13,9 +36,6 @@ export const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
13
36
  serverInternalError === null ||
14
37
  (typeof serverInternalError !== "object" &&
15
38
  typeof serverInternalError !== "function");
16
- if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
17
- return serverInternalError.asResponse();
18
- }
19
39
  const dataToSend = serverInternalErrorIsAPrimitive
20
40
  ? {
21
41
  code: "VALUE_THROWED",
@@ -34,18 +54,14 @@ export const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
34
54
  const availableContentTypes = {
35
55
  "text/html": () => {
36
56
  const renderHtmlForErrorWithoutDetails = () => {
37
- return `<p>Details not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).</p>`;
57
+ return `<p>Details not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).</p>`;
38
58
  };
39
59
  const renderHtmlForErrorWithDetails = () => {
40
60
  return errorToHTML(serverInternalError);
41
61
  };
42
62
 
43
- const internalErrorHtmlTemplate = readFileSync(
44
- new URL(internalErrorHtmlFileUrl),
45
- "utf8",
46
- );
47
63
  const internalErrorHtml = replacePlaceholdersInHtml(
48
- internalErrorHtmlTemplate,
64
+ readInternalErrorHtmlTemplate(),
49
65
  {
50
66
  errorMessage: serverInternalErrorIsAPrimitive
51
67
  ? `Code inside server has thrown a literal.`
@@ -79,7 +95,7 @@ export const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
79
95
  internalErrorMessage += `\n${serverInternalError.stack}`;
80
96
  }
81
97
  } else {
82
- internalErrorMessage += `\nDetails not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).`;
98
+ internalErrorMessage += `\nDetails not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).`;
83
99
  }
84
100
 
85
101
  return {
@@ -1,7 +1,19 @@
1
1
  import { lstatSync, readdirSync } from "node:fs";
2
2
 
3
3
  import { pickContentType } from "../../content_negotiation/pick_content_type.js";
4
+ import { escapeHtml } from "../../internal/escape_html.js";
4
5
 
6
+ /**
7
+ * Response listing a directory, as json (an array of names) or as an html
8
+ * page of links depending on what the request accepts (json by default).
9
+ *
10
+ * @param {string|URL} url - The directory.
11
+ * @param {Object} [params]
12
+ * @param {Object} [params.headers={}] - Request headers, read for `accept`.
13
+ * @param {string|URL} [params.rootDirectoryUrl] - Directory served at "/", so
14
+ * that the html links are relative to the server.
15
+ * @returns {{ status: number, headers: Object, body: string }}
16
+ */
5
17
  export const fetchDirectory = (
6
18
  url,
7
19
  { headers = {}, rootDirectoryUrl } = {},
@@ -20,7 +32,7 @@ export const fetchDirectory = (
20
32
  status: 200,
21
33
  headers: {
22
34
  "content-type": "application/json",
23
- "content-length": directoryContentJson.length,
35
+ "content-length": Buffer.byteLength(directoryContentJson),
24
36
  },
25
37
  body: directoryContentJson,
26
38
  };
@@ -35,7 +47,7 @@ export const fetchDirectory = (
35
47
  </head>
36
48
 
37
49
  <body>
38
- <h1>Content of directory ${url}</h1>
50
+ <h1>Content of directory ${escapeHtml(url)}</h1>
39
51
  <ul>
40
52
  ${directoryContentArray.map((filename) => {
41
53
  const fileUrlObject = new URL(filename, url);
@@ -46,8 +58,9 @@ export const fetchDirectory = (
46
58
  if (lstatSync(fileUrlObject).isDirectory()) {
47
59
  fileUrlRelativeToServer += "/";
48
60
  }
61
+ const linkHtml = escapeHtml(fileUrlRelativeToServer);
49
62
  return `<li>
50
- <a href="/${fileUrlRelativeToServer}">${fileUrlRelativeToServer}</a>
63
+ <a href="/${linkHtml}">${linkHtml}</a>
51
64
  </li>`;
52
65
  }).join(`
53
66
  `)}