@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/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.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Server-Sent Events
|
|
2
|
+
|
|
3
|
+
`ServerEvents` keeps track of the connected clients and broadcasts events to them. A client connects with an `EventSource` (`accept: text/event-stream`) or with a `WebSocket`: `serverEvents.fetch` handles both.
|
|
4
|
+
|
|
5
|
+
_server.js_
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { ServerEvents, startServer } from "@jsenv/server";
|
|
9
|
+
|
|
10
|
+
const serverEvents = new ServerEvents();
|
|
11
|
+
|
|
12
|
+
setInterval(() => {
|
|
13
|
+
serverEvents.sendEventToAllClients({
|
|
14
|
+
type: "ping",
|
|
15
|
+
data: JSON.stringify({ ts: Date.now() }),
|
|
16
|
+
});
|
|
17
|
+
}, 1000);
|
|
18
|
+
|
|
19
|
+
await startServer({
|
|
20
|
+
port: 3456,
|
|
21
|
+
routes: [
|
|
22
|
+
{
|
|
23
|
+
endpoint: "GET /events",
|
|
24
|
+
fetch: serverEvents.fetch,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
_client.js_
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const eventSource = new EventSource("http://localhost:3456/events");
|
|
34
|
+
eventSource.addEventListener("ping", (event) => {
|
|
35
|
+
console.log("ping from server", event.lastEventId, JSON.parse(event.data));
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
An event is `{ type, data, id, retry }`; `data` is sent as is (stringify objects yourself) and must hold no line break: the event stream is line based, a line break ends the event. `JSON.stringify` without indentation is safe. Every event gets an incrementing `id` and is kept (`historyLength`, 1000 by default): a client reconnecting with a `last-event-id` header, or a `?last-event-id=` search param, receives what it missed. A comment is sent every `keepaliveDuration` (30s) so that proxies keep the connection open.
|
|
42
|
+
|
|
43
|
+
A websocket client receives each event as one message, the whole event as json: `JSON.parse(message.data)` gives `{ type, data, id }`.
|
|
44
|
+
|
|
45
|
+
Past `maxClientAllowed` (100) a new client is refused with 503, or the oldest one is disconnected with `actionOnClientLimitReached: "kick-oldest"`. `close()` disconnects everyone and answers 204 until `open()`. `getClientCount()` and `getAllEventSince(id)` tell where things stand.
|
|
46
|
+
|
|
47
|
+
## Producing events only when someone listens
|
|
48
|
+
|
|
49
|
+
`LazyServerEvents` runs a producer when the first client connects and its cleanup when the last one leaves — a file watcher, a database subscription, a timer. Only `fetch` is exposed: events can only come from the producer.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { LazyServerEvents } from "@jsenv/server";
|
|
53
|
+
|
|
54
|
+
const ticks = new LazyServerEvents(({ sendEvent }) => {
|
|
55
|
+
const interval = setInterval(() => {
|
|
56
|
+
sendEvent({ type: "tick", data: new Date().toISOString() });
|
|
57
|
+
}, 1000);
|
|
58
|
+
return () => {
|
|
59
|
+
clearInterval(interval);
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// route: { endpoint: "GET /ticks", fetch: ticks.fetch }
|
|
64
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Server timing
|
|
2
|
+
|
|
3
|
+
`serverTiming: true` sends a `server-timing` response header telling what the server spent its time on. Devtools show it next to the TTFB of each request.
|
|
4
|
+
|
|
5
|
+
_Chrome devtools, TTFB:_
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
_Chrome devtools, server timing:_
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { startServer } from "@jsenv/server";
|
|
15
|
+
|
|
16
|
+
await startServer({
|
|
17
|
+
serverTiming: true,
|
|
18
|
+
routes: [
|
|
19
|
+
{
|
|
20
|
+
endpoint: "GET *",
|
|
21
|
+
fetch: async (request, { timing }) => {
|
|
22
|
+
const waitTiming = timing("waiting 50ms");
|
|
23
|
+
await new Promise((resolve) => {
|
|
24
|
+
setTimeout(resolve, 50);
|
|
25
|
+
});
|
|
26
|
+
waitTiming.end();
|
|
27
|
+
return new Response("Hello");
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
server-timing: a;desc="time to start responding";dur=51.2, b;desc="routing";dur=0.1, c;desc="waiting 50ms";dur=50.7
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The header holds:
|
|
39
|
+
|
|
40
|
+
- `time to start responding`, from the request to the response headers;
|
|
41
|
+
- the routing of each group of routes (`routing`, or `<plugin>.routing` for the routes a plugin brought);
|
|
42
|
+
- what the route measured with `helpers.timing(name)`; `createFileSystemFetch` measures its stat and etag;
|
|
43
|
+
- the `timing` property a plain response object hands back: `{ "db query": 12.3, "served from cache": null }` — a `null` duration is a marker, something to say with nothing to measure.
|
|
44
|
+
|
|
45
|
+
Entries are named `a`, `b`, `c`… because devtools sort them alphabetically; the `desc` carries the name.
|
|
46
|
+
|
|
47
|
+
`serverTiming: { minDuration: 0.5 }` drops the entries that took less than that many milliseconds: a request crosses every route, and a wall of sub-millisecond `routing` entries buries the measures worth reading. `time to start responding` and the markers always stay.
|
|
48
|
+
|
|
49
|
+
## Measuring without telling everyone
|
|
50
|
+
|
|
51
|
+
The header describes what the server does internally; an api usually wants it for itself, a bench or a probe, and for nobody else. Give a function instead of `true` and it is asked once per request, on what the client sent:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const PERF_TIMING_TOKEN = process.env.PERF_TIMING_TOKEN;
|
|
55
|
+
const isPerfTimingClient = (request) =>
|
|
56
|
+
request.headers["x-perf-timing"] === PERF_TIMING_TOKEN;
|
|
57
|
+
|
|
58
|
+
await startServer({
|
|
59
|
+
serverTiming: isPerfTimingClient,
|
|
60
|
+
plugins: [
|
|
61
|
+
serverPluginCORS({
|
|
62
|
+
accessControlAllowedOrigins: ["https://app.example.com"],
|
|
63
|
+
// without it a page cannot read the timings of a cross-origin response
|
|
64
|
+
timingAllowOrigin: isPerfTimingClient,
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`{ enabled: isPerfTimingClient, minDuration: 1 }` says the same thing with a `minDuration`. When the predicate says no nothing is measured for that request and no header is composed; routes still call `helpers.timing(name)` unconditionally, it costs them nothing.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Serving files
|
|
2
|
+
|
|
3
|
+
```js
|
|
4
|
+
import { createFileSystemFetch, startServer } from "@jsenv/server";
|
|
5
|
+
|
|
6
|
+
await startServer({
|
|
7
|
+
routes: [
|
|
8
|
+
{
|
|
9
|
+
endpoint: "GET /assets/*",
|
|
10
|
+
fetch: createFileSystemFetch(import.meta.resolve("./assets/")),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
endpoint: "GET *",
|
|
14
|
+
fetch: createFileSystemFetch(import.meta.resolve("./public/"), {
|
|
15
|
+
mainFileRelativeUrl: "./index.html",
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The part of the url captured by `*` (the whole resource when the endpoint has none) is resolved inside the directory. A url leaving the directory (`..`) is answered 403. Any method other than GET and HEAD is declined: the next route is tried. The `content-type` comes from the file extension.
|
|
23
|
+
|
|
24
|
+
The options are in the JSDoc of `createFileSystemFetch`; what follows is how they combine.
|
|
25
|
+
|
|
26
|
+
## Client cache
|
|
27
|
+
|
|
28
|
+
By default every request gets 200 with the content. Two ways to unlock 304:
|
|
29
|
+
|
|
30
|
+
- `etagEnabled` — an `etag` computed from the content, remembered per file until its stats change (`etagMemory`, `etagMemoryMaxSize`); a matching `if-none-match` gets 304. Robust.
|
|
31
|
+
- `mtimeEnabled` — a `last-modified` header from the filesystem date, second precision; a recent enough `if-modified-since` gets 304. Relies on filesystem dates being meaningful.
|
|
32
|
+
|
|
33
|
+
When both are set, etag wins.
|
|
34
|
+
|
|
35
|
+
`cacheControl` becomes the `cache-control` header. By default a versioned url (`isVersioned`: the url has a `v` search param) is `private,max-age=2592000,immutable` and anything else `private,max-age=0,must-revalidate`. It can be a function of the request:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
createFileSystemFetch(import.meta.resolve("./"), {
|
|
39
|
+
etagEnabled: true,
|
|
40
|
+
cacheControl: (request) =>
|
|
41
|
+
request.resource === "/"
|
|
42
|
+
? `private,max-age=0,must-revalidate`
|
|
43
|
+
: `private,max-age=3600,immutable`,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`"no-store"` disables etag and mtime, they would be pointless.
|
|
48
|
+
|
|
49
|
+
## Compression
|
|
50
|
+
|
|
51
|
+
`compressionEnabled` compresses textual files bigger than `compressionSizeThreshold` (1024 bytes) with brotli, gzip or deflate, whichever `accept-encoding` prefers. The compression happens for every request (brotli at quality 4 to keep it cheap) and the response has no `content-length`: for production, compress the files once at build time instead.
|
|
52
|
+
|
|
53
|
+
## Directories
|
|
54
|
+
|
|
55
|
+
A url pointing to a directory gets 403, unless:
|
|
56
|
+
|
|
57
|
+
- `canReadDirectory: true` — the listing is sent, as json or as an html page of links (see `fetchDirectory`),
|
|
58
|
+
- `mainFileRelativeUrl` — that file is sent for the root of the served directory, and for any extension-less url that does not exist (client side routing needs the same page for `/users/42`). A subdirectory still gets 403.
|
|
59
|
+
|
|
60
|
+
`ENOENTFallback: () => fileUrl` serves another file when the requested one does not exist.
|
|
61
|
+
|
|
62
|
+
## What a 404 says
|
|
63
|
+
|
|
64
|
+
The status text is `ENOENT: File not found`; the file path is added only when the server runs with `canExposeSensitiveData` (see [security](./security.md)). Same for the other filesystem errors (EACCES → 403, EBUSY → 503 with `retry-after`, …).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# WebSocket
|
|
2
|
+
|
|
3
|
+
A route accepts a websocket by returning a `WebSocketResponse`:
|
|
4
|
+
|
|
5
|
+
_server.js_
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import {
|
|
9
|
+
createFileSystemFetch,
|
|
10
|
+
startServer,
|
|
11
|
+
WebSocketResponse,
|
|
12
|
+
} from "@jsenv/server";
|
|
13
|
+
|
|
14
|
+
await startServer({
|
|
15
|
+
port: 3000,
|
|
16
|
+
routes: [
|
|
17
|
+
{
|
|
18
|
+
endpoint: "GET /chat.websocket",
|
|
19
|
+
fetch: () => {
|
|
20
|
+
return new WebSocketResponse((websocket) => {
|
|
21
|
+
websocket.send("Hello world");
|
|
22
|
+
websocket.on("message", (data) => {
|
|
23
|
+
websocket.send(data);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
// the client is gone
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
endpoint: "GET *",
|
|
33
|
+
fetch: createFileSystemFetch(import.meta.resolve("./")),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
_client.html_
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<!doctype html>
|
|
43
|
+
<html lang="en">
|
|
44
|
+
<head>
|
|
45
|
+
<title>Title</title>
|
|
46
|
+
<link rel="icon" href="data:," />
|
|
47
|
+
</head>
|
|
48
|
+
|
|
49
|
+
<body>
|
|
50
|
+
<script>
|
|
51
|
+
const websocket = new WebSocket("ws://localhost:3000/chat.websocket");
|
|
52
|
+
websocket.onmessage = (message) => {
|
|
53
|
+
document.body.appendChild(document.createTextNode(message.data));
|
|
54
|
+
};
|
|
55
|
+
</script>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A route is a websocket route when its endpoint ends with `.websocket` or its `headers` pattern has `upgrade: "websocket"`; a plain request to it is answered 426. The handler receives the [ws](https://github.com/websockets/ws/blob/master/doc/ws.md) socket once the upgrade is done; if it returns a function, that function runs when the socket closes.
|
|
61
|
+
|
|
62
|
+
A websocket route can still refuse the upgrade by returning a regular response (a 401 for instance). A 101 response that is not a `WebSocketResponse` is an error, and so is a `WebSocketResponse` returned to a request that did not ask for an upgrade.
|
|
63
|
+
|
|
64
|
+
`server.webSocketOrigin` is the `ws://` (or `wss://`) origin of the server. To broadcast to many clients, see [server sent events](./server_sent_events.md): `ServerEvents` accepts websocket clients too.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/server",
|
|
3
|
-
"version": "17.6.
|
|
3
|
+
"version": "17.6.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"/dist/",
|
|
31
|
+
"/docs/",
|
|
31
32
|
"/index.js",
|
|
32
33
|
"/src/"
|
|
33
34
|
],
|
|
@@ -54,10 +55,10 @@
|
|
|
54
55
|
"@jsenv/url-pattern": "../../tooling/url-pattern",
|
|
55
56
|
"@jsenv/urls": "../../tooling/urls",
|
|
56
57
|
"@jsenv/utils": "../../tooling/utils",
|
|
57
|
-
"eventsource": "5.1.
|
|
58
|
+
"eventsource": "5.1.2",
|
|
58
59
|
"formidable": "3.5.4",
|
|
59
60
|
"playwright": "1.63.0",
|
|
60
|
-
"undici": "8.
|
|
61
|
+
"undici": "8.11.2",
|
|
61
62
|
"ws": "8.21.3"
|
|
62
63
|
},
|
|
63
64
|
"publishConfig": {
|
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
} from "../internal/colorize_response_status.js";
|
|
21
21
|
import { headersFromObject } from "../internal/headers_from_object.js";
|
|
22
22
|
import { parseSingleHeaderWithAttributes } from "../internal/multiple_header.js";
|
|
23
|
+
import {
|
|
24
|
+
readRequestTarget,
|
|
25
|
+
resourceToUrlObject,
|
|
26
|
+
} from "../internal/request_target.js";
|
|
23
27
|
import { observableFromNodeStream } from "./observable_from_node_stream.js";
|
|
24
28
|
|
|
25
29
|
export const fromNodeRequest = (
|
|
@@ -66,8 +70,13 @@ export const fromNodeRequest = (
|
|
|
66
70
|
nodeRequest.pause();
|
|
67
71
|
const body = observableFromNodeStream(nodeRequest);
|
|
68
72
|
|
|
73
|
+
// an absolute-form target names the origin the request is for; the host
|
|
74
|
+
// header is ignored in that case (RFC 9112 section 3.3)
|
|
75
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
69
76
|
let requestOrigin;
|
|
70
|
-
if (
|
|
77
|
+
if (requestTarget.origin) {
|
|
78
|
+
requestOrigin = requestTarget.origin;
|
|
79
|
+
} else if (nodeRequest.upgrade) {
|
|
71
80
|
requestOrigin = serverOrigin;
|
|
72
81
|
} else if (nodeRequest.authority) {
|
|
73
82
|
requestOrigin = nodeRequest.connection.encrypted
|
|
@@ -200,7 +209,7 @@ export const fromNodeRequest = (
|
|
|
200
209
|
http2: Boolean(nodeRequest.stream),
|
|
201
210
|
origin: requestOrigin,
|
|
202
211
|
...getPropertiesFromResource({
|
|
203
|
-
resource:
|
|
212
|
+
resource: requestTarget.resource,
|
|
204
213
|
baseUrl: requestOrigin,
|
|
205
214
|
}),
|
|
206
215
|
method: nodeRequest.method,
|
|
@@ -440,13 +449,12 @@ export const applyRedirectionToRequest = (
|
|
|
440
449
|
};
|
|
441
450
|
};
|
|
442
451
|
const getPropertiesFromResource = ({ resource, baseUrl }) => {
|
|
443
|
-
const urlObject =
|
|
444
|
-
let pathname = urlObject.pathname;
|
|
452
|
+
const urlObject = resourceToUrlObject(resource, baseUrl);
|
|
445
453
|
|
|
446
454
|
return {
|
|
447
455
|
url: String(urlObject),
|
|
448
456
|
searchParams: urlObject.searchParams,
|
|
449
|
-
pathname,
|
|
457
|
+
pathname: urlObject.pathname,
|
|
450
458
|
resource,
|
|
451
459
|
};
|
|
452
460
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A request target is the text between the method and the http version on the
|
|
3
|
+
* request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
|
|
4
|
+
* It is a path, not a url reference, and the two must never be confused:
|
|
5
|
+
* "//wp-includes/x" is an origin-form target whose first path segment is
|
|
6
|
+
* empty, but `new URL("//wp-includes/x", origin)` reads it as a
|
|
7
|
+
* protocol-relative reference and turns "wp-includes" into the host. So the
|
|
8
|
+
* path is assigned onto the origin instead, and what a client sends can never
|
|
9
|
+
* replace that origin.
|
|
10
|
+
*
|
|
11
|
+
* The one form that legitimately names an origin is absolute-form
|
|
12
|
+
* ("GET http://host/path", sent by proxies and open-proxy scanners); it is
|
|
13
|
+
* read as such so the origin it names goes through the allowedHosts check
|
|
14
|
+
* like any other host, instead of reaching the routes unchecked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} target the raw request target
|
|
21
|
+
* @returns {{ origin: string|null, host: string|null, resource: string }|null}
|
|
22
|
+
* null when the target cannot be read; the server answers 400
|
|
23
|
+
*/
|
|
24
|
+
export const readRequestTarget = (target) => {
|
|
25
|
+
if (!ABSOLUTE_FORM_REGEX.test(target)) {
|
|
26
|
+
// origin-form ("/path?query"), asterisk-form ("*") or authority-form
|
|
27
|
+
return { origin: null, host: null, resource: target };
|
|
28
|
+
}
|
|
29
|
+
if (!URL.canParse(target)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const urlObject = new URL(target);
|
|
33
|
+
if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
origin: urlObject.origin,
|
|
38
|
+
host: urlObject.host,
|
|
39
|
+
resource: `${urlObject.pathname}${urlObject.search}`,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// the search of baseUrl is dropped: the resource carries its own
|
|
44
|
+
export const resourceToUrlObject = (resource, baseUrl) => {
|
|
45
|
+
const urlObject = new URL(baseUrl);
|
|
46
|
+
const searchSeparatorIndex = resource.indexOf("?");
|
|
47
|
+
if (searchSeparatorIndex === -1) {
|
|
48
|
+
urlObject.pathname = resource;
|
|
49
|
+
urlObject.search = "";
|
|
50
|
+
} else {
|
|
51
|
+
urlObject.pathname = resource.slice(0, searchSeparatorIndex);
|
|
52
|
+
urlObject.search = resource.slice(searchSeparatorIndex);
|
|
53
|
+
}
|
|
54
|
+
return urlObject;
|
|
55
|
+
};
|