@jsenv/server 17.6.6 → 17.6.8
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/dist/jsenv_server.js +148 -55
- package/docs/AI_INSTRUCTIONS.md +29 -0
- package/docs/cluster.md +122 -0
- package/docs/content_negotiation.md +78 -0
- package/docs/cors.md +32 -0
- package/docs/demos/cluster/cluster_demo_primary.mjs +31 -0
- package/docs/demos/cluster/cluster_demo_primary_watch.mjs +52 -0
- package/docs/demos/cluster/cluster_demo_server.mjs +10 -0
- package/docs/demos/cors/cors_error_demo.mjs +29 -0
- package/docs/demos/https/https_file_demo.mjs +21 -0
- package/docs/demos/https/server.crt +24 -0
- package/docs/demos/https/server.key +27 -0
- package/docs/demos/node_fetch_demo.mjs +14 -0
- package/docs/demos/server_sent_events/client.js +6 -0
- package/docs/demos/server_sent_events/run.js +2 -0
- package/docs/demos/server_sent_events/server.js +20 -0
- package/docs/demos/websocket/client.html +16 -0
- package/docs/demos/websocket/server.js +26 -0
- package/docs/handling_errors.md +75 -0
- package/docs/handling_requests.md +201 -0
- package/docs/https.md +77 -0
- package/docs/lifecycle.md +52 -0
- package/docs/plugins.md +53 -0
- package/docs/screenshots/500.png +0 -0
- package/docs/screenshots/500_expanded.png +0 -0
- package/docs/screenshots/500_expanded_and_details_enabled.png +0 -0
- package/docs/screenshots/devtools_TTFB.png +0 -0
- package/docs/screenshots/devtools_server_timing.png +0 -0
- package/docs/screenshots/sse-screencast.gif +0 -0
- package/docs/screenshots/sse-screencast.mp4 +0 -0
- package/docs/security.md +32 -0
- package/docs/server_sent_events.md +64 -0
- package/docs/server_timing.md +70 -0
- package/docs/serving_files.md +64 -0
- package/docs/websocket.md +64 -0
- package/package.json +4 -3
- package/src/interfacing_with_node/from_node_request.js +13 -5
- package/src/internal/request_target.js +55 -0
- package/src/internal/track_server_pending_requests.js +14 -9
- package/src/plugins/cors/server_plugin_cors.js +8 -6
- package/src/plugins/filesystem/fetch_file.js +7 -3
- package/src/plugins/server_plugins_controller.js +2 -2
- package/src/progressive_response.js +1 -1
- package/src/router/router.js +9 -5
- package/src/server_events.js +5 -3
- package/src/start_server.js +29 -21
package/docs/cors.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# CORS
|
|
2
|
+
|
|
3
|
+
Cross-origin requests are refused by browsers unless the response carries the access control headers. `serverPluginCORS` adds them to every response, including errors — a 500 without them shows up as a CORS failure in the browser, which hides the actual problem.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import {
|
|
7
|
+
serverPluginCORS,
|
|
8
|
+
serverPluginErrorHandler,
|
|
9
|
+
startServer,
|
|
10
|
+
} from "@jsenv/server";
|
|
11
|
+
|
|
12
|
+
await startServer({
|
|
13
|
+
plugins: [
|
|
14
|
+
serverPluginErrorHandler(),
|
|
15
|
+
serverPluginCORS({
|
|
16
|
+
accessControlAllowedOrigins: ["https://my-app.example"],
|
|
17
|
+
accessControlAllowRequestMethod: true,
|
|
18
|
+
accessControlAllowRequestHeaders: true,
|
|
19
|
+
accessControlAllowCredentials: true,
|
|
20
|
+
}),
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The plugin stays off (returns no plugin) unless `accessControlAllowedOrigins` lists an origin or `accessControlAllowRequestOrigin` is true. The options are in its JSDoc; the ones worth a word:
|
|
26
|
+
|
|
27
|
+
- `accessControlAllowedOrigins` — the origins allowed to read the responses. `*` stands for any run of characters except `/`, so `"https://pr-*-my-app.fly.dev"` covers every preview deployment. The request origin is reflected back when it is allowed, with `vary: origin`. An origin that is not allowed gets the first literal origin of the list, which the browser rejects, or no `access-control-allow-origin` at all when the list only has patterns.
|
|
28
|
+
- `accessControlAllowRequestOrigin` — reflect any origin. Fine for a public API, not with credentials.
|
|
29
|
+
- `accessControlAllowRequestMethod`, `accessControlAllowRequestHeaders` — also allow whatever a preflight asks for, on top of `accessControlAllowedMethods` (GET, POST, PUT, DELETE, OPTIONS) and `accessControlAllowedHeaders` (`x-requested-with`).
|
|
30
|
+
- `accessControlMaxAge` — seconds a browser may cache a preflight (600).
|
|
31
|
+
|
|
32
|
+
Preflight `OPTIONS` requests are answered by the router itself (see [handling requests](./handling_requests.md)); the plugin only adds the headers.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import cluster from "node:cluster";
|
|
2
|
+
import { cpus } from "node:os";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const numCPUs = cpus().length;
|
|
6
|
+
|
|
7
|
+
const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url);
|
|
8
|
+
|
|
9
|
+
cluster.setupPrimary({
|
|
10
|
+
exec: fileURLToPath(workerFileUrl),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
console.log(`Primary ${process.pid} is running`);
|
|
14
|
+
for (let i = 0; i < numCPUs; i++) {
|
|
15
|
+
cluster.fork();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cluster.on("online", (worker) => {
|
|
19
|
+
console.log(`Worker ${worker.process.pid} is online`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
23
|
+
// https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
|
|
24
|
+
if (worker.exitedAfterDisconnect) {
|
|
25
|
+
console.log(
|
|
26
|
+
`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
|
|
27
|
+
);
|
|
28
|
+
console.log(`Starting a new worker`);
|
|
29
|
+
cluster.fork();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { registerFileLifecycle } from "@jsenv/filesystem";
|
|
2
|
+
import cluster from "node:cluster";
|
|
3
|
+
import { cpus } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const numCPUs = cpus().length;
|
|
7
|
+
|
|
8
|
+
const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url);
|
|
9
|
+
|
|
10
|
+
cluster.setupPrimary({
|
|
11
|
+
exec: fileURLToPath(workerFileUrl),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
console.log(`Primary ${process.pid} is running`);
|
|
15
|
+
for (let i = 0; i < numCPUs; i++) {
|
|
16
|
+
cluster.fork();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
cluster.on("online", (worker) => {
|
|
20
|
+
console.log(`Worker ${worker.process.pid} is online`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
24
|
+
// https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
|
|
25
|
+
if (worker.exitedAfterDisconnect) {
|
|
26
|
+
console.log(
|
|
27
|
+
`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
|
|
28
|
+
);
|
|
29
|
+
console.log(`Starting a new worker`);
|
|
30
|
+
cluster.fork();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
registerFileLifecycle(workerFileUrl, {
|
|
35
|
+
updated: () => {
|
|
36
|
+
console.log("Server file updated, restarting servers...");
|
|
37
|
+
|
|
38
|
+
Object.keys(cluster.workers).forEach((workerId) => {
|
|
39
|
+
const worker = cluster.workers[workerId];
|
|
40
|
+
|
|
41
|
+
// force kill if graceful exit fails
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
worker.kill("SIGKILL");
|
|
44
|
+
}, 5000);
|
|
45
|
+
worker.once("exit", () => {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
});
|
|
48
|
+
// try gracefull exit
|
|
49
|
+
worker.kill();
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
serverPluginCORS,
|
|
3
|
+
serverPluginErrorHandler,
|
|
4
|
+
startServer,
|
|
5
|
+
} from "@jsenv/server";
|
|
6
|
+
|
|
7
|
+
const server = await startServer({
|
|
8
|
+
plugins: [
|
|
9
|
+
serverPluginErrorHandler(),
|
|
10
|
+
serverPluginCORS({
|
|
11
|
+
accessControlAllowRequestOrigin: true,
|
|
12
|
+
accessControlAllowRequestMethod: true,
|
|
13
|
+
accessControlAllowRequestHeaders: true,
|
|
14
|
+
accessControlAllowCredentials: true,
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
routes: [
|
|
18
|
+
{
|
|
19
|
+
endpoint: "GET *",
|
|
20
|
+
fetch: () => {
|
|
21
|
+
throw new Error("test");
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const response = await fetch(server.origin);
|
|
28
|
+
console.log(response.status); // 500
|
|
29
|
+
console.log(response.headers.has("access-control-allow-origin")); // true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { startServer } from "@jsenv/server";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
await startServer({
|
|
5
|
+
https: {
|
|
6
|
+
certificate: readFileSync(new URL("./server.crt", import.meta.url), "utf8"),
|
|
7
|
+
privateKey: readFileSync(new URL("./server.key", import.meta.url), "utf8"),
|
|
8
|
+
},
|
|
9
|
+
allowHttpRequestOnHttps: true,
|
|
10
|
+
routes: [
|
|
11
|
+
{
|
|
12
|
+
endpoint: "GET *",
|
|
13
|
+
fetch: (request) => {
|
|
14
|
+
const clientUsesHttp = request.origin.startsWith("http:");
|
|
15
|
+
return new Response(
|
|
16
|
+
clientUsesHttp ? `Welcome http user` : `Welcome https user`,
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIECjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBkTEuMCwGA1UEAxMlaHR0
|
|
3
|
+
cHM6Ly9naXRodWIuY29tL2pzZW52L2pzZW52LXNlcnZlcjELMAkGA1UEBhMCRlIx
|
|
4
|
+
GDAWBgNVBAgTD0FscGVzIE1hcml0aW1lczERMA8GA1UEBxMIVmFsYm9ubmUxDjAM
|
|
5
|
+
BgNVBAoTBWpzZW52MRUwEwYDVQQLEwxqc2VudiBzZXJ2ZXIwHhcNMjEwNjEwMDgz
|
|
6
|
+
NDM4WhcNMjIwNzExMDgzNDM5WjCBkTEuMCwGA1UEAxMlaHR0cHM6Ly9naXRodWIu
|
|
7
|
+
Y29tL2pzZW52L2pzZW52LXNlcnZlcjELMAkGA1UEBhMCRlIxGDAWBgNVBAgTD0Fs
|
|
8
|
+
cGVzIE1hcml0aW1lczERMA8GA1UEBxMIVmFsYm9ubmUxDjAMBgNVBAoTBWpzZW52
|
|
9
|
+
MRUwEwYDVQQLEwxqc2VudiBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
|
|
10
|
+
ggEKAoIBAQDDaJLXCSyLm5zWi/J2vcNrhRfHpDcBnydRMxEqqmc+zazd80pqeGEy
|
|
11
|
+
znVQDnjrJ0cymFXVvelw15UnB5kKhRpsJMzh/5c1rIXU2Ti5cpnWDb599I44vPdJ
|
|
12
|
+
APf8L7d2z0keLLn6gQMdIQKFv68jIM1rWer3FFyA3W4RP16UhtYCDNwO4WeHvryE
|
|
13
|
+
4bBkYInYo6CKTTUR3pv0BB9oNdQdiSGDgDy/JX6n/DF2y6QfyznICW0rtITG52Gg
|
|
14
|
+
NLAS/0SY//xmE7sApv/fdy8IHtZ1WB7tbvZPjpwcTy4ISAmxUbSs8ratcCD8JEhP
|
|
15
|
+
OSKWybp22EFssdDuF8mRApJ46Wbu+VWrAgMBAAGjazBpMB8GA1UdIwQYMBaAFDs2
|
|
16
|
+
JcFL/Bn0mqi7rt7DZuNnpPajMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr
|
|
17
|
+
BgEFBQcDATAhBgNVHREEGjAYhwR/AAABgglsb2NhbGhvc3SCBWpzZW52MA0GCSqG
|
|
18
|
+
SIb3DQEBCwUAA4IBAQB7OabCeian7zIrW2uQc66VI/jO1Ol6vPugtmywPtYiSKkC
|
|
19
|
+
EXG3xuXFi8klPy7tPMJucMPCTZmBy9ymNkaxzrhrZqNvFcdfrJanEaSltu96Yoti
|
|
20
|
+
PTwoqxX0ymAbVTwnulxLiaJqu0z3rQkSogIdeo4InPxqqTA1gYAakMZPkS5JNFgz
|
|
21
|
+
aSlzvxK6rKXD+b2PxclMBvD9CL5eq9StHppD9rYgY39islxRcaQPazXSCTNhU/TN
|
|
22
|
+
Y1gUngqE7PJoBgjJjsVp4MmIjDv9snR12RbIVpm+o2szg2rSN0D0SamjW2hH3MtN
|
|
23
|
+
si/Ce9UYvwS3Bijm/CuxxZX5JKMAGQzLvGhMu1KT
|
|
24
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-----BEGIN RSA PRIVATE KEY-----
|
|
2
|
+
MIIEpAIBAAKCAQEAw2iS1wksi5uc1ovydr3Da4UXx6Q3AZ8nUTMRKqpnPs2s3fNK
|
|
3
|
+
anhhMs51UA546ydHMphV1b3pcNeVJweZCoUabCTM4f+XNayF1Nk4uXKZ1g2+ffSO
|
|
4
|
+
OLz3SQD3/C+3ds9JHiy5+oEDHSEChb+vIyDNa1nq9xRcgN1uET9elIbWAgzcDuFn
|
|
5
|
+
h768hOGwZGCJ2KOgik01Ed6b9AQfaDXUHYkhg4A8vyV+p/wxdsukH8s5yAltK7SE
|
|
6
|
+
xudhoDSwEv9EmP/8ZhO7AKb/33cvCB7WdVge7W72T46cHE8uCEgJsVG0rPK2rXAg
|
|
7
|
+
/CRITzkilsm6dthBbLHQ7hfJkQKSeOlm7vlVqwIDAQABAoIBABu6tzu0nZGiH3uC
|
|
8
|
+
mIGpJBYZ6On3r6aXQykOqhz32XbcEO/eGm1Jb+GZ0qPtqdMU29GeNHAEZyAQN6Y2
|
|
9
|
+
Oc14XeuCvWBEuNxxAvzVZmtKi/IIOUP+dEAw8JZ3gLlUvCAsAiA6EJovvJVhnc4F
|
|
10
|
+
CYFeokfdlq5BQW83/OqNp4pEKBJaKVAzCon0EuqudHbIVk6pZvrxgNCAYtL/ADG+
|
|
11
|
+
3GNwhG16jqcokU5xan0tfHi9wwgmT6mRt8WHqdH4tVPysMckHrlUDy3Nf089PWXE
|
|
12
|
+
gmJ8Fm1f6aEkpW7Io/8Z4cMEhJbFPSw8gvhpkTrmac8g2kz4dOvy7uzGlRQX84NB
|
|
13
|
+
UHG0SeECgYEA5ZCzzYM/2zA9zraB7tb3h3aXZSXvH9X/O239SEFzxwRfyKf3HaWO
|
|
14
|
+
zeLnKVXwJt5/42Yt90aVnEUuT0dMZufzNU4llcc9YRYw42APXuCGpOyhrdh8ML2C
|
|
15
|
+
IdBFZskXQV9U1rheJsqJy2uYk0RhbWe1B6jw+sYPnH+mTRUgE11Z4R8CgYEA2ej5
|
|
16
|
+
AUVjG/Es4PwGGqYSH1f2rBTcyP1EVbKtT8mrp2gkaenWnEZJ6KBV2FGOji4Y50GU
|
|
17
|
+
K05YnY/OkqgF+ii2Sr4BgqeAYcQxzEoWEcJx4M2lyCkpzSlXfIkbm2UjOoHeEa9v
|
|
18
|
+
ly6Bcr8U0FpfO5yo9EaQo/SR+sj9z0QprcYfvfUCgYEAqfmbeCafSF8O1JjD2iFn
|
|
19
|
+
A/EmJemRhaQAqHNHH5H+9LPjbAIZ8eScuQsxl6Se/VivnqMQk8+6hQugP2kOQ/+o
|
|
20
|
+
T6a+I4lzhAhXSHPezaQ3bbQb/kmVos0epoS8bl+kjW3Vtmqqo9RBdNIFFb2C2ev4
|
|
21
|
+
2WQYkcXG11yYuOhUaKItI4cCgYAgVQz9P0phTvuVtk9BjPqLZAXZqo7H06xb+tL0
|
|
22
|
+
T7wEbtNYU7XgIh29Qmz6V7+LIRPY3EYb1tgXKMtBv2SDpZ7GoEKhHDFUUEQDR8rW
|
|
23
|
+
f79d16WNeW4WXtf4Rm+uMudzOum0Pg+UovXaDLgN4hrXQXtgI0g6y4MNbt0Z78cv
|
|
24
|
+
XYJAMQKBgQCXjfNHXZ3XHaT+lrHlfDOfEyRh7oRQ5CJVgzfRBQMI4bW5M0OzvMie
|
|
25
|
+
O0EugYcoCtdxyMCUWg/CurbHFQ49lSHASYQnTRgLdyq0825vhxVzj+1kYFK1I6vP
|
|
26
|
+
B8FHFiUWjsgrqNgBAC/PhIDbZ4VtPrdlPBQjWwdSNpEHIL6b+rP7hA==
|
|
27
|
+
-----END RSA PRIVATE KEY-----
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { startServer } from "@jsenv/server";
|
|
2
|
+
|
|
3
|
+
const server = await startServer({
|
|
4
|
+
routes: [
|
|
5
|
+
{
|
|
6
|
+
endpoint: "GET *",
|
|
7
|
+
fetch: () => new Response("Hello world"),
|
|
8
|
+
},
|
|
9
|
+
],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const response = await fetch(server.origin);
|
|
13
|
+
const responseBodyAsText = await response.text();
|
|
14
|
+
console.log(responseBodyAsText); // "Hello world"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ServerEvents, startServer } from "@jsenv/server";
|
|
2
|
+
|
|
3
|
+
const serverEvents = new ServerEvents();
|
|
4
|
+
setInterval(() => {
|
|
5
|
+
serverEvents.sendEventToAllClients({
|
|
6
|
+
type: "ping",
|
|
7
|
+
data: JSON.stringify({ ts: Date.now() }),
|
|
8
|
+
});
|
|
9
|
+
}, 1000);
|
|
10
|
+
|
|
11
|
+
await startServer({
|
|
12
|
+
logLevel: "warn",
|
|
13
|
+
port: 3456,
|
|
14
|
+
routes: [
|
|
15
|
+
{
|
|
16
|
+
endpoint: "GET /events",
|
|
17
|
+
fetch: serverEvents.fetch,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Title</title>
|
|
5
|
+
<link rel="icon" href="data:," />
|
|
6
|
+
</head>
|
|
7
|
+
|
|
8
|
+
<body>
|
|
9
|
+
<script>
|
|
10
|
+
const websocket = new WebSocket("ws://localhost:3000");
|
|
11
|
+
websocket.onmessage = (message) => {
|
|
12
|
+
document.body.appendChild(document.createTextNode(message.data));
|
|
13
|
+
};
|
|
14
|
+
</script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFileSystemFetch,
|
|
3
|
+
startServer,
|
|
4
|
+
WebSocketResponse,
|
|
5
|
+
} from "@jsenv/server";
|
|
6
|
+
|
|
7
|
+
await startServer({
|
|
8
|
+
port: 3000,
|
|
9
|
+
routes: [
|
|
10
|
+
{
|
|
11
|
+
endpoint: "GET *",
|
|
12
|
+
headers: {
|
|
13
|
+
upgrade: "websocket",
|
|
14
|
+
},
|
|
15
|
+
fetch: () => {
|
|
16
|
+
return new WebSocketResponse((websocket) => {
|
|
17
|
+
websocket.send("Hello world");
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
endpoint: "GET *",
|
|
23
|
+
fetch: createFileSystemFetch(import.meta.resolve("./")),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Handling errors
|
|
2
|
+
|
|
3
|
+
A route that throws (or rejects) is answered by the first plugin whose `handleError` hook returns a response. **Without such a plugin the error is thrown and the process exits**: a server always runs with one. `serverPluginErrorHandler` is the generic one:
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { serverPluginErrorHandler, startServer } from "@jsenv/server";
|
|
7
|
+
|
|
8
|
+
await startServer({
|
|
9
|
+
plugins: [serverPluginErrorHandler()],
|
|
10
|
+
routes: [
|
|
11
|
+
{
|
|
12
|
+
endpoint: "GET *",
|
|
13
|
+
fetch: () => {
|
|
14
|
+
throw new Error("toto");
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It answers 500 with an html page, a text or a json, depending on what the request accepts.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
With `sendErrorDetails: true` the error stack (and its properties, in json) is sent. A stack reveals file paths and code: development only.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
The error responses get `cache-control: no-store`.
|
|
32
|
+
|
|
33
|
+
## Handling some errors yourself
|
|
34
|
+
|
|
35
|
+
`serverPluginErrorHandler` catches every error, so it comes last; a plugin handling a subset of them comes before:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
await startServer({
|
|
39
|
+
plugins: [
|
|
40
|
+
{
|
|
41
|
+
handleError: (error) => {
|
|
42
|
+
if (error.code === "FOO") {
|
|
43
|
+
return new Response('Custom response for error with code "FOO"', {
|
|
44
|
+
status: 500,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
serverPluginErrorHandler(),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
An error exposing an `asResponse()` method is answered with what it returns, with or without an error plugin: a way for a domain error to carry its own status (a request body too large is answered 413 that way).
|
|
56
|
+
|
|
57
|
+
A route can also decide not to throw:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
fetch: async () => {
|
|
61
|
+
try {
|
|
62
|
+
return Response.json(await fetchExternalData());
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return Response.json({ error: "Could not retrieve data" }, { status: 502 });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Timeouts
|
|
70
|
+
|
|
71
|
+
A route that has not started responding after `responseTimeout` (10 minutes by default) is answered 504; the route keeps running and what it throws afterwards is logged.
|
|
72
|
+
|
|
73
|
+
## Stopping on internal error
|
|
74
|
+
|
|
75
|
+
`stopOnInternalError: true` stops the server as soon as a route throws, for a supervisor to restart it in a clean state. The request that threw, and every other request still running, is answered 500 with no body: the `handleError` plugins still run, but their response is not sent.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Handling requests
|
|
2
|
+
|
|
3
|
+
A request is answered by the first route whose `fetch` returns a response.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { startServer } from "@jsenv/server";
|
|
7
|
+
|
|
8
|
+
await startServer({
|
|
9
|
+
routes: [
|
|
10
|
+
{
|
|
11
|
+
endpoint: "GET /",
|
|
12
|
+
fetch: () => new Response("Hello world"),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Routes
|
|
19
|
+
|
|
20
|
+
`endpoint` is an http method (or `*`) followed by a resource pattern:
|
|
21
|
+
|
|
22
|
+
| endpoint | matches |
|
|
23
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
24
|
+
| `GET /` | exactly `/` |
|
|
25
|
+
| `GET /users/:id` | `/users/42`, with `request.params.id === "42"` |
|
|
26
|
+
| `GET /assets/*` | `/assets/css/main.css`, with `request.params[0] === "css/main.css"` |
|
|
27
|
+
| `GET /search?q=:query` | `/search?q=hello`, with `request.params.query === "hello"` |
|
|
28
|
+
| `* /api/*` | any method |
|
|
29
|
+
| `GET *` | anything: a catch-all, put it last |
|
|
30
|
+
|
|
31
|
+
Routes are tried in order. `fetch` can be async; returning `null` or `undefined` declines the request and the next route is tried.
|
|
32
|
+
|
|
33
|
+
When no route answers, the router builds the response from what almost matched:
|
|
34
|
+
|
|
35
|
+
- **405 Method Not Allowed** (with an `allow` header) when the resource matched routes for other methods — a catch-all `*` route does not count, it says nothing about the resource,
|
|
36
|
+
- **415 Unsupported Media Type** when a POST/PATCH/PUT route wanted another `content-type` (see `acceptedMediaTypes` below),
|
|
37
|
+
- **406 Not Acceptable** when the route cannot produce what the request accepts (see [content negotiation](./content_negotiation.md)),
|
|
38
|
+
- **426 Upgrade Required** for a websocket route requested without an upgrade,
|
|
39
|
+
- **404 Not Found** otherwise.
|
|
40
|
+
|
|
41
|
+
`OPTIONS` requests are answered from the routes (`allow`, `accept-post`, `accept-patch` headers); `OPTIONS *` describes the whole server.
|
|
42
|
+
|
|
43
|
+
The routes can be explored at `/.internal/route_inspector` (json at `/.internal/routes.json`); a route's `description` and `clientCodeExample` show up there.
|
|
44
|
+
|
|
45
|
+
## The request object
|
|
46
|
+
|
|
47
|
+
Frozen, passed as first argument to `fetch`:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
request.method; // "GET"
|
|
51
|
+
request.url; // "http://127.0.0.1:8080/users/42?page=2"
|
|
52
|
+
request.origin; // "http://127.0.0.1:8080"
|
|
53
|
+
request.pathname; // "/users/42"
|
|
54
|
+
request.resource; // "/users/42?page=2" (path and search, as received)
|
|
55
|
+
request.searchParams; // URLSearchParams
|
|
56
|
+
request.params; // { id: "42" }, captured by the endpoint pattern
|
|
57
|
+
request.headers; // { accept: "text/html", ... } lowercased names
|
|
58
|
+
request.cookies; // Map { "session" => "abc" }
|
|
59
|
+
request.signal; // AbortSignal: the client left, or the server stops
|
|
60
|
+
request.http2; // true when the request came over http2
|
|
61
|
+
request.proto; // "http" or "https", from the connection
|
|
62
|
+
request.host; // the host header
|
|
63
|
+
request.ip; // address of the socket
|
|
64
|
+
request.ipForwarded; // what the forwarded / x-forwarded-for header says, unverified
|
|
65
|
+
request.protoForwarded; // idem (x-forwarded-proto)
|
|
66
|
+
request.hostForwarded; // idem (x-forwarded-host)
|
|
67
|
+
request.logger; // debug/info/warn/error scoped to this request
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`request.logger` is what a route uses to log: its lines are buffered and written together with the response status line, so the logs of concurrent requests do not interleave.
|
|
71
|
+
|
|
72
|
+
Reading the body, each returns a promise:
|
|
73
|
+
|
|
74
|
+
- `request.json()`, `request.text()`, `request.buffer()`
|
|
75
|
+
- `request.formData()` → `{ fields, files }` for `multipart/form-data` (files are written to the temp directory)
|
|
76
|
+
- `request.queryString()` for `application/x-www-form-urlencoded`
|
|
77
|
+
- `request.body` is an observable of chunks, for streaming or for reading with a size limit
|
|
78
|
+
|
|
79
|
+
`json()`, `text()`, `buffer()` and `queryString()` refuse a body bigger than `requestBodyMaxSize` (1 MiB by default) with a 413 response; a route accepting more passes its own limit: `request.json({ maxSize: 50 * 1024 * 1024 })`. `request.body` is never limited (see [security](./security.md)).
|
|
80
|
+
|
|
81
|
+
A route reading its body declares `acceptedMediaTypes`: an unsupported `content-type` is refused with 415 before `fetch` runs.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
{
|
|
85
|
+
endpoint: "PATCH /users/:id",
|
|
86
|
+
acceptedMediaTypes: ["application/json", "multipart/form-data"],
|
|
87
|
+
fetch: async (request) => {
|
|
88
|
+
if (request.headers["content-type"] === "application/json") {
|
|
89
|
+
const patch = await request.json();
|
|
90
|
+
return Response.json(patch);
|
|
91
|
+
}
|
|
92
|
+
const { fields, files } = await request.formData();
|
|
93
|
+
return Response.json({ fields, files: Object.keys(files) });
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## The helpers
|
|
99
|
+
|
|
100
|
+
Second argument of `fetch`:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
fetch: async (request, helpers) => {
|
|
104
|
+
helpers.timing; // measure something, see server_timing.md
|
|
105
|
+
helpers.injectResponseHeader("vary", "accept"); // added to the response, whatever it ends up being
|
|
106
|
+
helpers.contentNegotiation; // { mediaType, language, version, encoding }, see content_negotiation.md
|
|
107
|
+
helpers.responseCookies.set("session", "abc", { httpOnly: true, path: "/" });
|
|
108
|
+
helpers.responseCookies.delete("session");
|
|
109
|
+
await helpers.hasPermissions(["admin"]); // true or false, see permissions below
|
|
110
|
+
await helpers.getAllPermissions(); // a Set of every permission granted
|
|
111
|
+
helpers.canExposeSensitiveData; // the startServer option
|
|
112
|
+
helpers.router; // router.inspect() lists the routes
|
|
113
|
+
};
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Plugins add their own with `augmentRouteFetchSecondArg` (see [plugins](./plugins.md)).
|
|
117
|
+
|
|
118
|
+
## Responses
|
|
119
|
+
|
|
120
|
+
`fetch` returns a standard `Response`:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
new Response("Hello", {
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: { "content-type": "text/plain" },
|
|
126
|
+
});
|
|
127
|
+
Response.json({ hello: "world" });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
or a plain object:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
{
|
|
134
|
+
status: 200, // defaults to 404
|
|
135
|
+
statusText: "OK", // the reason phrase of the status line (http/1.1 only)
|
|
136
|
+
statusMessage: "A longer explanation", // becomes the body of a 4xx/5xx response that has none
|
|
137
|
+
headers: { "content-type": "text/plain" },
|
|
138
|
+
body: "Hello",
|
|
139
|
+
timing: { "db query": 12.3 }, // joins the server-timing header, see server_timing.md
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`body` can be a string, a `Buffer`, a Node readable stream (`createReadStream(file)`), a web `ReadableStream`, a file handle, a promise of one of these, or be written over time:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { ProgressiveResponse } from "@jsenv/server";
|
|
147
|
+
|
|
148
|
+
fetch: () =>
|
|
149
|
+
new ProgressiveResponse(({ write, end }) => {
|
|
150
|
+
write("a");
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
write("b");
|
|
153
|
+
end();
|
|
154
|
+
}, 100);
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A 4xx/5xx response without body gets one — html, text or json depending on what the request accepts — made of `statusText` and `statusMessage`. What they say is escaped, so echoing the request in them is fine.
|
|
159
|
+
|
|
160
|
+
## Permissions
|
|
161
|
+
|
|
162
|
+
A plugin grants permissions to a request with the `grantPermissions` hook; each route declares what it needs:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
await startServer({
|
|
166
|
+
plugins: [
|
|
167
|
+
{
|
|
168
|
+
grantPermissions: async (request) => {
|
|
169
|
+
return (await isAdmin(request)) ? ["admin"] : [];
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
routes: [
|
|
174
|
+
// 404 for anyone else: the route does not even exist for them
|
|
175
|
+
{ endpoint: "GET /admin", permissionsRequired: ["admin"], fetch },
|
|
176
|
+
// 403 for anyone else: the route is known, access is denied
|
|
177
|
+
{
|
|
178
|
+
endpoint: "DELETE /users/:id",
|
|
179
|
+
permissionsRequired: ["admin"],
|
|
180
|
+
permissionsToSee: [],
|
|
181
|
+
fetch,
|
|
182
|
+
},
|
|
183
|
+
// everyone
|
|
184
|
+
{ endpoint: "GET /", permissionsRequired: [], fetch },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
As soon as one route declares permissions, a route without `permissionsRequired` is hidden (404) from everyone: forgetting it cannot expose a route. `permissionsToSee` decides between 404 (hidden) and 403 (known, denied). Permissions are computed lazily and at most once per request, whatever the number of routes checked.
|
|
190
|
+
|
|
191
|
+
## Rewriting a request before routing
|
|
192
|
+
|
|
193
|
+
The `redirectRequest` plugin hook replaces the request seen by the routes; the first one stays reachable as `request.original` (and the one just before as `request.previous`). `serverPluginRequestAliases` does it from url patterns:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { serverPluginRequestAliases } from "@jsenv/server";
|
|
197
|
+
|
|
198
|
+
plugins: [
|
|
199
|
+
serverPluginRequestAliases({ "/": "/index.html", "/*.mjs": "/*.js" }),
|
|
200
|
+
];
|
|
201
|
+
```
|