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