@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,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": {
|
|
@@ -21,14 +21,6 @@ export const trackServerPendingRequests = (nodeServer) => {
|
|
|
21
21
|
pendingClients.clear();
|
|
22
22
|
await Promise.all(
|
|
23
23
|
pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
|
|
24
|
-
if (nodeResponse.headersSent === false) {
|
|
25
|
-
if (nodeRequest.stream) {
|
|
26
|
-
// http2 has no reason phrase
|
|
27
|
-
nodeResponse.writeHead(status);
|
|
28
|
-
} else {
|
|
29
|
-
nodeResponse.writeHead(status, asReasonPhrase(reason));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
24
|
return new Promise((resolve) => {
|
|
33
25
|
if (nodeResponse.destroyed) {
|
|
34
26
|
resolve();
|
|
@@ -37,7 +29,20 @@ export const trackServerPendingRequests = (nodeServer) => {
|
|
|
37
29
|
nodeResponse.once("close", () => {
|
|
38
30
|
resolve();
|
|
39
31
|
});
|
|
40
|
-
nodeResponse.
|
|
32
|
+
if (nodeResponse.headersSent) {
|
|
33
|
+
// a status already sent cannot be replaced: the response is cut
|
|
34
|
+
nodeResponse.destroy();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (nodeRequest.stream) {
|
|
38
|
+
// http2 has no reason phrase, nor a connection header
|
|
39
|
+
nodeResponse.writeHead(status);
|
|
40
|
+
} else {
|
|
41
|
+
nodeResponse.writeHead(status, asReasonPhrase(reason), {
|
|
42
|
+
connection: "close",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
nodeResponse.end();
|
|
41
46
|
});
|
|
42
47
|
}),
|
|
43
48
|
);
|
|
@@ -115,9 +115,10 @@ const createAllowedOriginChecker = (allowedOrigins) => {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
return {
|
|
118
|
-
// when the request origin cannot be reflected back
|
|
119
|
-
//
|
|
120
|
-
|
|
118
|
+
// sent when the request origin cannot be reflected back: a single valid
|
|
119
|
+
// origin, never a pattern, and never "*" which would allow every origin.
|
|
120
|
+
// With only patterns there is nothing to send.
|
|
121
|
+
defaultOrigin: literalOrigins[0] ?? null,
|
|
121
122
|
isAllowed: (origin) => {
|
|
122
123
|
if (literalOrigins.includes(origin)) {
|
|
123
124
|
return true;
|
|
@@ -161,7 +162,6 @@ const generateAccessControlHeaders = ({
|
|
|
161
162
|
|
|
162
163
|
// Access-Control-Allow-Origin must be a single value (not a list).
|
|
163
164
|
// We reflect back the request's origin if it is in the allowed list.
|
|
164
|
-
// If no origin matches we fall back to "*" (only when not using credentials).
|
|
165
165
|
let allowOrigin = null;
|
|
166
166
|
|
|
167
167
|
const requestOrigin = readRequestOrigin(headers);
|
|
@@ -213,14 +213,16 @@ const generateAccessControlHeaders = ({
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
return {
|
|
216
|
-
|
|
216
|
+
...(allowOrigin === null
|
|
217
|
+
? {}
|
|
218
|
+
: { "access-control-allow-origin": allowOrigin }),
|
|
217
219
|
"access-control-allow-methods": allowedMethodArray.join(", "),
|
|
218
220
|
"access-control-allow-headers": allowedHeaderArray.join(", "),
|
|
219
221
|
...(accessControlAllowCredentials
|
|
220
222
|
? { "access-control-allow-credentials": true }
|
|
221
223
|
: {}),
|
|
222
224
|
"access-control-max-age": accessControlMaxAge,
|
|
223
|
-
...(timingAllowOriginEnabled(request)
|
|
225
|
+
...(allowOrigin !== null && timingAllowOriginEnabled(request)
|
|
224
226
|
? { "timing-allow-origin": allowOrigin }
|
|
225
227
|
: {}),
|
|
226
228
|
...(vary.length ? { vary: vary.join(", ") } : {}),
|
|
@@ -50,8 +50,9 @@ export const createFileSystemFetch = (directoryUrl, options) => {
|
|
|
50
50
|
* @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
|
|
51
51
|
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
52
52
|
* @param {Object} [options]
|
|
53
|
-
* @param {string} [options.mainFileRelativeUrl] - File served for the
|
|
54
|
-
* as a fallback, for extension-less urls that do not exist (client side routing).
|
|
53
|
+
* @param {string} [options.mainFileRelativeUrl] - File served for the root of `directoryUrl`
|
|
54
|
+
* and, as a fallback, for extension-less urls that do not exist (client side routing).
|
|
55
|
+
* A subdirectory still gets 403 unless `canReadDirectory`.
|
|
55
56
|
* @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
|
|
56
57
|
* answer 304 to a matching `if-none-match`.
|
|
57
58
|
* @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
|
|
@@ -14,10 +14,10 @@ const SERVER_PLUGIN_PROPERTIES = {
|
|
|
14
14
|
grantPermissions: { type: "hook" },
|
|
15
15
|
// async (error, { request }) => response | null
|
|
16
16
|
handleError: { type: "hook" },
|
|
17
|
-
// (request, { response, warn }) to look at the response before it is sent
|
|
18
|
-
inspectResponse: { type: "hook" },
|
|
19
17
|
// (request, response) => responseToCompose | null
|
|
20
18
|
injectResponseProperties: { type: "hook" },
|
|
19
|
+
// (request, { response, warn }) to look at the final response before it is sent
|
|
20
|
+
inspectResponse: { type: "hook" },
|
|
21
21
|
// ({ reason }) once the server is stopped
|
|
22
22
|
serverStopped: { type: "hook" },
|
|
23
23
|
// route descriptors, appended after the routes given to startServer
|
|
@@ -8,7 +8,7 @@ import { createObservable } from "./interfacing_with_node/observable.js";
|
|
|
8
8
|
*
|
|
9
9
|
* @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
|
|
10
10
|
* Receives `write` and `end`. If it returns a function, that function runs
|
|
11
|
-
*
|
|
11
|
+
* once the response is over: after `end()`, or when the client disconnects.
|
|
12
12
|
* @param {Object} [init]
|
|
13
13
|
* @param {number} [init.status=200]
|
|
14
14
|
* @param {string} [init.statusText]
|
package/src/router/router.js
CHANGED
|
@@ -659,7 +659,8 @@ const createRoute = ({
|
|
|
659
659
|
if (!endpoint || typeof endpoint !== "string") {
|
|
660
660
|
throw new TypeError(`endpoint must be a string, received ${endpoint}`);
|
|
661
661
|
}
|
|
662
|
-
const [method, resource] =
|
|
662
|
+
const [method, resource] =
|
|
663
|
+
endpoint === "*" ? ["*", "*"] : endpoint.split(" ");
|
|
663
664
|
if (method !== "*" && !HTTP_METHODS.includes(method)) {
|
|
664
665
|
throw new TypeError(`"${method}" is not an HTTP method`);
|
|
665
666
|
}
|
|
@@ -878,8 +879,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
|
|
|
878
879
|
* @param {Object} params - Content negotiation parameters
|
|
879
880
|
* @param {Array<string>} params.availableMediaTypes - Content types the server can produce
|
|
880
881
|
* @param {Array<string>} params.availableLanguages - Languages the server can respond with
|
|
882
|
+
* @param {Array<string|number|Function>} params.availableVersions - Versions the server can respond with
|
|
881
883
|
* @param {Array<string>} params.availableEncodings - Encodings the server supports
|
|
882
|
-
* @returns {
|
|
884
|
+
* @returns {Object} 406 response properties
|
|
883
885
|
*/
|
|
884
886
|
const createNotAcceptableResponse = (
|
|
885
887
|
request,
|
package/src/server_events.js
CHANGED
|
@@ -14,7 +14,7 @@ import { WebSocketResponse } from "./web_socket_response.js";
|
|
|
14
14
|
* @class
|
|
15
15
|
* @param {Object} options - Configuration options for the SSE controller
|
|
16
16
|
* @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
|
|
17
|
-
* @param {Boolean} [options.keepProcessAlive=false] - If true,
|
|
17
|
+
* @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
|
|
18
18
|
* @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
|
|
19
19
|
* @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
|
|
20
20
|
* @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
|
|
@@ -70,7 +70,7 @@ export class ServerEvents {
|
|
|
70
70
|
* @param {Function} producer - Function called when first client connects
|
|
71
71
|
* @param {Object} [options] - Configuration options for the SSE controller
|
|
72
72
|
* @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
|
|
73
|
-
* @param {Boolean} [options.keepProcessAlive=false] - If true,
|
|
73
|
+
* @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
|
|
74
74
|
* @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
|
|
75
75
|
* @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
|
|
76
76
|
* @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
|
|
@@ -139,8 +139,9 @@ const createServerEvents = ({
|
|
|
139
139
|
} = {}) => {
|
|
140
140
|
const logger = createLogger({ logLevel });
|
|
141
141
|
|
|
142
|
+
// starts closed: open() below installs the keepalive interval
|
|
142
143
|
const serverEventSource = {
|
|
143
|
-
closed:
|
|
144
|
+
closed: true,
|
|
144
145
|
};
|
|
145
146
|
const clientArray = [];
|
|
146
147
|
const eventHistory = createEventHistory(historyLength);
|
|
@@ -382,6 +383,7 @@ const createServerEvents = ({
|
|
|
382
383
|
close,
|
|
383
384
|
open,
|
|
384
385
|
});
|
|
386
|
+
open();
|
|
385
387
|
return serverEventSource;
|
|
386
388
|
};
|
|
387
389
|
|
package/src/start_server.js
CHANGED
|
@@ -117,8 +117,9 @@ const TIMING_NOOP = () => {
|
|
|
117
117
|
* @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
|
|
118
118
|
* inside a cluster worker where the primary process is in charge.
|
|
119
119
|
* @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
|
|
120
|
-
* @param {boolean} [params.stopOnInternalError=false] - Stop
|
|
121
|
-
*
|
|
120
|
+
* @param {boolean} [params.stopOnInternalError=false] - Stop as soon as a route throws. The
|
|
121
|
+
* request that threw and every other pending request are answered 500 with no body, the
|
|
122
|
+
* "handleError" plugins' response is not sent.
|
|
122
123
|
* @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
|
|
123
124
|
* the process alive.
|
|
124
125
|
* @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
|
|
@@ -150,8 +151,9 @@ const TIMING_NOOP = () => {
|
|
|
150
151
|
* @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
|
|
151
152
|
* webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
|
|
152
153
|
* - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
|
|
153
|
-
* - `stop(reason)`:
|
|
154
|
-
*
|
|
154
|
+
* - `stop(reason)`: answers the pending requests with 503, closes every connection and
|
|
155
|
+
* resolves once done; `reason` can be anything, becomes the 503 status text and
|
|
156
|
+
* defaults to `STOP_REASON_NOT_SPECIFIED`.
|
|
155
157
|
* - `stoppedPromise`: resolves with the reason the server stopped for (one of the
|
|
156
158
|
* `STOP_REASON_*` exports or what was given to `stop`).
|
|
157
159
|
* - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
|
|
@@ -442,16 +444,16 @@ export const startServer = async ({
|
|
|
442
444
|
stopCallbackSet.add(removeConnectionErrorListener);
|
|
443
445
|
|
|
444
446
|
const connectionsTracker = trackServerPendingConnections(nodeServer);
|
|
445
|
-
// opened connection must be shutdown before the close event is emitted
|
|
446
|
-
stopCallbackSet.add(connectionsTracker.stop);
|
|
447
|
-
|
|
448
447
|
const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
448
|
+
// The pending requests are answered before any connection is destroyed:
|
|
449
|
+
// destroyed first, their clients would only see a socket error. The
|
|
450
|
+
// connections must still be shut down for the close event to be emitted.
|
|
451
|
+
stopCallbackSet.add(async ({ reason }) => {
|
|
452
|
+
await pendingRequestsTracker.stop({
|
|
452
453
|
status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
|
|
453
454
|
reason,
|
|
454
455
|
});
|
|
456
|
+
await connectionsTracker.stop(reason);
|
|
455
457
|
});
|
|
456
458
|
|
|
457
459
|
const applyRequestInternalRedirection = (request) => {
|