@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.
Files changed (44) hide show
  1. package/dist/jsenv_server.js +49 -35
  2. package/docs/AI_INSTRUCTIONS.md +29 -0
  3. package/docs/cluster.md +122 -0
  4. package/docs/content_negotiation.md +78 -0
  5. package/docs/cors.md +32 -0
  6. package/docs/demos/cluster/cluster_demo_primary.mjs +31 -0
  7. package/docs/demos/cluster/cluster_demo_primary_watch.mjs +52 -0
  8. package/docs/demos/cluster/cluster_demo_server.mjs +10 -0
  9. package/docs/demos/cors/cors_error_demo.mjs +29 -0
  10. package/docs/demos/https/https_file_demo.mjs +21 -0
  11. package/docs/demos/https/server.crt +24 -0
  12. package/docs/demos/https/server.key +27 -0
  13. package/docs/demos/node_fetch_demo.mjs +14 -0
  14. package/docs/demos/server_sent_events/client.js +6 -0
  15. package/docs/demos/server_sent_events/run.js +2 -0
  16. package/docs/demos/server_sent_events/server.js +20 -0
  17. package/docs/demos/websocket/client.html +16 -0
  18. package/docs/demos/websocket/server.js +26 -0
  19. package/docs/handling_errors.md +75 -0
  20. package/docs/handling_requests.md +201 -0
  21. package/docs/https.md +77 -0
  22. package/docs/lifecycle.md +52 -0
  23. package/docs/plugins.md +53 -0
  24. package/docs/screenshots/500.png +0 -0
  25. package/docs/screenshots/500_expanded.png +0 -0
  26. package/docs/screenshots/500_expanded_and_details_enabled.png +0 -0
  27. package/docs/screenshots/devtools_TTFB.png +0 -0
  28. package/docs/screenshots/devtools_server_timing.png +0 -0
  29. package/docs/screenshots/sse-screencast.gif +0 -0
  30. package/docs/screenshots/sse-screencast.mp4 +0 -0
  31. package/docs/security.md +32 -0
  32. package/docs/server_sent_events.md +64 -0
  33. package/docs/server_timing.md +70 -0
  34. package/docs/serving_files.md +64 -0
  35. package/docs/websocket.md +64 -0
  36. package/package.json +4 -3
  37. package/src/internal/track_server_pending_requests.js +14 -9
  38. package/src/plugins/cors/server_plugin_cors.js +8 -6
  39. package/src/plugins/filesystem/fetch_file.js +3 -2
  40. package/src/plugins/server_plugins_controller.js +2 -2
  41. package/src/progressive_response.js +1 -1
  42. package/src/router/router.js +4 -2
  43. package/src/server_events.js +5 -3
  44. 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,6 @@
1
+ import { EventSource } from "eventsource";
2
+
3
+ const eventSource = new EventSource("http://localhost:3456/events");
4
+ eventSource.addEventListener("ping", (event) => {
5
+ console.log("> ping from server", event.lastEventId, JSON.parse(event.data));
6
+ });
@@ -0,0 +1,2 @@
1
+ import("./server.js");
2
+ import("./client.js");
@@ -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
+ ![screenshot of internal error page](./screenshots/500.png)
24
+
25
+ ![screenshot of internal error page expanded](./screenshots/500_expanded.png)
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
+ ![screenshot of internal error page with details expanded](./screenshots/500_expanded_and_details_enabled.png)
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)).
@@ -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
@@ -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.