@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
|
@@ -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
|
|
@@ -112,8 +113,11 @@ export const fetchFileSystem = async (
|
|
|
112
113
|
if (request.params && "0" in request.params) {
|
|
113
114
|
resource = request.params["0"];
|
|
114
115
|
} else {
|
|
115
|
-
resource = request.resource
|
|
116
|
+
resource = request.resource;
|
|
116
117
|
}
|
|
118
|
+
// the resource is resolved against the served directory, so it must stay a
|
|
119
|
+
// relative reference: a leading slash would resolve to the filesystem root
|
|
120
|
+
resource = resource.replace(/^\/+/, "");
|
|
117
121
|
const filesystemUrl = new URL(resource, directoryUrlString);
|
|
118
122
|
const urlString = asUrlString(filesystemUrl);
|
|
119
123
|
if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
|
|
@@ -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
|
@@ -356,9 +356,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
356
356
|
}
|
|
357
357
|
if (!route.matchMethod(request.method)) {
|
|
358
358
|
// a 405 asserts the resource exists with other methods: a route
|
|
359
|
-
// matching
|
|
360
|
-
//
|
|
361
|
-
if (!route.isFallback && route.
|
|
359
|
+
// matching every resource ("GET *", "GET /") names no resource, so
|
|
360
|
+
// it cannot make that assertion and an unknown address stays a 404
|
|
361
|
+
if (!route.isFallback && !route.matchesEveryResource) {
|
|
362
362
|
wouldHaveMatched.methodSet.add(route.method);
|
|
363
363
|
}
|
|
364
364
|
continue;
|
|
@@ -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
|
}
|
|
@@ -709,6 +710,8 @@ const createRoute = ({
|
|
|
709
710
|
: (requestResource) => {
|
|
710
711
|
return resourcePattern.match(requestResource);
|
|
711
712
|
},
|
|
713
|
+
matchesEveryResource:
|
|
714
|
+
resource === "*" || resourcePattern.matchesEveryResource,
|
|
712
715
|
matchHeaders:
|
|
713
716
|
headers === undefined
|
|
714
717
|
? () => true
|
|
@@ -876,8 +879,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
|
|
|
876
879
|
* @param {Object} params - Content negotiation parameters
|
|
877
880
|
* @param {Array<string>} params.availableMediaTypes - Content types the server can produce
|
|
878
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
|
|
879
883
|
* @param {Array<string>} params.availableEncodings - Encodings the server supports
|
|
880
|
-
* @returns {
|
|
884
|
+
* @returns {Object} 406 response properties
|
|
881
885
|
*/
|
|
882
886
|
const createNotAcceptableResponse = (
|
|
883
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
|
@@ -18,6 +18,7 @@ import { listen, stopListening } from "./internal/listen.js";
|
|
|
18
18
|
import { listenEvent } from "./internal/listen_event.js";
|
|
19
19
|
import { listenRequest } from "./internal/listen_request.js";
|
|
20
20
|
import { listenServerConnectionError } from "./internal/listen_server_connection_error.js";
|
|
21
|
+
import { readRequestTarget } from "./internal/request_target.js";
|
|
21
22
|
import { composeTwoResponses } from "./internal/response_composition.js";
|
|
22
23
|
import { createSecureServer } from "./internal/secure_server.js";
|
|
23
24
|
import { resolveServerOrigins } from "./internal/server_origins.js";
|
|
@@ -116,8 +117,9 @@ const TIMING_NOOP = () => {
|
|
|
116
117
|
* @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
|
|
117
118
|
* inside a cluster worker where the primary process is in charge.
|
|
118
119
|
* @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
|
|
119
|
-
* @param {boolean} [params.stopOnInternalError=false] - Stop
|
|
120
|
-
*
|
|
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.
|
|
121
123
|
* @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
|
|
122
124
|
* the process alive.
|
|
123
125
|
* @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
|
|
@@ -149,8 +151,9 @@ const TIMING_NOOP = () => {
|
|
|
149
151
|
* @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
|
|
150
152
|
* webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
|
|
151
153
|
* - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
|
|
152
|
-
* - `stop(reason)`:
|
|
153
|
-
*
|
|
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`.
|
|
154
157
|
* - `stoppedPromise`: resolves with the reason the server stopped for (one of the
|
|
155
158
|
* `STOP_REASON_*` exports or what was given to `stop`).
|
|
156
159
|
* - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
|
|
@@ -441,16 +444,16 @@ export const startServer = async ({
|
|
|
441
444
|
stopCallbackSet.add(removeConnectionErrorListener);
|
|
442
445
|
|
|
443
446
|
const connectionsTracker = trackServerPendingConnections(nodeServer);
|
|
444
|
-
// opened connection must be shutdown before the close event is emitted
|
|
445
|
-
stopCallbackSet.add(connectionsTracker.stop);
|
|
446
|
-
|
|
447
447
|
const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
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({
|
|
451
453
|
status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
|
|
452
454
|
reason,
|
|
453
455
|
});
|
|
456
|
+
await connectionsTracker.stop(reason);
|
|
454
457
|
});
|
|
455
458
|
|
|
456
459
|
const applyRequestInternalRedirection = (request) => {
|
|
@@ -792,7 +795,14 @@ export const startServer = async ({
|
|
|
792
795
|
|
|
793
796
|
request: {
|
|
794
797
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
795
|
-
const
|
|
798
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
799
|
+
if (!requestTarget) {
|
|
800
|
+
nodeResponse.writeHead(400, "Request url is not supported");
|
|
801
|
+
nodeResponse.end();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const requestHost =
|
|
805
|
+
requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
|
|
796
806
|
if (!isHostAllowed(requestHost)) {
|
|
797
807
|
logger.warn(
|
|
798
808
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -803,19 +813,11 @@ export const startServer = async ({
|
|
|
803
813
|
}
|
|
804
814
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
805
815
|
nodeResponse.writeHead(301, {
|
|
806
|
-
location: `${serverOrigin}${
|
|
816
|
+
location: `${serverOrigin}${requestTarget.resource}`,
|
|
807
817
|
});
|
|
808
818
|
nodeResponse.end();
|
|
809
819
|
return;
|
|
810
820
|
}
|
|
811
|
-
try {
|
|
812
|
-
// eslint-disable-next-line no-new
|
|
813
|
-
new URL(nodeRequest.url, "http://example.com");
|
|
814
|
-
} catch {
|
|
815
|
-
nodeResponse.writeHead(400, "Request url is not supported");
|
|
816
|
-
nodeResponse.end();
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
821
|
|
|
820
822
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
821
823
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
@@ -897,7 +899,13 @@ export const startServer = async ({
|
|
|
897
899
|
};
|
|
898
900
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
899
901
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
900
|
-
const
|
|
902
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
903
|
+
if (!requestTarget) {
|
|
904
|
+
socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
|
|
905
|
+
socket.destroy();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const requestHost = requestTarget.host || nodeRequest.headers.host;
|
|
901
909
|
if (!isHostAllowed(requestHost)) {
|
|
902
910
|
logger.warn(
|
|
903
911
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|