@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/dist/jsenv_server.js
CHANGED
|
@@ -1150,6 +1150,62 @@ const stringifyHeaderProperty = ({ name, value }) => {
|
|
|
1150
1150
|
return `${name}=${value}`;
|
|
1151
1151
|
};
|
|
1152
1152
|
|
|
1153
|
+
/*
|
|
1154
|
+
* A request target is the text between the method and the http version on the
|
|
1155
|
+
* request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
|
|
1156
|
+
* It is a path, not a url reference, and the two must never be confused:
|
|
1157
|
+
* "//wp-includes/x" is an origin-form target whose first path segment is
|
|
1158
|
+
* empty, but `new URL("//wp-includes/x", origin)` reads it as a
|
|
1159
|
+
* protocol-relative reference and turns "wp-includes" into the host. So the
|
|
1160
|
+
* path is assigned onto the origin instead, and what a client sends can never
|
|
1161
|
+
* replace that origin.
|
|
1162
|
+
*
|
|
1163
|
+
* The one form that legitimately names an origin is absolute-form
|
|
1164
|
+
* ("GET http://host/path", sent by proxies and open-proxy scanners); it is
|
|
1165
|
+
* read as such so the origin it names goes through the allowedHosts check
|
|
1166
|
+
* like any other host, instead of reaching the routes unchecked.
|
|
1167
|
+
*/
|
|
1168
|
+
|
|
1169
|
+
const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* @param {string} target the raw request target
|
|
1173
|
+
* @returns {{ origin: string|null, host: string|null, resource: string }|null}
|
|
1174
|
+
* null when the target cannot be read; the server answers 400
|
|
1175
|
+
*/
|
|
1176
|
+
const readRequestTarget = (target) => {
|
|
1177
|
+
if (!ABSOLUTE_FORM_REGEX.test(target)) {
|
|
1178
|
+
// origin-form ("/path?query"), asterisk-form ("*") or authority-form
|
|
1179
|
+
return { origin: null, host: null, resource: target };
|
|
1180
|
+
}
|
|
1181
|
+
if (!URL.canParse(target)) {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
const urlObject = new URL(target);
|
|
1185
|
+
if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
origin: urlObject.origin,
|
|
1190
|
+
host: urlObject.host,
|
|
1191
|
+
resource: `${urlObject.pathname}${urlObject.search}`,
|
|
1192
|
+
};
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// the search of baseUrl is dropped: the resource carries its own
|
|
1196
|
+
const resourceToUrlObject = (resource, baseUrl) => {
|
|
1197
|
+
const urlObject = new URL(baseUrl);
|
|
1198
|
+
const searchSeparatorIndex = resource.indexOf("?");
|
|
1199
|
+
if (searchSeparatorIndex === -1) {
|
|
1200
|
+
urlObject.pathname = resource;
|
|
1201
|
+
urlObject.search = "";
|
|
1202
|
+
} else {
|
|
1203
|
+
urlObject.pathname = resource.slice(0, searchSeparatorIndex);
|
|
1204
|
+
urlObject.search = resource.slice(searchSeparatorIndex);
|
|
1205
|
+
}
|
|
1206
|
+
return urlObject;
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1153
1209
|
// https://wicg.github.io/observable/#core-infrastructure
|
|
1154
1210
|
|
|
1155
1211
|
if ("observable" in Symbol === false) {
|
|
@@ -1329,8 +1385,13 @@ const fromNodeRequest = (
|
|
|
1329
1385
|
nodeRequest.pause();
|
|
1330
1386
|
const body = observableFromNodeStream(nodeRequest);
|
|
1331
1387
|
|
|
1388
|
+
// an absolute-form target names the origin the request is for; the host
|
|
1389
|
+
// header is ignored in that case (RFC 9112 section 3.3)
|
|
1390
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
1332
1391
|
let requestOrigin;
|
|
1333
|
-
if (
|
|
1392
|
+
if (requestTarget.origin) {
|
|
1393
|
+
requestOrigin = requestTarget.origin;
|
|
1394
|
+
} else if (nodeRequest.upgrade) {
|
|
1334
1395
|
requestOrigin = serverOrigin;
|
|
1335
1396
|
} else if (nodeRequest.authority) {
|
|
1336
1397
|
requestOrigin = nodeRequest.connection.encrypted
|
|
@@ -1463,7 +1524,7 @@ const fromNodeRequest = (
|
|
|
1463
1524
|
http2: Boolean(nodeRequest.stream),
|
|
1464
1525
|
origin: requestOrigin,
|
|
1465
1526
|
...getPropertiesFromResource({
|
|
1466
|
-
resource:
|
|
1527
|
+
resource: requestTarget.resource,
|
|
1467
1528
|
baseUrl: requestOrigin,
|
|
1468
1529
|
}),
|
|
1469
1530
|
method: nodeRequest.method,
|
|
@@ -1690,13 +1751,12 @@ const applyRedirectionToRequest = (
|
|
|
1690
1751
|
};
|
|
1691
1752
|
};
|
|
1692
1753
|
const getPropertiesFromResource = ({ resource, baseUrl }) => {
|
|
1693
|
-
const urlObject =
|
|
1694
|
-
let pathname = urlObject.pathname;
|
|
1754
|
+
const urlObject = resourceToUrlObject(resource, baseUrl);
|
|
1695
1755
|
|
|
1696
1756
|
return {
|
|
1697
1757
|
url: String(urlObject),
|
|
1698
1758
|
searchParams: urlObject.searchParams,
|
|
1699
|
-
pathname,
|
|
1759
|
+
pathname: urlObject.pathname,
|
|
1700
1760
|
resource,
|
|
1701
1761
|
};
|
|
1702
1762
|
};
|
|
@@ -2974,14 +3034,6 @@ const trackServerPendingRequests = (nodeServer) => {
|
|
|
2974
3034
|
pendingClients.clear();
|
|
2975
3035
|
await Promise.all(
|
|
2976
3036
|
pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
|
|
2977
|
-
if (nodeResponse.headersSent === false) {
|
|
2978
|
-
if (nodeRequest.stream) {
|
|
2979
|
-
// http2 has no reason phrase
|
|
2980
|
-
nodeResponse.writeHead(status);
|
|
2981
|
-
} else {
|
|
2982
|
-
nodeResponse.writeHead(status, asReasonPhrase(reason));
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
3037
|
return new Promise((resolve) => {
|
|
2986
3038
|
if (nodeResponse.destroyed) {
|
|
2987
3039
|
resolve();
|
|
@@ -2990,7 +3042,20 @@ const trackServerPendingRequests = (nodeServer) => {
|
|
|
2990
3042
|
nodeResponse.once("close", () => {
|
|
2991
3043
|
resolve();
|
|
2992
3044
|
});
|
|
2993
|
-
nodeResponse.
|
|
3045
|
+
if (nodeResponse.headersSent) {
|
|
3046
|
+
// a status already sent cannot be replaced: the response is cut
|
|
3047
|
+
nodeResponse.destroy();
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
if (nodeRequest.stream) {
|
|
3051
|
+
// http2 has no reason phrase, nor a connection header
|
|
3052
|
+
nodeResponse.writeHead(status);
|
|
3053
|
+
} else {
|
|
3054
|
+
nodeResponse.writeHead(status, asReasonPhrase(reason), {
|
|
3055
|
+
connection: "close",
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
nodeResponse.end();
|
|
2994
3059
|
});
|
|
2995
3060
|
}),
|
|
2996
3061
|
);
|
|
@@ -3083,7 +3148,7 @@ const getWebSocketHandler = (responseProperties) => {
|
|
|
3083
3148
|
* @class
|
|
3084
3149
|
* @param {Object} options - Configuration options for the SSE controller
|
|
3085
3150
|
* @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
|
|
3086
|
-
* @param {Boolean} [options.keepProcessAlive=false] - If true,
|
|
3151
|
+
* @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
|
|
3087
3152
|
* @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
|
|
3088
3153
|
* @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
|
|
3089
3154
|
* @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
|
|
@@ -3139,7 +3204,7 @@ class ServerEvents {
|
|
|
3139
3204
|
* @param {Function} producer - Function called when first client connects
|
|
3140
3205
|
* @param {Object} [options] - Configuration options for the SSE controller
|
|
3141
3206
|
* @param {String} [options.logLevel] - Controls logging verbosity ('debug', 'info', 'warn', 'error', etc.)
|
|
3142
|
-
* @param {Boolean} [options.keepProcessAlive=false] - If true,
|
|
3207
|
+
* @param {Boolean} [options.keepProcessAlive=false] - If true, the keepalive timer keeps the process alive until `close()`
|
|
3143
3208
|
* @param {Number} [options.keepaliveDuration=30000] - Milliseconds between keepalive messages to prevent connection timeout
|
|
3144
3209
|
* @param {Number} [options.retryDuration=1000] - Suggested client reconnection delay in milliseconds
|
|
3145
3210
|
* @param {Number} [options.historyLength=1000] - Maximum number of events to keep in history for reconnecting clients
|
|
@@ -3208,8 +3273,9 @@ const createServerEvents = ({
|
|
|
3208
3273
|
} = {}) => {
|
|
3209
3274
|
const logger = createLogger({ logLevel });
|
|
3210
3275
|
|
|
3276
|
+
// starts closed: open() below installs the keepalive interval
|
|
3211
3277
|
const serverEventSource = {
|
|
3212
|
-
closed:
|
|
3278
|
+
closed: true,
|
|
3213
3279
|
};
|
|
3214
3280
|
const clientArray = [];
|
|
3215
3281
|
const eventHistory = createEventHistory(historyLength);
|
|
@@ -3451,6 +3517,7 @@ const createServerEvents = ({
|
|
|
3451
3517
|
close,
|
|
3452
3518
|
open,
|
|
3453
3519
|
});
|
|
3520
|
+
open();
|
|
3454
3521
|
return serverEventSource;
|
|
3455
3522
|
};
|
|
3456
3523
|
|
|
@@ -4621,10 +4688,10 @@ const SERVER_PLUGIN_PROPERTIES = {
|
|
|
4621
4688
|
grantPermissions: { type: "hook" },
|
|
4622
4689
|
// async (error, { request }) => response | null
|
|
4623
4690
|
handleError: { type: "hook" },
|
|
4624
|
-
// (request, { response, warn }) to look at the response before it is sent
|
|
4625
|
-
inspectResponse: { type: "hook" },
|
|
4626
4691
|
// (request, response) => responseToCompose | null
|
|
4627
4692
|
injectResponseProperties: { type: "hook" },
|
|
4693
|
+
// (request, { response, warn }) to look at the final response before it is sent
|
|
4694
|
+
inspectResponse: { type: "hook" },
|
|
4628
4695
|
// ({ reason }) once the server is stopped
|
|
4629
4696
|
serverStopped: { type: "hook" },
|
|
4630
4697
|
// route descriptors, appended after the routes given to startServer
|
|
@@ -5833,8 +5900,17 @@ const createResourcePattern = (pattern) => {
|
|
|
5833
5900
|
}
|
|
5834
5901
|
|
|
5835
5902
|
const patternEndsWithSlash = pathnamePatternString.endsWith("/");
|
|
5903
|
+
// a pattern constraining nothing: every resource matches it, so whoever uses
|
|
5904
|
+
// it cannot name the resource that matched
|
|
5905
|
+
const matchesEveryResource =
|
|
5906
|
+
!searchPattern &&
|
|
5907
|
+
!hashPattern &&
|
|
5908
|
+
(pathnamePatternString === "*" ||
|
|
5909
|
+
pathnamePatternString === "/" ||
|
|
5910
|
+
pathnamePatternString === "/*");
|
|
5836
5911
|
|
|
5837
5912
|
return {
|
|
5913
|
+
matchesEveryResource,
|
|
5838
5914
|
match: (resource) => {
|
|
5839
5915
|
const [pathname, search, hash] = resourceToParts(resource);
|
|
5840
5916
|
let decodedPathname = decodeURIComponent(pathname);
|
|
@@ -6477,9 +6553,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6477
6553
|
}
|
|
6478
6554
|
if (!route.matchMethod(request.method)) {
|
|
6479
6555
|
// a 405 asserts the resource exists with other methods: a route
|
|
6480
|
-
// matching
|
|
6481
|
-
//
|
|
6482
|
-
if (!route.isFallback && route.
|
|
6556
|
+
// matching every resource ("GET *", "GET /") names no resource, so
|
|
6557
|
+
// it cannot make that assertion and an unknown address stays a 404
|
|
6558
|
+
if (!route.isFallback && !route.matchesEveryResource) {
|
|
6483
6559
|
wouldHaveMatched.methodSet.add(route.method);
|
|
6484
6560
|
}
|
|
6485
6561
|
continue;
|
|
@@ -6777,7 +6853,8 @@ const createRoute = ({
|
|
|
6777
6853
|
if (!endpoint || typeof endpoint !== "string") {
|
|
6778
6854
|
throw new TypeError(`endpoint must be a string, received ${endpoint}`);
|
|
6779
6855
|
}
|
|
6780
|
-
const [method, resource] =
|
|
6856
|
+
const [method, resource] =
|
|
6857
|
+
endpoint === "*" ? ["*", "*"] : endpoint.split(" ");
|
|
6781
6858
|
if (method !== "*" && !HTTP_METHODS.includes(method)) {
|
|
6782
6859
|
throw new TypeError(`"${method}" is not an HTTP method`);
|
|
6783
6860
|
}
|
|
@@ -6827,6 +6904,8 @@ const createRoute = ({
|
|
|
6827
6904
|
: (requestResource) => {
|
|
6828
6905
|
return resourcePattern.match(requestResource);
|
|
6829
6906
|
},
|
|
6907
|
+
matchesEveryResource:
|
|
6908
|
+
resource === "*" || resourcePattern.matchesEveryResource,
|
|
6830
6909
|
matchHeaders:
|
|
6831
6910
|
headers === undefined
|
|
6832
6911
|
? () => true
|
|
@@ -6994,8 +7073,9 @@ const createResourceOptionsResponse = (request, resourceOptions) => {
|
|
|
6994
7073
|
* @param {Object} params - Content negotiation parameters
|
|
6995
7074
|
* @param {Array<string>} params.availableMediaTypes - Content types the server can produce
|
|
6996
7075
|
* @param {Array<string>} params.availableLanguages - Languages the server can respond with
|
|
7076
|
+
* @param {Array<string|number|Function>} params.availableVersions - Versions the server can respond with
|
|
6997
7077
|
* @param {Array<string>} params.availableEncodings - Encodings the server supports
|
|
6998
|
-
* @returns {
|
|
7078
|
+
* @returns {Object} 406 response properties
|
|
6999
7079
|
*/
|
|
7000
7080
|
const createNotAcceptableResponse = (
|
|
7001
7081
|
request,
|
|
@@ -7296,8 +7376,9 @@ const TIMING_NOOP = () => {
|
|
|
7296
7376
|
* @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
|
|
7297
7377
|
* inside a cluster worker where the primary process is in charge.
|
|
7298
7378
|
* @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
|
|
7299
|
-
* @param {boolean} [params.stopOnInternalError=false] - Stop
|
|
7300
|
-
*
|
|
7379
|
+
* @param {boolean} [params.stopOnInternalError=false] - Stop as soon as a route throws. The
|
|
7380
|
+
* request that threw and every other pending request are answered 500 with no body, the
|
|
7381
|
+
* "handleError" plugins' response is not sent.
|
|
7301
7382
|
* @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
|
|
7302
7383
|
* the process alive.
|
|
7303
7384
|
* @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
|
|
@@ -7329,8 +7410,9 @@ const TIMING_NOOP = () => {
|
|
|
7329
7410
|
* @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
|
|
7330
7411
|
* webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
|
|
7331
7412
|
* - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
|
|
7332
|
-
* - `stop(reason)`:
|
|
7333
|
-
*
|
|
7413
|
+
* - `stop(reason)`: answers the pending requests with 503, closes every connection and
|
|
7414
|
+
* resolves once done; `reason` can be anything, becomes the 503 status text and
|
|
7415
|
+
* defaults to `STOP_REASON_NOT_SPECIFIED`.
|
|
7334
7416
|
* - `stoppedPromise`: resolves with the reason the server stopped for (one of the
|
|
7335
7417
|
* `STOP_REASON_*` exports or what was given to `stop`).
|
|
7336
7418
|
* - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
|
|
@@ -7621,16 +7703,16 @@ const startServer = async ({
|
|
|
7621
7703
|
stopCallbackSet.add(removeConnectionErrorListener);
|
|
7622
7704
|
|
|
7623
7705
|
const connectionsTracker = trackServerPendingConnections(nodeServer);
|
|
7624
|
-
// opened connection must be shutdown before the close event is emitted
|
|
7625
|
-
stopCallbackSet.add(connectionsTracker.stop);
|
|
7626
|
-
|
|
7627
7706
|
const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
|
|
7628
|
-
//
|
|
7629
|
-
|
|
7630
|
-
|
|
7707
|
+
// The pending requests are answered before any connection is destroyed:
|
|
7708
|
+
// destroyed first, their clients would only see a socket error. The
|
|
7709
|
+
// connections must still be shut down for the close event to be emitted.
|
|
7710
|
+
stopCallbackSet.add(async ({ reason }) => {
|
|
7711
|
+
await pendingRequestsTracker.stop({
|
|
7631
7712
|
status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
|
|
7632
7713
|
reason,
|
|
7633
7714
|
});
|
|
7715
|
+
await connectionsTracker.stop(reason);
|
|
7634
7716
|
});
|
|
7635
7717
|
|
|
7636
7718
|
const applyRequestInternalRedirection = (request) => {
|
|
@@ -7972,7 +8054,14 @@ const startServer = async ({
|
|
|
7972
8054
|
|
|
7973
8055
|
{
|
|
7974
8056
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
7975
|
-
const
|
|
8057
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
8058
|
+
if (!requestTarget) {
|
|
8059
|
+
nodeResponse.writeHead(400, "Request url is not supported");
|
|
8060
|
+
nodeResponse.end();
|
|
8061
|
+
return;
|
|
8062
|
+
}
|
|
8063
|
+
const requestHost =
|
|
8064
|
+
requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
|
|
7976
8065
|
if (!isHostAllowed(requestHost)) {
|
|
7977
8066
|
logger.warn(
|
|
7978
8067
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -7983,19 +8072,11 @@ const startServer = async ({
|
|
|
7983
8072
|
}
|
|
7984
8073
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
7985
8074
|
nodeResponse.writeHead(301, {
|
|
7986
|
-
location: `${serverOrigin}${
|
|
8075
|
+
location: `${serverOrigin}${requestTarget.resource}`,
|
|
7987
8076
|
});
|
|
7988
8077
|
nodeResponse.end();
|
|
7989
8078
|
return;
|
|
7990
8079
|
}
|
|
7991
|
-
try {
|
|
7992
|
-
// eslint-disable-next-line no-new
|
|
7993
|
-
new URL(nodeRequest.url, "http://example.com");
|
|
7994
|
-
} catch {
|
|
7995
|
-
nodeResponse.writeHead(400, "Request url is not supported");
|
|
7996
|
-
nodeResponse.end();
|
|
7997
|
-
return;
|
|
7998
|
-
}
|
|
7999
8080
|
|
|
8000
8081
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
8001
8082
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
@@ -8072,7 +8153,13 @@ const startServer = async ({
|
|
|
8072
8153
|
};
|
|
8073
8154
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
8074
8155
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
8075
|
-
const
|
|
8156
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
8157
|
+
if (!requestTarget) {
|
|
8158
|
+
socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
|
|
8159
|
+
socket.destroy();
|
|
8160
|
+
return;
|
|
8161
|
+
}
|
|
8162
|
+
const requestHost = requestTarget.host || nodeRequest.headers.host;
|
|
8076
8163
|
if (!isHostAllowed(requestHost)) {
|
|
8077
8164
|
logger.warn(
|
|
8078
8165
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -8399,7 +8486,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8399
8486
|
*
|
|
8400
8487
|
* @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
|
|
8401
8488
|
* Receives `write` and `end`. If it returns a function, that function runs
|
|
8402
|
-
*
|
|
8489
|
+
* once the response is over: after `end()`, or when the client disconnects.
|
|
8403
8490
|
* @param {Object} [init]
|
|
8404
8491
|
* @param {number} [init.status=200]
|
|
8405
8492
|
* @param {string} [init.statusText]
|
|
@@ -8569,9 +8656,10 @@ const createAllowedOriginChecker = (allowedOrigins) => {
|
|
|
8569
8656
|
}
|
|
8570
8657
|
|
|
8571
8658
|
return {
|
|
8572
|
-
// when the request origin cannot be reflected back
|
|
8573
|
-
//
|
|
8574
|
-
|
|
8659
|
+
// sent when the request origin cannot be reflected back: a single valid
|
|
8660
|
+
// origin, never a pattern, and never "*" which would allow every origin.
|
|
8661
|
+
// With only patterns there is nothing to send.
|
|
8662
|
+
defaultOrigin: literalOrigins[0] ?? null,
|
|
8575
8663
|
isAllowed: (origin) => {
|
|
8576
8664
|
if (literalOrigins.includes(origin)) {
|
|
8577
8665
|
return true;
|
|
@@ -8615,7 +8703,6 @@ const generateAccessControlHeaders = ({
|
|
|
8615
8703
|
|
|
8616
8704
|
// Access-Control-Allow-Origin must be a single value (not a list).
|
|
8617
8705
|
// We reflect back the request's origin if it is in the allowed list.
|
|
8618
|
-
// If no origin matches we fall back to "*" (only when not using credentials).
|
|
8619
8706
|
let allowOrigin = null;
|
|
8620
8707
|
|
|
8621
8708
|
const requestOrigin = readRequestOrigin(headers);
|
|
@@ -8667,14 +8754,16 @@ const generateAccessControlHeaders = ({
|
|
|
8667
8754
|
}
|
|
8668
8755
|
|
|
8669
8756
|
return {
|
|
8670
|
-
|
|
8757
|
+
...(allowOrigin === null
|
|
8758
|
+
? {}
|
|
8759
|
+
: { "access-control-allow-origin": allowOrigin }),
|
|
8671
8760
|
"access-control-allow-methods": allowedMethodArray.join(", "),
|
|
8672
8761
|
"access-control-allow-headers": allowedHeaderArray.join(", "),
|
|
8673
8762
|
...(accessControlAllowCredentials
|
|
8674
8763
|
? { "access-control-allow-credentials": true }
|
|
8675
8764
|
: {}),
|
|
8676
8765
|
"access-control-max-age": accessControlMaxAge,
|
|
8677
|
-
...(timingAllowOriginEnabled(request)
|
|
8766
|
+
...(allowOrigin !== null && timingAllowOriginEnabled(request)
|
|
8678
8767
|
? { "timing-allow-origin": allowOrigin }
|
|
8679
8768
|
: {}),
|
|
8680
8769
|
...(vary.length ? { vary: vary.join(", ") } : {}),
|
|
@@ -8925,8 +9014,9 @@ const createFileSystemFetch = (directoryUrl, options) => {
|
|
|
8925
9014
|
* @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
|
|
8926
9015
|
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
8927
9016
|
* @param {Object} [options]
|
|
8928
|
-
* @param {string} [options.mainFileRelativeUrl] - File served for the
|
|
8929
|
-
* as a fallback, for extension-less urls that do not exist (client side routing).
|
|
9017
|
+
* @param {string} [options.mainFileRelativeUrl] - File served for the root of `directoryUrl`
|
|
9018
|
+
* and, as a fallback, for extension-less urls that do not exist (client side routing).
|
|
9019
|
+
* A subdirectory still gets 403 unless `canReadDirectory`.
|
|
8930
9020
|
* @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
|
|
8931
9021
|
* answer 304 to a matching `if-none-match`.
|
|
8932
9022
|
* @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
|
|
@@ -8987,8 +9077,11 @@ const fetchFileSystem = async (
|
|
|
8987
9077
|
if (request.params && "0" in request.params) {
|
|
8988
9078
|
resource = request.params["0"];
|
|
8989
9079
|
} else {
|
|
8990
|
-
resource = request.resource
|
|
9080
|
+
resource = request.resource;
|
|
8991
9081
|
}
|
|
9082
|
+
// the resource is resolved against the served directory, so it must stay a
|
|
9083
|
+
// relative reference: a leading slash would resolve to the filesystem root
|
|
9084
|
+
resource = resource.replace(/^\/+/, "");
|
|
8992
9085
|
const filesystemUrl = new URL(resource, directoryUrlString);
|
|
8993
9086
|
const urlString = asUrlString(filesystemUrl);
|
|
8994
9087
|
if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @jsenv/server for AI agents
|
|
2
|
+
|
|
3
|
+
An http server for Node.js: `startServer({ routes, plugins })` where a route is `{ endpoint: "GET /users/:id", fetch: (request, helpers) => response }`.
|
|
4
|
+
|
|
5
|
+
## What to read, in order
|
|
6
|
+
|
|
7
|
+
1. The JSDoc of the export you use — `startServer` documents every option and the shape of a route; `createFileSystemFetch`, `serverPluginCORS`, `serverPluginErrorHandler`, `ServerEvents`, `WebSocketResponse`, `ProgressiveResponse`, `pickContentType`… document theirs. In a project both `node_modules/@jsenv/server/src/` (one file per concern, `index.js` lists the exports) and the bundle `dist/jsenv_server.js` are published with their JSDoc.
|
|
8
|
+
2. The guideline docs of this directory, one per area:
|
|
9
|
+
- [handling_requests.md](./handling_requests.md) — routes, the `request` object, `helpers`, response formats, what the router answers by itself
|
|
10
|
+
- [handling_errors.md](./handling_errors.md) — a route that throws, `serverPluginErrorHandler`, timeouts
|
|
11
|
+
- [plugins.md](./plugins.md) — the hooks, the built-in plugins
|
|
12
|
+
- [serving_files.md](./serving_files.md) — `createFileSystemFetch`
|
|
13
|
+
- [content_negotiation.md](./content_negotiation.md) — `availableMediaTypes` & co
|
|
14
|
+
- [websocket.md](./websocket.md), [server_sent_events.md](./server_sent_events.md) — real time
|
|
15
|
+
- [cors.md](./cors.md), [https.md](./https.md), [server_timing.md](./server_timing.md), [lifecycle.md](./lifecycle.md), [security.md](./security.md), [cluster.md](./cluster.md)
|
|
16
|
+
3. The tests in `tests/` of the package (only in the jsenv/core repository): one directory per feature, each test starts a real server and requests it.
|
|
17
|
+
|
|
18
|
+
## Things that are easy to get wrong
|
|
19
|
+
|
|
20
|
+
- A route responds with its `fetch` function (not `response`, not `handleRequest`). Returning `null`/`undefined` lets the next route try.
|
|
21
|
+
- The request `headers` object has lowercased keys; `request.cookies` is a `Map`; `request.params` holds what `:name` and `*` captured.
|
|
22
|
+
- A plain response object must give `status` (it defaults to 404). `statusMessage` is not `statusText`: it feeds the body of 4xx/5xx responses.
|
|
23
|
+
- Without a plugin implementing `handleError` (`serverPluginErrorHandler`), a route that throws makes the process exit.
|
|
24
|
+
- A plugin property that is not a hook name is ignored with a console warning (`Unexpected "handleRequest" property`): when a hook seems dead, read the console.
|
|
25
|
+
- `ServerEvents` broadcasts to every connected client; there is no per-client send. A client → server message goes through a plain route (POST), or a `WebSocketResponse` when both directions are needed.
|
|
26
|
+
- `canExposeSensitiveData: true` is for development on the developer's machine only (see [security.md](./security.md)).
|
|
27
|
+
- A request whose `host` header is not a name of the server gets 403: a custom hostname goes in `hostname` or `allowedHosts`.
|
|
28
|
+
- `request.json()` and the other readers answer 413 past 1 MiB (`requestBodyMaxSize`, or `{ maxSize }` per call).
|
|
29
|
+
- Running from the jsenv/core repository: `node --conditions=dev:jsenv <file>`, otherwise the stale `dist/` bundle is used.
|
package/docs/cluster.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Cluster
|
|
2
|
+
|
|
3
|
+
One server per CPU core, with Node's cluster module: the workers share the port.
|
|
4
|
+
|
|
5
|
+
_cluster_demo_server.mjs:_
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { startServer, createFileSystemFetch } from "@jsenv/server";
|
|
9
|
+
|
|
10
|
+
await startServer({
|
|
11
|
+
routes: [
|
|
12
|
+
{
|
|
13
|
+
endpoint: "GET *",
|
|
14
|
+
fetch: createFileSystemFetch(import.meta.resolve("./")),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
_cluster_demo_primary.mjs_
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import cluster from "node:cluster";
|
|
24
|
+
import { cpus } from "node:os";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const numCPUs = cpus().length;
|
|
28
|
+
|
|
29
|
+
cluster.setupPrimary({
|
|
30
|
+
exec: fileURLToPath(new URL("./cluster_demo_server.mjs", import.meta.url)),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
console.log(`Primary ${process.pid} is running`);
|
|
34
|
+
for (let i = 0; i < numCPUs; i++) {
|
|
35
|
+
cluster.fork();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cluster.on("online", (worker) => {
|
|
39
|
+
console.log(`Worker ${worker.process.pid} is online`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
43
|
+
// https://nodejs.org/api/cluster.html#workerexitedafterdisconnect
|
|
44
|
+
if (worker.exitedAfterDisconnect) {
|
|
45
|
+
console.log(
|
|
46
|
+
`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
|
|
47
|
+
);
|
|
48
|
+
console.log(`Starting a new worker`);
|
|
49
|
+
cluster.fork();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
❯ node ./docs/demos/cluster/cluster_demo_primary.mjs
|
|
56
|
+
Primary 42398 is running
|
|
57
|
+
Worker 42399 is online
|
|
58
|
+
Worker 42400 is online
|
|
59
|
+
Worker 42401 is online
|
|
60
|
+
Worker 42402 is online
|
|
61
|
+
server started at http://localhost:60000 (http://192.168.1.15:60000)
|
|
62
|
+
server started at http://localhost:60000 (http://192.168.1.15:60000)
|
|
63
|
+
server started at http://localhost:60000 (http://192.168.1.15:60000)
|
|
64
|
+
server started at http://localhost:60000 (http://192.168.1.15:60000)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Restart on file change
|
|
68
|
+
|
|
69
|
+
```diff
|
|
70
|
+
import cluster from "node:cluster"
|
|
71
|
+
import { cpus } from "node:os"
|
|
72
|
+
import { fileURLToPath } from "node:url"
|
|
73
|
+
+ import { registerFileLifecycle } from "@jsenv/filesystem"
|
|
74
|
+
|
|
75
|
+
const numCPUs = cpus().length
|
|
76
|
+
|
|
77
|
+
const workerFileUrl = new URL("./cluster_demo_server.mjs", import.meta.url)
|
|
78
|
+
|
|
79
|
+
cluster.setupPrimary({
|
|
80
|
+
exec: fileURLToPath(workerFileUrl),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
console.log(`Primary ${process.pid} is running`)
|
|
84
|
+
for (let i = 0; i < numCPUs; i++) {
|
|
85
|
+
cluster.fork()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cluster.on("online", (worker) => {
|
|
89
|
+
console.log(`Worker ${worker.process.pid} is online`)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
93
|
+
// https://nodejs.org/dist/latest-v16.x/docs/api/cluster.html#workerexitedafterdisconnect
|
|
94
|
+
if (worker.exitedAfterDisconnect) {
|
|
95
|
+
console.log(
|
|
96
|
+
`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`,
|
|
97
|
+
)
|
|
98
|
+
console.log(`Starting a new worker`)
|
|
99
|
+
cluster.fork()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
+ registerFileLifecycle(workerFileUrl, {
|
|
104
|
+
+ updated: () => {
|
|
105
|
+
+ console.log("Server file updated, restarting servers...")
|
|
106
|
+
+
|
|
107
|
+
+ Object.keys(cluster.workers).forEach((workerId) => {
|
|
108
|
+
+ const worker = cluster.workers[workerId]
|
|
109
|
+
+
|
|
110
|
+
+ // force kill if graceful exit fails
|
|
111
|
+
+ const timeout = setTimeout(() => {
|
|
112
|
+
+ worker.kill("SIGKILL")
|
|
113
|
+
+ }, 5000)
|
|
114
|
+
+ worker.once("exit", () => {
|
|
115
|
+
+ clearTimeout(timeout)
|
|
116
|
+
+ })
|
|
117
|
+
+ // try gracefull exit
|
|
118
|
+
+ worker.kill()
|
|
119
|
+
+ })
|
|
120
|
+
+ },
|
|
121
|
+
+ })
|
|
122
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Content negotiation
|
|
2
|
+
|
|
3
|
+
A route declares what it can produce; the router picks what the request prefers, sets the `vary` header and answers 406 when nothing fits.
|
|
4
|
+
|
|
5
|
+
## Media type
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { startServer } from "@jsenv/server";
|
|
9
|
+
|
|
10
|
+
await startServer({
|
|
11
|
+
routes: [
|
|
12
|
+
{
|
|
13
|
+
endpoint: "GET /hello",
|
|
14
|
+
availableMediaTypes: ["application/json", "text/plain"],
|
|
15
|
+
fetch: (request, { contentNegotiation }) => {
|
|
16
|
+
if (contentNegotiation.mediaType === "application/json") {
|
|
17
|
+
return Response.json({ data: "Hello world" });
|
|
18
|
+
}
|
|
19
|
+
return new Response("Hello world");
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`Accept: text/*` gets text, `Accept: application/json` gets json, `Accept: image/png` gets 406. Without an `accept` header the first media type wins. The media type of a route whose endpoint has an extension (`GET /data.json`) is inferred.
|
|
27
|
+
|
|
28
|
+
The same can be done by hand with `pickContentType(request, availableMediaTypes)`, which returns the media type to use or `null` — then the 406 and the `vary` header are up to the route. Unlike the router, it also returns `null` when the request has no `accept` header.
|
|
29
|
+
|
|
30
|
+
## Language, version, encoding
|
|
31
|
+
|
|
32
|
+
`availableLanguages` (`accept-language`), `availableVersions` (`accept-version`) and `availableEncodings` (`accept-encoding`) work the same way, and combine:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
{
|
|
36
|
+
endpoint: "GET /hello",
|
|
37
|
+
availableMediaTypes: ["application/json", "text/plain"],
|
|
38
|
+
availableLanguages: ["fr", "en"],
|
|
39
|
+
fetch: (request, { contentNegotiation }) => {
|
|
40
|
+
const message =
|
|
41
|
+
contentNegotiation.language === "fr"
|
|
42
|
+
? "Bonjour tout le monde"
|
|
43
|
+
: "Hello world";
|
|
44
|
+
const headers = { "content-language": contentNegotiation.language };
|
|
45
|
+
if (contentNegotiation.mediaType === "application/json") {
|
|
46
|
+
return Response.json({ data: message }, { headers });
|
|
47
|
+
}
|
|
48
|
+
return new Response(message, { headers });
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { gzipSync } from "node:zlib";
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
endpoint: "GET /hello",
|
|
58
|
+
availableEncodings: ["gzip", "identity"],
|
|
59
|
+
fetch: (request, { contentNegotiation }) => {
|
|
60
|
+
if (contentNegotiation.encoding === "gzip") {
|
|
61
|
+
return new Response(gzipSync(Buffer.from("Hello world!")), {
|
|
62
|
+
headers: { "content-encoding": "gzip" },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return new Response("Hello world!");
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A version can also be a function, `availableVersions: [(version) => version.startsWith("1.")]`.
|
|
71
|
+
|
|
72
|
+
## When several negotiations fail
|
|
73
|
+
|
|
74
|
+
The 406 explains each failure (media types, languages, versions, encodings) and lists what is available in `available-media-types`, `available-languages`, `available-versions`, `available-encodings` response headers.
|
|
75
|
+
|
|
76
|
+
## Checking the response
|
|
77
|
+
|
|
78
|
+
The router warns (at the request log level) when the response `content-type`, `content-language`, `content-version` or `content-encoding` is not among what the route declared. `serverPluginResponseAcceptanceCheck` goes further and warns when a response does not honor what the request accepted, whatever the route declared.
|