@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.
- package/README.md +47 -48
- package/dist/client/default_body_4xx_5xx/4xx.html +1 -1
- package/dist/jsenv_server.js +1332 -1269
- package/dist/ws/ws.js +3 -1
- package/package.json +5 -5
- package/src/content_negotiation/pick_content_encoding.js +11 -1
- package/src/content_negotiation/pick_content_language.js +11 -1
- package/src/content_negotiation/pick_content_type.js +11 -1
- package/src/content_negotiation/pick_content_version.js +1 -1
- package/src/interfacing_with_node/from_node_request.js +154 -178
- package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
- package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
- package/src/interfacing_with_node/write_node_response.js +59 -103
- package/src/internal/allowed_hosts.js +85 -0
- package/src/internal/escape_html.js +11 -0
- package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
- package/src/internal/listen.js +32 -5
- package/src/internal/{listenRequest.js → listen_request.js} +1 -1
- package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
- package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
- package/src/internal/reason_phrase.js +17 -0
- package/src/internal/response_composition.js +11 -2
- package/src/internal/secure_server.js +22 -0
- package/src/internal/server_origins.js +78 -0
- package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
- package/src/internal/track_server_pending_connections.js +50 -0
- package/src/internal/track_server_pending_requests.js +47 -0
- package/src/plugins/cors/server_plugin_cors.js +39 -6
- package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
- package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
- package/src/plugins/filesystem/fetch_directory.js +16 -3
- package/src/plugins/filesystem/fetch_file.js +80 -21
- package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
- package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
- package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
- package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
- package/src/plugins/server_plugins_controller.js +27 -13
- package/src/progressive_response.js +24 -0
- package/src/router/permissions.js +52 -0
- package/src/router/router.js +14 -79
- package/src/server_events.js +3 -1
- package/src/server_timing/timing_header.js +1 -1
- package/src/start_server.js +266 -416
- package/src/internal/headersFromString.js +0 -21
- package/src/internal/headersToObject.js +0 -7
- package/src/internal/headersToString.js +0 -16
- package/src/internal/listenClientError.js +0 -27
- package/src/internal/trackServerPendingConnections.js +0 -145
- package/src/internal/trackServerPendingRequests.js +0 -68
- package/src/request_body_handling.js +0 -22
- /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
- /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
- /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
- /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
|
-
|
|
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 "./
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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="/${
|
|
63
|
+
<a href="/${linkHtml}">${linkHtml}</a>
|
|
51
64
|
</li>`;
|
|
52
65
|
}).join(`
|
|
53
66
|
`)}
|