@jsenv/server 17.6.5 → 17.6.7
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 +166 -37
- package/package.json +1 -1
- package/src/interfacing_with_node/from_node_request.js +13 -5
- package/src/internal/request_target.js +55 -0
- package/src/plugins/cors/server_plugin_cors.js +20 -6
- package/src/plugins/filesystem/fetch_file.js +4 -1
- package/src/router/router.js +5 -3
- package/src/start_server.js +64 -22
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
|
};
|
|
@@ -5833,8 +5893,17 @@ const createResourcePattern = (pattern) => {
|
|
|
5833
5893
|
}
|
|
5834
5894
|
|
|
5835
5895
|
const patternEndsWithSlash = pathnamePatternString.endsWith("/");
|
|
5896
|
+
// a pattern constraining nothing: every resource matches it, so whoever uses
|
|
5897
|
+
// it cannot name the resource that matched
|
|
5898
|
+
const matchesEveryResource =
|
|
5899
|
+
!searchPattern &&
|
|
5900
|
+
!hashPattern &&
|
|
5901
|
+
(pathnamePatternString === "*" ||
|
|
5902
|
+
pathnamePatternString === "/" ||
|
|
5903
|
+
pathnamePatternString === "/*");
|
|
5836
5904
|
|
|
5837
5905
|
return {
|
|
5906
|
+
matchesEveryResource,
|
|
5838
5907
|
match: (resource) => {
|
|
5839
5908
|
const [pathname, search, hash] = resourceToParts(resource);
|
|
5840
5909
|
let decodedPathname = decodeURIComponent(pathname);
|
|
@@ -6477,9 +6546,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6477
6546
|
}
|
|
6478
6547
|
if (!route.matchMethod(request.method)) {
|
|
6479
6548
|
// a 405 asserts the resource exists with other methods: a route
|
|
6480
|
-
// matching
|
|
6481
|
-
//
|
|
6482
|
-
if (!route.isFallback && route.
|
|
6549
|
+
// matching every resource ("GET *", "GET /") names no resource, so
|
|
6550
|
+
// it cannot make that assertion and an unknown address stays a 404
|
|
6551
|
+
if (!route.isFallback && !route.matchesEveryResource) {
|
|
6483
6552
|
wouldHaveMatched.methodSet.add(route.method);
|
|
6484
6553
|
}
|
|
6485
6554
|
continue;
|
|
@@ -6827,6 +6896,8 @@ const createRoute = ({
|
|
|
6827
6896
|
: (requestResource) => {
|
|
6828
6897
|
return resourcePattern.match(requestResource);
|
|
6829
6898
|
},
|
|
6899
|
+
matchesEveryResource:
|
|
6900
|
+
resource === "*" || resourcePattern.matchesEveryResource,
|
|
6830
6901
|
matchHeaders:
|
|
6831
6902
|
headers === undefined
|
|
6832
6903
|
? () => true
|
|
@@ -7309,11 +7380,14 @@ const TIMING_NOOP = () => {
|
|
|
7309
7380
|
* - `GET /@jsenv/server/*`, serving this package's own client files;
|
|
7310
7381
|
* - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
|
|
7311
7382
|
* subscribes to in order to reload when the server restarts.
|
|
7312
|
-
* @param {boolean|Object} [params.serverTiming=false] - `true
|
|
7313
|
-
* `server-timing` response headers: the
|
|
7314
|
-
*
|
|
7315
|
-
*
|
|
7316
|
-
*
|
|
7383
|
+
* @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
|
|
7384
|
+
* boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
|
|
7385
|
+
* time to start responding, the routing of each plugin, what routes measure with
|
|
7386
|
+
* `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
|
|
7387
|
+
* too: it is asked once per request, so a server can measure for a client it recognizes
|
|
7388
|
+
* (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
|
|
7389
|
+
* drops the entries that took less: 0 keeps everything (what a test wants), a human
|
|
7390
|
+
* reading devtools usually wants the sub-millisecond noise gone.
|
|
7317
7391
|
* @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
|
|
7318
7392
|
* still has no response after that many ms (0 disables).
|
|
7319
7393
|
* @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
|
|
@@ -7670,13 +7744,16 @@ const startServer = async ({
|
|
|
7670
7744
|
sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
|
|
7671
7745
|
return [receiveRequestOperation, sendResponseOperation];
|
|
7672
7746
|
};
|
|
7673
|
-
const serverTimingMinDuration =
|
|
7674
|
-
serverTiming
|
|
7675
|
-
? serverTiming.minDuration || 0
|
|
7676
|
-
: 0;
|
|
7747
|
+
const { serverTimingEnabled, serverTimingMinDuration } =
|
|
7748
|
+
resolveServerTiming(serverTiming);
|
|
7677
7749
|
const getResponseProperties = async (request) => {
|
|
7750
|
+
// Asked once per request, on what the client sent: an api can then measure
|
|
7751
|
+
// for everyone and disclose to a token, a cookie, an origin — instead of
|
|
7752
|
+
// choosing between measuring nothing and telling every client what it does
|
|
7753
|
+
// internally.
|
|
7754
|
+
const timingEnabled = serverTimingEnabled(request);
|
|
7678
7755
|
const timings = {};
|
|
7679
|
-
const timing =
|
|
7756
|
+
const timing = timingEnabled
|
|
7680
7757
|
? (name) => {
|
|
7681
7758
|
const start = performance.now();
|
|
7682
7759
|
timings[name] = null;
|
|
@@ -7711,7 +7788,7 @@ const startServer = async ({
|
|
|
7711
7788
|
|
|
7712
7789
|
let headersToInject;
|
|
7713
7790
|
const finalizeResponseProperties = (responseProperties) => {
|
|
7714
|
-
if (
|
|
7791
|
+
if (timingEnabled) {
|
|
7715
7792
|
startRespondingTiming.end();
|
|
7716
7793
|
// A response can hand back measures of its own (a `timing` property —
|
|
7717
7794
|
// durations keyed by description, null for a plain marker): they join
|
|
@@ -7966,7 +8043,14 @@ const startServer = async ({
|
|
|
7966
8043
|
|
|
7967
8044
|
{
|
|
7968
8045
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
7969
|
-
const
|
|
8046
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
8047
|
+
if (!requestTarget) {
|
|
8048
|
+
nodeResponse.writeHead(400, "Request url is not supported");
|
|
8049
|
+
nodeResponse.end();
|
|
8050
|
+
return;
|
|
8051
|
+
}
|
|
8052
|
+
const requestHost =
|
|
8053
|
+
requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
|
|
7970
8054
|
if (!isHostAllowed(requestHost)) {
|
|
7971
8055
|
logger.warn(
|
|
7972
8056
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -7977,19 +8061,11 @@ const startServer = async ({
|
|
|
7977
8061
|
}
|
|
7978
8062
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
7979
8063
|
nodeResponse.writeHead(301, {
|
|
7980
|
-
location: `${serverOrigin}${
|
|
8064
|
+
location: `${serverOrigin}${requestTarget.resource}`,
|
|
7981
8065
|
});
|
|
7982
8066
|
nodeResponse.end();
|
|
7983
8067
|
return;
|
|
7984
8068
|
}
|
|
7985
|
-
try {
|
|
7986
|
-
// eslint-disable-next-line no-new
|
|
7987
|
-
new URL(nodeRequest.url, "http://example.com");
|
|
7988
|
-
} catch {
|
|
7989
|
-
nodeResponse.writeHead(400, "Request url is not supported");
|
|
7990
|
-
nodeResponse.end();
|
|
7991
|
-
return;
|
|
7992
|
-
}
|
|
7993
8069
|
|
|
7994
8070
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
7995
8071
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
@@ -8066,7 +8142,13 @@ const startServer = async ({
|
|
|
8066
8142
|
};
|
|
8067
8143
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
8068
8144
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
8069
|
-
const
|
|
8145
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
8146
|
+
if (!requestTarget) {
|
|
8147
|
+
socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
|
|
8148
|
+
socket.destroy();
|
|
8149
|
+
return;
|
|
8150
|
+
}
|
|
8151
|
+
const requestHost = requestTarget.host || nodeRequest.headers.host;
|
|
8070
8152
|
if (!isHostAllowed(requestHost)) {
|
|
8071
8153
|
logger.warn(
|
|
8072
8154
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -8233,6 +8315,36 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
|
8233
8315
|
exit: STOP_REASON_PROCESS_EXIT,
|
|
8234
8316
|
};
|
|
8235
8317
|
|
|
8318
|
+
const SERVER_TIMING_DISABLED = () => false;
|
|
8319
|
+
const SERVER_TIMING_ENABLED = () => true;
|
|
8320
|
+
|
|
8321
|
+
const resolveServerTiming = (serverTiming) => {
|
|
8322
|
+
if (typeof serverTiming === "function") {
|
|
8323
|
+
return {
|
|
8324
|
+
serverTimingEnabled: serverTiming,
|
|
8325
|
+
serverTimingMinDuration: 0,
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
if (serverTiming && typeof serverTiming === "object") {
|
|
8329
|
+
const { enabled = true, minDuration = 0 } = serverTiming;
|
|
8330
|
+
return {
|
|
8331
|
+
serverTimingEnabled:
|
|
8332
|
+
typeof enabled === "function"
|
|
8333
|
+
? enabled
|
|
8334
|
+
: enabled
|
|
8335
|
+
? SERVER_TIMING_ENABLED
|
|
8336
|
+
: SERVER_TIMING_DISABLED,
|
|
8337
|
+
serverTimingMinDuration: minDuration,
|
|
8338
|
+
};
|
|
8339
|
+
}
|
|
8340
|
+
return {
|
|
8341
|
+
serverTimingEnabled: serverTiming
|
|
8342
|
+
? SERVER_TIMING_ENABLED
|
|
8343
|
+
: SERVER_TIMING_DISABLED,
|
|
8344
|
+
serverTimingMinDuration: 0,
|
|
8345
|
+
};
|
|
8346
|
+
};
|
|
8347
|
+
|
|
8236
8348
|
const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
|
|
8237
8349
|
let internalErrorHtmlTemplate;
|
|
8238
8350
|
const readInternalErrorHtmlTemplate = () => {
|
|
@@ -8421,6 +8533,9 @@ class ProgressiveResponse {
|
|
|
8421
8533
|
* so here we just need to add the CORS headers to the response
|
|
8422
8534
|
*/
|
|
8423
8535
|
|
|
8536
|
+
const TIMING_ALLOW_ORIGIN_DISABLED = () => false;
|
|
8537
|
+
const TIMING_ALLOW_ORIGIN_ENABLED = () => true;
|
|
8538
|
+
|
|
8424
8539
|
const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
|
|
8425
8540
|
|
|
8426
8541
|
const jsenvAccessControlAllowedMethods = [
|
|
@@ -8453,8 +8568,10 @@ const jsenvAccessControlAllowedMethods = [
|
|
|
8453
8568
|
* @param {boolean} [params.accessControlAllowCredentials=false] - Send
|
|
8454
8569
|
* `access-control-allow-credentials: true`.
|
|
8455
8570
|
* @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
|
|
8456
|
-
* @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
|
|
8457
|
-
* allowed origin can read resource timing.
|
|
8571
|
+
* @param {boolean|Function} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
|
|
8572
|
+
* so the allowed origin can read resource timing (the `server-timing` header included).
|
|
8573
|
+
* A `(request) => boolean` is asked once per request, so it can be the very test that
|
|
8574
|
+
* decides `startServer({ serverTiming })`.
|
|
8458
8575
|
* @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
|
|
8459
8576
|
*/
|
|
8460
8577
|
const serverPluginCORS = ({
|
|
@@ -8482,6 +8599,12 @@ const serverPluginCORS = ({
|
|
|
8482
8599
|
const allowedOriginChecker = createAllowedOriginChecker(
|
|
8483
8600
|
accessControlAllowedOrigins,
|
|
8484
8601
|
);
|
|
8602
|
+
const timingAllowOriginEnabled =
|
|
8603
|
+
typeof timingAllowOrigin === "function"
|
|
8604
|
+
? timingAllowOrigin
|
|
8605
|
+
: timingAllowOrigin
|
|
8606
|
+
? TIMING_ALLOW_ORIGIN_ENABLED
|
|
8607
|
+
: TIMING_ALLOW_ORIGIN_DISABLED;
|
|
8485
8608
|
|
|
8486
8609
|
return {
|
|
8487
8610
|
name: "jsenv:cors",
|
|
@@ -8496,7 +8619,7 @@ const serverPluginCORS = ({
|
|
|
8496
8619
|
accessControlAllowRequestHeaders,
|
|
8497
8620
|
accessControlAllowCredentials,
|
|
8498
8621
|
accessControlMaxAge,
|
|
8499
|
-
|
|
8622
|
+
timingAllowOriginEnabled,
|
|
8500
8623
|
});
|
|
8501
8624
|
return {
|
|
8502
8625
|
headers: accessControlHeaders,
|
|
@@ -8550,7 +8673,7 @@ const originPatternToRegExp = (originPattern) => {
|
|
|
8550
8673
|
// https://www.w3.org/TR/cors/
|
|
8551
8674
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
|
8552
8675
|
const generateAccessControlHeaders = ({
|
|
8553
|
-
request
|
|
8676
|
+
request,
|
|
8554
8677
|
allowedOriginChecker,
|
|
8555
8678
|
accessControlAllowRequestOrigin,
|
|
8556
8679
|
accessControlAllowedMethods,
|
|
@@ -8561,8 +8684,9 @@ const generateAccessControlHeaders = ({
|
|
|
8561
8684
|
// by default OPTIONS request can be cache for a long time, it's not going to change soon ?
|
|
8562
8685
|
// we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
|
|
8563
8686
|
accessControlMaxAge = 600,
|
|
8564
|
-
|
|
8687
|
+
timingAllowOriginEnabled,
|
|
8565
8688
|
} = {}) => {
|
|
8689
|
+
const { headers } = request;
|
|
8566
8690
|
const vary = [];
|
|
8567
8691
|
|
|
8568
8692
|
// Access-Control-Allow-Origin must be a single value (not a list).
|
|
@@ -8626,7 +8750,9 @@ const generateAccessControlHeaders = ({
|
|
|
8626
8750
|
? { "access-control-allow-credentials": true }
|
|
8627
8751
|
: {}),
|
|
8628
8752
|
"access-control-max-age": accessControlMaxAge,
|
|
8629
|
-
...(
|
|
8753
|
+
...(timingAllowOriginEnabled(request)
|
|
8754
|
+
? { "timing-allow-origin": allowOrigin }
|
|
8755
|
+
: {}),
|
|
8630
8756
|
...(vary.length ? { vary: vary.join(", ") } : {}),
|
|
8631
8757
|
};
|
|
8632
8758
|
};
|
|
@@ -8937,8 +9063,11 @@ const fetchFileSystem = async (
|
|
|
8937
9063
|
if (request.params && "0" in request.params) {
|
|
8938
9064
|
resource = request.params["0"];
|
|
8939
9065
|
} else {
|
|
8940
|
-
resource = request.resource
|
|
9066
|
+
resource = request.resource;
|
|
8941
9067
|
}
|
|
9068
|
+
// the resource is resolved against the served directory, so it must stay a
|
|
9069
|
+
// relative reference: a leading slash would resolve to the filesystem root
|
|
9070
|
+
resource = resource.replace(/^\/+/, "");
|
|
8942
9071
|
const filesystemUrl = new URL(resource, directoryUrlString);
|
|
8943
9072
|
const urlString = asUrlString(filesystemUrl);
|
|
8944
9073
|
if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
|
package/package.json
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
} from "../internal/colorize_response_status.js";
|
|
21
21
|
import { headersFromObject } from "../internal/headers_from_object.js";
|
|
22
22
|
import { parseSingleHeaderWithAttributes } from "../internal/multiple_header.js";
|
|
23
|
+
import {
|
|
24
|
+
readRequestTarget,
|
|
25
|
+
resourceToUrlObject,
|
|
26
|
+
} from "../internal/request_target.js";
|
|
23
27
|
import { observableFromNodeStream } from "./observable_from_node_stream.js";
|
|
24
28
|
|
|
25
29
|
export const fromNodeRequest = (
|
|
@@ -66,8 +70,13 @@ export const fromNodeRequest = (
|
|
|
66
70
|
nodeRequest.pause();
|
|
67
71
|
const body = observableFromNodeStream(nodeRequest);
|
|
68
72
|
|
|
73
|
+
// an absolute-form target names the origin the request is for; the host
|
|
74
|
+
// header is ignored in that case (RFC 9112 section 3.3)
|
|
75
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
69
76
|
let requestOrigin;
|
|
70
|
-
if (
|
|
77
|
+
if (requestTarget.origin) {
|
|
78
|
+
requestOrigin = requestTarget.origin;
|
|
79
|
+
} else if (nodeRequest.upgrade) {
|
|
71
80
|
requestOrigin = serverOrigin;
|
|
72
81
|
} else if (nodeRequest.authority) {
|
|
73
82
|
requestOrigin = nodeRequest.connection.encrypted
|
|
@@ -200,7 +209,7 @@ export const fromNodeRequest = (
|
|
|
200
209
|
http2: Boolean(nodeRequest.stream),
|
|
201
210
|
origin: requestOrigin,
|
|
202
211
|
...getPropertiesFromResource({
|
|
203
|
-
resource:
|
|
212
|
+
resource: requestTarget.resource,
|
|
204
213
|
baseUrl: requestOrigin,
|
|
205
214
|
}),
|
|
206
215
|
method: nodeRequest.method,
|
|
@@ -440,13 +449,12 @@ export const applyRedirectionToRequest = (
|
|
|
440
449
|
};
|
|
441
450
|
};
|
|
442
451
|
const getPropertiesFromResource = ({ resource, baseUrl }) => {
|
|
443
|
-
const urlObject =
|
|
444
|
-
let pathname = urlObject.pathname;
|
|
452
|
+
const urlObject = resourceToUrlObject(resource, baseUrl);
|
|
445
453
|
|
|
446
454
|
return {
|
|
447
455
|
url: String(urlObject),
|
|
448
456
|
searchParams: urlObject.searchParams,
|
|
449
|
-
pathname,
|
|
457
|
+
pathname: urlObject.pathname,
|
|
450
458
|
resource,
|
|
451
459
|
};
|
|
452
460
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A request target is the text between the method and the http version on the
|
|
3
|
+
* request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
|
|
4
|
+
* It is a path, not a url reference, and the two must never be confused:
|
|
5
|
+
* "//wp-includes/x" is an origin-form target whose first path segment is
|
|
6
|
+
* empty, but `new URL("//wp-includes/x", origin)` reads it as a
|
|
7
|
+
* protocol-relative reference and turns "wp-includes" into the host. So the
|
|
8
|
+
* path is assigned onto the origin instead, and what a client sends can never
|
|
9
|
+
* replace that origin.
|
|
10
|
+
*
|
|
11
|
+
* The one form that legitimately names an origin is absolute-form
|
|
12
|
+
* ("GET http://host/path", sent by proxies and open-proxy scanners); it is
|
|
13
|
+
* read as such so the origin it names goes through the allowedHosts check
|
|
14
|
+
* like any other host, instead of reaching the routes unchecked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} target the raw request target
|
|
21
|
+
* @returns {{ origin: string|null, host: string|null, resource: string }|null}
|
|
22
|
+
* null when the target cannot be read; the server answers 400
|
|
23
|
+
*/
|
|
24
|
+
export const readRequestTarget = (target) => {
|
|
25
|
+
if (!ABSOLUTE_FORM_REGEX.test(target)) {
|
|
26
|
+
// origin-form ("/path?query"), asterisk-form ("*") or authority-form
|
|
27
|
+
return { origin: null, host: null, resource: target };
|
|
28
|
+
}
|
|
29
|
+
if (!URL.canParse(target)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const urlObject = new URL(target);
|
|
33
|
+
if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
origin: urlObject.origin,
|
|
38
|
+
host: urlObject.host,
|
|
39
|
+
resource: `${urlObject.pathname}${urlObject.search}`,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// the search of baseUrl is dropped: the resource carries its own
|
|
44
|
+
export const resourceToUrlObject = (resource, baseUrl) => {
|
|
45
|
+
const urlObject = new URL(baseUrl);
|
|
46
|
+
const searchSeparatorIndex = resource.indexOf("?");
|
|
47
|
+
if (searchSeparatorIndex === -1) {
|
|
48
|
+
urlObject.pathname = resource;
|
|
49
|
+
urlObject.search = "";
|
|
50
|
+
} else {
|
|
51
|
+
urlObject.pathname = resource.slice(0, searchSeparatorIndex);
|
|
52
|
+
urlObject.search = resource.slice(searchSeparatorIndex);
|
|
53
|
+
}
|
|
54
|
+
return urlObject;
|
|
55
|
+
};
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* so here we just need to add the CORS headers to the response
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const TIMING_ALLOW_ORIGIN_DISABLED = () => false;
|
|
7
|
+
const TIMING_ALLOW_ORIGIN_ENABLED = () => true;
|
|
8
|
+
|
|
6
9
|
export const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
|
|
7
10
|
|
|
8
11
|
export const jsenvAccessControlAllowedMethods = [
|
|
@@ -35,8 +38,10 @@ export const jsenvAccessControlAllowedMethods = [
|
|
|
35
38
|
* @param {boolean} [params.accessControlAllowCredentials=false] - Send
|
|
36
39
|
* `access-control-allow-credentials: true`.
|
|
37
40
|
* @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
|
|
38
|
-
* @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
|
|
39
|
-
* allowed origin can read resource timing.
|
|
41
|
+
* @param {boolean|Function} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
|
|
42
|
+
* so the allowed origin can read resource timing (the `server-timing` header included).
|
|
43
|
+
* A `(request) => boolean` is asked once per request, so it can be the very test that
|
|
44
|
+
* decides `startServer({ serverTiming })`.
|
|
40
45
|
* @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
|
|
41
46
|
*/
|
|
42
47
|
export const serverPluginCORS = ({
|
|
@@ -64,6 +69,12 @@ export const serverPluginCORS = ({
|
|
|
64
69
|
const allowedOriginChecker = createAllowedOriginChecker(
|
|
65
70
|
accessControlAllowedOrigins,
|
|
66
71
|
);
|
|
72
|
+
const timingAllowOriginEnabled =
|
|
73
|
+
typeof timingAllowOrigin === "function"
|
|
74
|
+
? timingAllowOrigin
|
|
75
|
+
: timingAllowOrigin
|
|
76
|
+
? TIMING_ALLOW_ORIGIN_ENABLED
|
|
77
|
+
: TIMING_ALLOW_ORIGIN_DISABLED;
|
|
67
78
|
|
|
68
79
|
return {
|
|
69
80
|
name: "jsenv:cors",
|
|
@@ -78,7 +89,7 @@ export const serverPluginCORS = ({
|
|
|
78
89
|
accessControlAllowRequestHeaders,
|
|
79
90
|
accessControlAllowCredentials,
|
|
80
91
|
accessControlMaxAge,
|
|
81
|
-
|
|
92
|
+
timingAllowOriginEnabled,
|
|
82
93
|
});
|
|
83
94
|
return {
|
|
84
95
|
headers: accessControlHeaders,
|
|
@@ -132,7 +143,7 @@ const originPatternToRegExp = (originPattern) => {
|
|
|
132
143
|
// https://www.w3.org/TR/cors/
|
|
133
144
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
|
134
145
|
const generateAccessControlHeaders = ({
|
|
135
|
-
request
|
|
146
|
+
request,
|
|
136
147
|
allowedOriginChecker,
|
|
137
148
|
accessControlAllowRequestOrigin,
|
|
138
149
|
accessControlAllowedMethods,
|
|
@@ -143,8 +154,9 @@ const generateAccessControlHeaders = ({
|
|
|
143
154
|
// by default OPTIONS request can be cache for a long time, it's not going to change soon ?
|
|
144
155
|
// we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
|
|
145
156
|
accessControlMaxAge = 600,
|
|
146
|
-
|
|
157
|
+
timingAllowOriginEnabled,
|
|
147
158
|
} = {}) => {
|
|
159
|
+
const { headers } = request;
|
|
148
160
|
const vary = [];
|
|
149
161
|
|
|
150
162
|
// Access-Control-Allow-Origin must be a single value (not a list).
|
|
@@ -208,7 +220,9 @@ const generateAccessControlHeaders = ({
|
|
|
208
220
|
? { "access-control-allow-credentials": true }
|
|
209
221
|
: {}),
|
|
210
222
|
"access-control-max-age": accessControlMaxAge,
|
|
211
|
-
...(
|
|
223
|
+
...(timingAllowOriginEnabled(request)
|
|
224
|
+
? { "timing-allow-origin": allowOrigin }
|
|
225
|
+
: {}),
|
|
212
226
|
...(vary.length ? { vary: vary.join(", ") } : {}),
|
|
213
227
|
};
|
|
214
228
|
};
|
|
@@ -112,8 +112,11 @@ export const fetchFileSystem = async (
|
|
|
112
112
|
if (request.params && "0" in request.params) {
|
|
113
113
|
resource = request.params["0"];
|
|
114
114
|
} else {
|
|
115
|
-
resource = request.resource
|
|
115
|
+
resource = request.resource;
|
|
116
116
|
}
|
|
117
|
+
// the resource is resolved against the served directory, so it must stay a
|
|
118
|
+
// relative reference: a leading slash would resolve to the filesystem root
|
|
119
|
+
resource = resource.replace(/^\/+/, "");
|
|
117
120
|
const filesystemUrl = new URL(resource, directoryUrlString);
|
|
118
121
|
const urlString = asUrlString(filesystemUrl);
|
|
119
122
|
if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
|
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;
|
|
@@ -709,6 +709,8 @@ const createRoute = ({
|
|
|
709
709
|
: (requestResource) => {
|
|
710
710
|
return resourcePattern.match(requestResource);
|
|
711
711
|
},
|
|
712
|
+
matchesEveryResource:
|
|
713
|
+
resource === "*" || resourcePattern.matchesEveryResource,
|
|
712
714
|
matchHeaders:
|
|
713
715
|
headers === undefined
|
|
714
716
|
? () => true
|
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";
|
|
@@ -129,11 +130,14 @@ const TIMING_NOOP = () => {
|
|
|
129
130
|
* - `GET /@jsenv/server/*`, serving this package's own client files;
|
|
130
131
|
* - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
|
|
131
132
|
* subscribes to in order to reload when the server restarts.
|
|
132
|
-
* @param {boolean|Object} [params.serverTiming=false] - `true
|
|
133
|
-
* `server-timing` response headers: the
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
133
|
+
* @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
|
|
134
|
+
* boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
|
|
135
|
+
* time to start responding, the routing of each plugin, what routes measure with
|
|
136
|
+
* `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
|
|
137
|
+
* too: it is asked once per request, so a server can measure for a client it recognizes
|
|
138
|
+
* (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
|
|
139
|
+
* drops the entries that took less: 0 keeps everything (what a test wants), a human
|
|
140
|
+
* reading devtools usually wants the sub-millisecond noise gone.
|
|
137
141
|
* @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
|
|
138
142
|
* still has no response after that many ms (0 disables).
|
|
139
143
|
* @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
|
|
@@ -490,13 +494,16 @@ export const startServer = async ({
|
|
|
490
494
|
sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
|
|
491
495
|
return [receiveRequestOperation, sendResponseOperation];
|
|
492
496
|
};
|
|
493
|
-
const serverTimingMinDuration =
|
|
494
|
-
serverTiming
|
|
495
|
-
? serverTiming.minDuration || 0
|
|
496
|
-
: 0;
|
|
497
|
+
const { serverTimingEnabled, serverTimingMinDuration } =
|
|
498
|
+
resolveServerTiming(serverTiming);
|
|
497
499
|
const getResponseProperties = async (request) => {
|
|
500
|
+
// Asked once per request, on what the client sent: an api can then measure
|
|
501
|
+
// for everyone and disclose to a token, a cookie, an origin — instead of
|
|
502
|
+
// choosing between measuring nothing and telling every client what it does
|
|
503
|
+
// internally.
|
|
504
|
+
const timingEnabled = serverTimingEnabled(request);
|
|
498
505
|
const timings = {};
|
|
499
|
-
const timing =
|
|
506
|
+
const timing = timingEnabled
|
|
500
507
|
? (name) => {
|
|
501
508
|
const start = performance.now();
|
|
502
509
|
timings[name] = null;
|
|
@@ -531,7 +538,7 @@ export const startServer = async ({
|
|
|
531
538
|
|
|
532
539
|
let headersToInject;
|
|
533
540
|
const finalizeResponseProperties = (responseProperties) => {
|
|
534
|
-
if (
|
|
541
|
+
if (timingEnabled) {
|
|
535
542
|
startRespondingTiming.end();
|
|
536
543
|
// A response can hand back measures of its own (a `timing` property —
|
|
537
544
|
// durations keyed by description, null for a plain marker): they join
|
|
@@ -786,7 +793,14 @@ export const startServer = async ({
|
|
|
786
793
|
|
|
787
794
|
request: {
|
|
788
795
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
789
|
-
const
|
|
796
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
797
|
+
if (!requestTarget) {
|
|
798
|
+
nodeResponse.writeHead(400, "Request url is not supported");
|
|
799
|
+
nodeResponse.end();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const requestHost =
|
|
803
|
+
requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
|
|
790
804
|
if (!isHostAllowed(requestHost)) {
|
|
791
805
|
logger.warn(
|
|
792
806
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -797,19 +811,11 @@ export const startServer = async ({
|
|
|
797
811
|
}
|
|
798
812
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
799
813
|
nodeResponse.writeHead(301, {
|
|
800
|
-
location: `${serverOrigin}${
|
|
814
|
+
location: `${serverOrigin}${requestTarget.resource}`,
|
|
801
815
|
});
|
|
802
816
|
nodeResponse.end();
|
|
803
817
|
return;
|
|
804
818
|
}
|
|
805
|
-
try {
|
|
806
|
-
// eslint-disable-next-line no-new
|
|
807
|
-
new URL(nodeRequest.url, "http://example.com");
|
|
808
|
-
} catch {
|
|
809
|
-
nodeResponse.writeHead(400, "Request url is not supported");
|
|
810
|
-
nodeResponse.end();
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
819
|
|
|
814
820
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
815
821
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
@@ -891,7 +897,13 @@ export const startServer = async ({
|
|
|
891
897
|
};
|
|
892
898
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
893
899
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
894
|
-
const
|
|
900
|
+
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
901
|
+
if (!requestTarget) {
|
|
902
|
+
socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
|
|
903
|
+
socket.destroy();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const requestHost = requestTarget.host || nodeRequest.headers.host;
|
|
895
907
|
if (!isHostAllowed(requestHost)) {
|
|
896
908
|
logger.warn(
|
|
897
909
|
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
@@ -1057,3 +1069,33 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
|
1057
1069
|
beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
|
|
1058
1070
|
exit: STOP_REASON_PROCESS_EXIT,
|
|
1059
1071
|
};
|
|
1072
|
+
|
|
1073
|
+
const SERVER_TIMING_DISABLED = () => false;
|
|
1074
|
+
const SERVER_TIMING_ENABLED = () => true;
|
|
1075
|
+
|
|
1076
|
+
const resolveServerTiming = (serverTiming) => {
|
|
1077
|
+
if (typeof serverTiming === "function") {
|
|
1078
|
+
return {
|
|
1079
|
+
serverTimingEnabled: serverTiming,
|
|
1080
|
+
serverTimingMinDuration: 0,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
if (serverTiming && typeof serverTiming === "object") {
|
|
1084
|
+
const { enabled = true, minDuration = 0 } = serverTiming;
|
|
1085
|
+
return {
|
|
1086
|
+
serverTimingEnabled:
|
|
1087
|
+
typeof enabled === "function"
|
|
1088
|
+
? enabled
|
|
1089
|
+
: enabled
|
|
1090
|
+
? SERVER_TIMING_ENABLED
|
|
1091
|
+
: SERVER_TIMING_DISABLED,
|
|
1092
|
+
serverTimingMinDuration: minDuration,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
serverTimingEnabled: serverTiming
|
|
1097
|
+
? SERVER_TIMING_ENABLED
|
|
1098
|
+
: SERVER_TIMING_DISABLED,
|
|
1099
|
+
serverTimingMinDuration: 0,
|
|
1100
|
+
};
|
|
1101
|
+
};
|