@jsenv/server 17.6.7 → 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 +49 -35
- 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/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 +3 -2
- package/src/plugins/server_plugins_controller.js +2 -2
- package/src/progressive_response.js +1 -1
- package/src/router/router.js +4 -2
- package/src/server_events.js +5 -3
- package/src/start_server.js +12 -10
|
@@ -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
|
+
```
|
package/docs/https.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# HTTPS
|
|
2
|
+
|
|
3
|
+
```js
|
|
4
|
+
import { startServer } from "@jsenv/server";
|
|
5
|
+
|
|
6
|
+
const server = await startServer();
|
|
7
|
+
server.origin.startsWith("http://"); // true
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { startServer } from "@jsenv/server";
|
|
13
|
+
|
|
14
|
+
const server = await startServer({
|
|
15
|
+
https: {
|
|
16
|
+
certificate: readFileSync(new URL("./server.crt", import.meta.url), "utf8"),
|
|
17
|
+
privateKey: readFileSync(new URL("./server.key", import.meta.url), "utf8"),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
server.origin.startsWith("https://"); // true
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Without certificate files, [@jsenv/https-local](https://github.com/jsenv/core/tree/main/packages/tooling/https-local) generates one for localhost, signed by an authority it can install on the machine:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { requestCertificate } from "@jsenv/https-local";
|
|
27
|
+
import { startServer } from "@jsenv/server";
|
|
28
|
+
|
|
29
|
+
const { certificate, privateKey } = requestCertificate();
|
|
30
|
+
await startServer({ https: { certificate, privateKey } });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Http requests on an https server
|
|
34
|
+
|
|
35
|
+
By default an https server also accepts http on the same port and redirects it (301) to the https origin. `redirectHttpToHttps: false` refuses http instead. To serve both:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
await startServer({
|
|
39
|
+
https: { certificate, privateKey },
|
|
40
|
+
allowHttpRequestOnHttps: true,
|
|
41
|
+
routes: [
|
|
42
|
+
{
|
|
43
|
+
endpoint: "GET *",
|
|
44
|
+
fetch: (request) => {
|
|
45
|
+
return new Response(
|
|
46
|
+
request.origin.startsWith("http:")
|
|
47
|
+
? `Welcome http user`
|
|
48
|
+
: `Welcome https user`,
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## http2
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
await startServer({
|
|
60
|
+
https: { certificate, privateKey },
|
|
61
|
+
http2: true,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Http2 needs https (browsers do not speak it in clear). Http/1.1 clients are still accepted, unless `http1Allowed: false`.
|
|
66
|
+
|
|
67
|
+
**When it is worth it.** A browser opens at most 6 http/1.1 connections per origin, http2 multiplexes everything on one. Measured with Chromium loading a page of 300 ES modules served by this package:
|
|
68
|
+
|
|
69
|
+
| network | http/1.1 | http2 |
|
|
70
|
+
| ------------------------------------- | -------- | ------ |
|
|
71
|
+
| localhost | 80 ms | 83 ms |
|
|
72
|
+
| 5 ms of latency (a phone on the wifi) | 369 ms | 131 ms |
|
|
73
|
+
| 20 ms of latency | 1300 ms | 135 ms |
|
|
74
|
+
|
|
75
|
+
Nothing to gain on the machine itself; a lot as soon as there is latency, so serving other devices (`acceptAnyIp`) is the case for it. The price is a certificate the device trusts.
|
|
76
|
+
|
|
77
|
+
**What is lost.** Http2 has no reason phrase (RFC 9113): the `statusText` of a response only reaches the server logs and the body of 4xx/5xx responses, browser devtools show the status code alone. Http/1.1 stays the default for that reason: during development the status line is where the server explains a 404 or a 403 at a glance.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Lifecycle
|
|
2
|
+
|
|
3
|
+
```js
|
|
4
|
+
import { startServer } from "@jsenv/server";
|
|
5
|
+
|
|
6
|
+
const server = await startServer({ port: 3000 });
|
|
7
|
+
server.origin; // "http://localhost:3000"
|
|
8
|
+
server.origins; // { local: "http://localhost:3000", localip: "http://127.0.0.1:3000" }
|
|
9
|
+
server.port; // 3000
|
|
10
|
+
server.getStatus(); // "opened"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`startServer` resolves once the server listens. With `port: 0` (the default) the OS picks a free port, `server.port` tells which one; `portHint` asks for a port and takes the next free one when it is busy. `hostname` ("localhost" by default) can be an ip. `acceptAnyIp: true` listens on every interface so that other machines reach the server, at `server.origins.externalip`.
|
|
14
|
+
|
|
15
|
+
## Stopping
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
await server.stop();
|
|
19
|
+
server.getStatus(); // "stopped"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`stop` answers every request still running with 503, then closes every connection, and resolves once everything is closed. A response already being written is cut short: its status is already sent. It takes a reason, anything, which `stoppedPromise` resolves with:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { startServer, STOP_REASON_PROCESS_SIGINT } from "@jsenv/server";
|
|
26
|
+
|
|
27
|
+
const server = await startServer();
|
|
28
|
+
server.stoppedPromise.then((reason) => {
|
|
29
|
+
if (reason === STOP_REASON_PROCESS_SIGINT) {
|
|
30
|
+
console.log("stopped by ctrl+c");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The server stops by itself on SIGINT (`stopOnSIGINT`, off inside a cluster worker where the primary process is in charge) and on SIGHUP, SIGTERM, beforeExit, exit (`stopOnExit`); the reasons are the `STOP_REASON_PROCESS_*` exports. `stopOnInternalError` stops it when a route throws. The `signal` option cancels the start itself.
|
|
36
|
+
|
|
37
|
+
## Keeping the process alive
|
|
38
|
+
|
|
39
|
+
A listening server keeps the process alive. `keepProcessAlive: false` lets the process exit when nothing else runs — tests want that: the server stops on its own once the test is done.
|
|
40
|
+
|
|
41
|
+
## Running something as long as the server runs
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
server.addEffect(() => {
|
|
45
|
+
const interval = setInterval(doSomething, 1000);
|
|
46
|
+
return () => {
|
|
47
|
+
clearInterval(interval);
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The callback runs right away, the function it returns runs when the server stops. Plugins have `serverListening` and `serverStopped` hooks for the same purpose (see [plugins](./plugins.md)).
|
package/docs/plugins.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Plugins
|
|
2
|
+
|
|
3
|
+
A server plugin is an object with a `name` and some of the hooks below. Plugins run in the order they are given, after the built-in ones. An array of plugins is flattened, so a function can return several plugins, or none (`serverPluginCORS` returns `[]` when CORS stays disabled).
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
await startServer({
|
|
7
|
+
plugins: [
|
|
8
|
+
{
|
|
9
|
+
name: "my_plugin",
|
|
10
|
+
routes: [{ endpoint: "GET /health", fetch: () => new Response("ok") }],
|
|
11
|
+
injectResponseProperties: () => {
|
|
12
|
+
return { headers: { "x-powered-by": "jsenv" } };
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Hooks
|
|
20
|
+
|
|
21
|
+
In the order they run for a request:
|
|
22
|
+
|
|
23
|
+
| hook | signature | role |
|
|
24
|
+
| ---------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `serverListening` | `({ port })` | once, when the server listens |
|
|
26
|
+
| `redirectRequest` | `(request) => { resource } \| { pathname } \| { ...requestProperties } \| null` | replace the request seen by the routes; `request.original` keeps the first one |
|
|
27
|
+
| `augmentRouteFetchSecondArg` | `async (request, helpers) => { ...moreHelpers } \| null` | add helpers for the routes |
|
|
28
|
+
| `grantPermissions` | `async (request) => ["permission", …] \| null` | called lazily, at most once per request, only when a route declares permissions |
|
|
29
|
+
| `routes` | an array of route descriptors | appended after the routes given to `startServer` |
|
|
30
|
+
| `handleError` | `async (error, { request }) => response \| null` | answer a route that threw; the first response wins ([handling errors](./handling_errors.md)) |
|
|
31
|
+
| `injectResponseProperties` | `(request, response) => responseToCompose \| null` | compose something into every response — headers mostly, see `composeTwoResponses` |
|
|
32
|
+
| `inspectResponse` | `(request, { response, warn })` | look at the final response, after every injection, before it is sent (`response` is `{ status, statusText, headers, body }`); `warn` logs for that request |
|
|
33
|
+
| `serverStopped` | `({ reason })` | once, when the server stopped |
|
|
34
|
+
|
|
35
|
+
`redirectRequest` and `injectResponseProperties` are synchronous; `augmentRouteFetchSecondArg`, `grantPermissions` and `handleError` may return a promise.
|
|
36
|
+
|
|
37
|
+
Lifecycle properties: `init()` runs before the server starts (returning `false` disables the plugin, returning a function registers it as `destroy`), `destroy()` runs when the server stops, `effect({ otherPlugins })` runs after every `init` (returning nothing disables the plugin).
|
|
38
|
+
|
|
39
|
+
## Built-in plugins
|
|
40
|
+
|
|
41
|
+
Always on:
|
|
42
|
+
|
|
43
|
+
- response cookies — `helpers.responseCookies.set(name, value, options)` and `.delete(name)`,
|
|
44
|
+
- default body for 4xx/5xx — a response without body for such a status gets one from its `statusText`/`statusMessage`,
|
|
45
|
+
- route inspector — `GET /.internal/route_inspector` and `GET /.internal/routes.json`.
|
|
46
|
+
|
|
47
|
+
With `canExposeSensitiveData` (development only, see [security](./security.md)):
|
|
48
|
+
|
|
49
|
+
- open file — `GET /.internal/open_file/*` opens a file of the machine in the editor,
|
|
50
|
+
- internal client files — `GET /@jsenv/server/*` serves this package's client files,
|
|
51
|
+
- autoreload on restart — `GET /.internal/alive.websocket` and `GET /.internal/alive.eventsource` close when the server stops, so a client reloads.
|
|
52
|
+
|
|
53
|
+
Exported, to add yourself: `serverPluginCORS`, `serverPluginErrorHandler`, `serverPluginRequestAliases`, `serverPluginResponseAcceptanceCheck`. Their options are in their JSDoc.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/security.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
What the server takes care of, and what it leaves to the code around it.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- A malformed header (`forwarded`, `cookie`, `referer`, `accept`…) never takes the server down: a request that cannot be read is answered 500 and logged.
|
|
8
|
+
- **The `host` header is checked.** A request whose host is not one of the names the server is reached at (localhost, `hostname`, the machine name and ips, `allowedHosts`) is answered 403. Without this a page served by another site reads the responses once its DNS name is rebound to this machine — the request comes from the developer's own browser, so listening on localhost is no protection. A custom name (an `/etc/hosts` entry, a tunnel) goes in `hostname` or `allowedHosts`; `allowedHosts: true` disables the check.
|
|
9
|
+
- **Request bodies are bounded.** `request.json()`, `text()`, `buffer()` and `queryString()` answer 413 past `requestBodyMaxSize` (1 MiB by default), from the `content-length` when there is one, else while reading; the rest of the body is drained for a few seconds so that the client gets the 413, then the connection is cut. A route accepting more passes its own `{ maxSize }`. `request.body` (the observable) is never limited: who streams it takes the responsibility. `formData()` is bounded by formidable (200 MB per file, 20 MB of fields).
|
|
10
|
+
- What a response echoes from the request (the url in a 404 message, a file path in a status text) is escaped in the html error pages, and status texts are reduced to what a status line accepts.
|
|
11
|
+
- `createFileSystemFetch` refuses to leave its directory (403).
|
|
12
|
+
- A route declaring permissions hides every route that declares none: forgetting `permissionsRequired` cannot expose a route ([handling requests](./handling_requests.md)).
|
|
13
|
+
- Once one route has permissions, the route inspector only lists what the requester may see.
|
|
14
|
+
|
|
15
|
+
## `canExposeSensitiveData`
|
|
16
|
+
|
|
17
|
+
This option lets the server hand out what belongs to the machine it runs on. It is meant for the developer's machine, never for a server others can reach. It unlocks:
|
|
18
|
+
|
|
19
|
+
- file paths in status texts (a 404 from `createFileSystemFetch` says which path);
|
|
20
|
+
- the declaration source of every route in the route inspector, where every route is listed whatever its permissions;
|
|
21
|
+
- `GET /.internal/open_file/*`, which opens any file of the machine in the editor;
|
|
22
|
+
- `GET /@jsenv/server/*`, which serves this package's own files;
|
|
23
|
+
- `/.internal/alive.websocket` and `/.internal/alive.eventsource`, telling clients when the server restarts.
|
|
24
|
+
|
|
25
|
+
`sendErrorDetails` of `serverPluginErrorHandler` is of the same kind: a stack reveals file paths and code.
|
|
26
|
+
|
|
27
|
+
## What it leaves to you
|
|
28
|
+
|
|
29
|
+
- **Forwarded headers are trusted as-is.** `request.ipForwarded`, `protoForwarded` and `hostForwarded` are what the `forwarded` / `x-forwarded-*` headers say; any client can send them. Read them only behind a proxy you control, `request.ip` is the socket address.
|
|
30
|
+
- **Uploaded files are not cleaned up.** `formData()` writes them to the temp directory and leaves them there.
|
|
31
|
+
- **A route that throws with no error handler exits the process.** Always run with `serverPluginErrorHandler` (or a `handleError` plugin), see [handling errors](./handling_errors.md).
|
|
32
|
+
- **No rate limiting, no authentication.** `grantPermissions` is where a session or a token becomes permissions; what it checks is up to you.
|