@jsenv/server 17.6.1 → 17.6.2
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/README.md +47 -48
- package/dist/jsenv_server.js +1332 -1269
- package/package.json +2 -2
- package/src/content_negotiation/pick_content_encoding.js +11 -1
- package/src/content_negotiation/pick_content_language.js +11 -1
- package/src/content_negotiation/pick_content_type.js +11 -1
- package/src/content_negotiation/pick_content_version.js +1 -1
- package/src/interfacing_with_node/from_node_request.js +154 -178
- package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
- package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
- package/src/interfacing_with_node/write_node_response.js +59 -103
- package/src/internal/allowed_hosts.js +85 -0
- package/src/internal/escape_html.js +11 -0
- package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
- package/src/internal/listen.js +32 -5
- package/src/internal/{listenRequest.js → listen_request.js} +1 -1
- package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
- package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
- package/src/internal/reason_phrase.js +17 -0
- package/src/internal/response_composition.js +11 -2
- package/src/internal/secure_server.js +22 -0
- package/src/internal/server_origins.js +78 -0
- package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
- package/src/internal/track_server_pending_connections.js +50 -0
- package/src/internal/track_server_pending_requests.js +47 -0
- package/src/plugins/cors/server_plugin_cors.js +39 -6
- package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
- package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
- package/src/plugins/filesystem/fetch_directory.js +16 -3
- package/src/plugins/filesystem/fetch_file.js +80 -21
- package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
- package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
- package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
- package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
- package/src/plugins/server_plugins_controller.js +27 -13
- package/src/progressive_response.js +24 -0
- package/src/router/permissions.js +52 -0
- package/src/router/router.js +14 -79
- package/src/server_events.js +3 -1
- package/src/server_timing/timing_header.js +1 -1
- package/src/start_server.js +266 -416
- package/src/internal/headersFromString.js +0 -21
- package/src/internal/headersToObject.js +0 -7
- package/src/internal/headersToString.js +0 -16
- package/src/internal/listenClientError.js +0 -27
- package/src/internal/trackServerPendingConnections.js +0 -145
- package/src/internal/trackServerPendingRequests.js +0 -68
- package/src/request_body_handling.js +0 -22
- /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
- /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
- /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
- /package/src/internal/{normalizeHeaderValue.js → normalize_header_value.js} +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Turns a request for a file into response properties: content type, client
|
|
3
|
+
* cache (etag or mtime), compression, directory listing or main file. The
|
|
4
|
+
* response is a plain object so that it composes with what the router and
|
|
5
|
+
* the plugins add.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { urlIsOrIsInsideOf, urlToExtension, urlToPathname } from "@jsenv/urls";
|
|
8
9
|
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
9
|
-
import { createReadStream
|
|
10
|
+
import { createReadStream } from "node:fs";
|
|
11
|
+
import { readFile, stat } from "node:fs/promises";
|
|
10
12
|
|
|
11
13
|
import { pickContentEncoding } from "../../content_negotiation/pick_content_encoding.js";
|
|
12
14
|
import { composeTwoResponses } from "../../internal/response_composition.js";
|
|
@@ -18,12 +20,59 @@ import {
|
|
|
18
20
|
isFileSystemPath,
|
|
19
21
|
} from "./filesystem_path_and_url.js";
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A route `fetch` serving the files of a directory. The part of the url
|
|
25
|
+
* captured by the endpoint `*` (or the whole resource when there is none) is
|
|
26
|
+
* resolved inside `directoryUrl`; a url escaping it is answered with 403.
|
|
27
|
+
*
|
|
28
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
29
|
+
* @param {Object} [options] - See {@link fetchFileSystem}.
|
|
30
|
+
* @returns {(request: Object, helpers: Object) => Promise<Object|null>}
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* {
|
|
34
|
+
* endpoint: "GET /assets/*",
|
|
35
|
+
* fetch: createFileSystemFetch(import.meta.resolve("./assets/"), { etagEnabled: true }),
|
|
36
|
+
* }
|
|
37
|
+
*/
|
|
21
38
|
export const createFileSystemFetch = (directoryUrl, options) => {
|
|
22
39
|
return (request, helpers) => {
|
|
23
40
|
return fetchFileSystem(request, helpers, directoryUrl, options);
|
|
24
41
|
};
|
|
25
42
|
};
|
|
26
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Serve a file of `directoryUrl` for `request`. Declines (`null`) any method
|
|
46
|
+
* other than GET and HEAD. File paths appear in the status text only when the
|
|
47
|
+
* server runs with `canExposeSensitiveData`.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} request - The request given to a route `fetch`.
|
|
50
|
+
* @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
|
|
51
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
|
|
54
|
+
* as a fallback, for extension-less urls that do not exist (client side routing).
|
|
55
|
+
* @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
|
|
56
|
+
* answer 304 to a matching `if-none-match`.
|
|
57
|
+
* @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
|
|
58
|
+
* file stats change) so that a file is hashed once.
|
|
59
|
+
* @param {number} [options.etagMemoryMaxSize=1000] - Files remembered at most.
|
|
60
|
+
* @param {boolean} [options.mtimeEnabled=false] - Send `last-modified` and answer 304 to
|
|
61
|
+
* `if-modified-since` (second precision). Ignored when `etagEnabled` is set.
|
|
62
|
+
* @param {boolean} [options.compressionEnabled=false] - Compress textual files with brotli,
|
|
63
|
+
* gzip or deflate according to `accept-encoding`.
|
|
64
|
+
* @param {number} [options.compressionSizeThreshold=1024] - Files smaller than that (bytes) are not compressed.
|
|
65
|
+
* @param {string|((request: Object) => string)} [options.cacheControl] - The `cache-control`
|
|
66
|
+
* response header. Defaults to 30 days immutable for versioned urls (see `isVersioned`) and
|
|
67
|
+
* `private,max-age=0,must-revalidate` otherwise. `"no-store"` disables etag and mtime.
|
|
68
|
+
* @param {(request: Object) => boolean} [options.isVersioned] - Tells if a url is versioned
|
|
69
|
+
* (its content never changes). Defaults to "has a `v` search param".
|
|
70
|
+
* @param {boolean} [options.canReadDirectory=false] - Answer a directory with its listing
|
|
71
|
+
* (json or html, see {@link fetchDirectory}) instead of 403.
|
|
72
|
+
* @param {() => string|URL|null} [options.ENOENTFallback] - Called when the file does not exist;
|
|
73
|
+
* the file url it returns is served instead.
|
|
74
|
+
* @returns {Promise<Object|null>} Response properties, `null` when the method is not GET/HEAD.
|
|
75
|
+
*/
|
|
27
76
|
export const fetchFileSystem = async (
|
|
28
77
|
request,
|
|
29
78
|
helpers,
|
|
@@ -56,6 +105,9 @@ export const fetchFileSystem = async (
|
|
|
56
105
|
if (!directoryUrlString.endsWith("/")) {
|
|
57
106
|
directoryUrlString = `${directoryUrlString}/`;
|
|
58
107
|
}
|
|
108
|
+
const canExposeSensitiveData = Boolean(
|
|
109
|
+
helpers && helpers.canExposeSensitiveData,
|
|
110
|
+
);
|
|
59
111
|
let resource;
|
|
60
112
|
if (request.params && "0" in request.params) {
|
|
61
113
|
resource = request.params["0"];
|
|
@@ -106,15 +158,14 @@ export const fetchFileSystem = async (
|
|
|
106
158
|
const serveFile = async (fileUrl) => {
|
|
107
159
|
try {
|
|
108
160
|
const readStatTiming = helpers?.timing("file service>read file stat");
|
|
109
|
-
const fileStat =
|
|
161
|
+
const fileStat = await stat(new URL(fileUrl));
|
|
110
162
|
readStatTiming?.end();
|
|
111
163
|
|
|
112
164
|
if (fileStat.isDirectory()) {
|
|
113
165
|
if (canReadDirectory) {
|
|
114
166
|
return fetchDirectory(fileUrl, {
|
|
115
167
|
headers: request.headers,
|
|
116
|
-
|
|
117
|
-
rootDirectoryUrl: directoryUrl,
|
|
168
|
+
rootDirectoryUrl: directoryUrlString,
|
|
118
169
|
});
|
|
119
170
|
}
|
|
120
171
|
if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
|
|
@@ -210,7 +261,9 @@ export const fetchFileSystem = async (
|
|
|
210
261
|
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
211
262
|
},
|
|
212
263
|
},
|
|
213
|
-
convertFileSystemErrorToResponseProperties(e
|
|
264
|
+
convertFileSystemErrorToResponseProperties(e, {
|
|
265
|
+
canExposeSensitiveData,
|
|
266
|
+
}) || {},
|
|
214
267
|
);
|
|
215
268
|
}
|
|
216
269
|
};
|
|
@@ -312,20 +365,13 @@ const computeEtag = async ({
|
|
|
312
365
|
return etagMemoryEntry.eTag;
|
|
313
366
|
}
|
|
314
367
|
}
|
|
315
|
-
const fileContentAsBuffer = await new
|
|
316
|
-
readFile(new URL(fileUrl), (error, buffer) => {
|
|
317
|
-
if (error) {
|
|
318
|
-
reject(error);
|
|
319
|
-
} else {
|
|
320
|
-
resolve(buffer);
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
});
|
|
368
|
+
const fileContentAsBuffer = await readFile(new URL(fileUrl));
|
|
324
369
|
const eTag = bufferToEtag(fileContentAsBuffer);
|
|
325
370
|
if (etagMemory) {
|
|
326
371
|
if (ETAG_MEMORY_MAP.size >= etagMemoryMaxSize) {
|
|
327
|
-
|
|
328
|
-
ETAG_MEMORY_MAP.
|
|
372
|
+
// a Map iterates in insertion order: the first key is the oldest entry
|
|
373
|
+
const oldestKey = ETAG_MEMORY_MAP.keys().next().value;
|
|
374
|
+
ETAG_MEMORY_MAP.delete(oldestKey);
|
|
329
375
|
}
|
|
330
376
|
ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
|
|
331
377
|
}
|
|
@@ -417,10 +463,23 @@ const fileUrlToReadableStream = (fileUrl) => {
|
|
|
417
463
|
});
|
|
418
464
|
};
|
|
419
465
|
|
|
466
|
+
// Brotli's default quality (11) is meant for compressing assets once, ahead
|
|
467
|
+
// of time: it is an order of magnitude slower than gzip. For compression at
|
|
468
|
+
// request time, quality 4 costs about as much cpu as gzip while still
|
|
469
|
+
// compressing better.
|
|
470
|
+
const BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION = 4;
|
|
471
|
+
|
|
420
472
|
const availableCompressionFormats = {
|
|
421
473
|
br: async (fileReadableStream) => {
|
|
422
|
-
const { createBrotliCompress } = await import("node:zlib");
|
|
423
|
-
return fileReadableStream.pipe(
|
|
474
|
+
const { constants, createBrotliCompress } = await import("node:zlib");
|
|
475
|
+
return fileReadableStream.pipe(
|
|
476
|
+
createBrotliCompress({
|
|
477
|
+
params: {
|
|
478
|
+
[constants.BROTLI_PARAM_QUALITY]:
|
|
479
|
+
BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION,
|
|
480
|
+
},
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
424
483
|
},
|
|
425
484
|
deflate: async (fileReadableStream) => {
|
|
426
485
|
const { createDeflate } = await import("node:zlib");
|
|
@@ -1,21 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
// The file path belongs to the server machine: it joins the status text only
|
|
2
|
+
// when the server was started with canExposeSensitiveData.
|
|
3
|
+
export const convertFileSystemErrorToResponseProperties = (
|
|
4
|
+
error,
|
|
5
|
+
{ canExposeSensitiveData },
|
|
6
|
+
) => {
|
|
7
|
+
const location = canExposeSensitiveData ? ` at ${error.path}` : "";
|
|
2
8
|
// https://iojs.org/api/errors.html#errors_eacces_permission_denied
|
|
3
9
|
if (isErrorWithCode(error, "EACCES")) {
|
|
4
10
|
return {
|
|
5
11
|
status: 403,
|
|
6
|
-
statusText: `EACCES: No permission to read file
|
|
12
|
+
statusText: `EACCES: No permission to read file${location}`,
|
|
7
13
|
};
|
|
8
14
|
}
|
|
9
15
|
if (isErrorWithCode(error, "EPERM")) {
|
|
10
16
|
return {
|
|
11
17
|
status: 403,
|
|
12
|
-
statusText: `EPERM: No permission to read file
|
|
18
|
+
statusText: `EPERM: No permission to read file${location}`,
|
|
13
19
|
};
|
|
14
20
|
}
|
|
15
21
|
if (isErrorWithCode(error, "ENOENT")) {
|
|
16
22
|
return {
|
|
17
23
|
status: 404,
|
|
18
|
-
statusText: `ENOENT: File not found
|
|
24
|
+
statusText: `ENOENT: File not found${location}`,
|
|
19
25
|
};
|
|
20
26
|
}
|
|
21
27
|
// file access may be temporarily blocked
|
|
@@ -23,7 +29,7 @@ export const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
23
29
|
if (isErrorWithCode(error, "EBUSY")) {
|
|
24
30
|
return {
|
|
25
31
|
status: 503,
|
|
26
|
-
statusText: `EBUSY: File is busy
|
|
32
|
+
statusText: `EBUSY: File is busy${location}`,
|
|
27
33
|
headers: {
|
|
28
34
|
"retry-after": 0.01, // retry in 10ms
|
|
29
35
|
},
|
|
@@ -42,7 +48,7 @@ export const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
42
48
|
if (isErrorWithCode(error, "EISDIR")) {
|
|
43
49
|
return {
|
|
44
50
|
status: 500,
|
|
45
|
-
statusText: `EISDIR: Unexpected directory operation
|
|
51
|
+
statusText: `EISDIR: Unexpected directory operation${location}`,
|
|
46
52
|
};
|
|
47
53
|
}
|
|
48
54
|
return null;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { URL_META } from "@jsenv/url-meta";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Server plugin rewriting request resources before routing, from url
|
|
5
|
+
* patterns to their replacement: `{ "/old/*": "/new/*", "/index": "/" }`
|
|
6
|
+
* (patterns as in @jsenv/url-meta). The search params of the request are
|
|
7
|
+
* kept unless the replacement has its own.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object<string, string>} resourceAliases
|
|
10
|
+
*/
|
|
3
11
|
export const serverPluginRequestAliases = (resourceAliases) => {
|
|
4
12
|
const aliases = {};
|
|
5
13
|
Object.keys(resourceAliases).forEach((key) => {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { pickContentEncoding } from "
|
|
2
|
-
import { pickContentLanguage } from "
|
|
3
|
-
import { pickContentType } from "
|
|
1
|
+
import { pickContentEncoding } from "../../content_negotiation/pick_content_encoding.js";
|
|
2
|
+
import { pickContentLanguage } from "../../content_negotiation/pick_content_language.js";
|
|
3
|
+
import { pickContentType } from "../../content_negotiation/pick_content_type.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Server plugin logging a warning when a response does not honor what the
|
|
7
|
+
* request accepts (`accept`, `accept-language`, `accept-encoding`). A
|
|
8
|
+
* development aid: it changes nothing in the response.
|
|
9
|
+
*/
|
|
5
10
|
export const serverPluginResponseAcceptanceCheck = () => {
|
|
6
11
|
return {
|
|
7
12
|
name: "jsenv:response_acceptance_check",
|
|
@@ -43,9 +48,9 @@ ${requestAcceptLanguageHeader}`);
|
|
|
43
48
|
const requestAcceptEncodingHeader = request.headers["accept-encoding"];
|
|
44
49
|
const responseContentEncodingHeader = response.headers["content-encoding"];
|
|
45
50
|
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
!pickContentEncoding(request, [
|
|
51
|
+
requestAcceptEncodingHeader &&
|
|
52
|
+
responseContentEncodingHeader &&
|
|
53
|
+
!pickContentEncoding(request, [responseContentEncodingHeader])
|
|
49
54
|
) {
|
|
50
55
|
warn(`response encoding is not in the request accepted encoding.
|
|
51
56
|
--- response content-encoding header ---
|
|
@@ -2,6 +2,16 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
const routeInspectorHtmlFileUrl = import.meta
|
|
4
4
|
.resolve("./client/route_inspector.html");
|
|
5
|
+
let routeInspectorHtml;
|
|
6
|
+
const readRouteInspectorHtml = () => {
|
|
7
|
+
if (routeInspectorHtml === undefined) {
|
|
8
|
+
routeInspectorHtml = readFileSync(
|
|
9
|
+
new URL(routeInspectorHtmlFileUrl),
|
|
10
|
+
"utf8",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return routeInspectorHtml;
|
|
14
|
+
};
|
|
5
15
|
|
|
6
16
|
export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
|
|
7
17
|
return {
|
|
@@ -14,11 +24,7 @@ export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
|
|
|
14
24
|
availableMediaTypes: ["text/html"],
|
|
15
25
|
declarationSource: import.meta.url,
|
|
16
26
|
fetch: () => {
|
|
17
|
-
|
|
18
|
-
new URL(routeInspectorHtmlFileUrl),
|
|
19
|
-
"utf8",
|
|
20
|
-
);
|
|
21
|
-
return new Response(inspectorHtml, {
|
|
27
|
+
return new Response(readRouteInspectorHtml(), {
|
|
22
28
|
headers: { "content-type": "text/html" },
|
|
23
29
|
});
|
|
24
30
|
},
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import { createPluginsController } from "../plugins_controller.js";
|
|
2
2
|
|
|
3
|
+
// The hooks a server plugin can implement, in the order they run for a
|
|
4
|
+
// request. See docs/plugins.md for what each one is meant for.
|
|
5
|
+
const SERVER_PLUGIN_PROPERTIES = {
|
|
6
|
+
// ({ port }) once the server listens
|
|
7
|
+
serverListening: { type: "hook" },
|
|
8
|
+
// (request) => { resource | pathname, ...requestProperties } | null
|
|
9
|
+
redirectRequest: { type: "hook" },
|
|
10
|
+
// async (request, helpers) => { ...helpersToAdd } | null
|
|
11
|
+
augmentRouteFetchSecondArg: { type: "hook" },
|
|
12
|
+
// async (request) => ["permission", ...] | null, called lazily and at most
|
|
13
|
+
// once per request when a route declares permissions
|
|
14
|
+
grantPermissions: { type: "hook" },
|
|
15
|
+
// async (error, { request }) => response | null
|
|
16
|
+
handleError: { type: "hook" },
|
|
17
|
+
// (request, { response, warn }) to look at the response before it is sent
|
|
18
|
+
inspectResponse: { type: "hook" },
|
|
19
|
+
// (request, response) => responseToCompose | null
|
|
20
|
+
injectResponseProperties: { type: "hook" },
|
|
21
|
+
// ({ reason }) once the server is stopped
|
|
22
|
+
serverStopped: { type: "hook" },
|
|
23
|
+
// route descriptors, appended after the routes given to startServer
|
|
24
|
+
routes: {},
|
|
25
|
+
};
|
|
26
|
+
|
|
3
27
|
export const createServerPluginsController = async (serverPlugins) => {
|
|
4
|
-
const
|
|
28
|
+
const serverPluginsController = await createPluginsController({
|
|
5
29
|
plugins: serverPlugins,
|
|
6
30
|
pluginDescription: {
|
|
7
31
|
name: "server plugin",
|
|
8
|
-
properties:
|
|
9
|
-
serverListening: { type: "hook" },
|
|
10
|
-
redirectRequest: { type: "hook" },
|
|
11
|
-
augmentRouteFetchSecondArg: { type: "hook" },
|
|
12
|
-
grantPermissions: { type: "hook" },
|
|
13
|
-
handleError: { type: "hook" },
|
|
14
|
-
onResponsePush: { type: "hook" },
|
|
15
|
-
injectResponseProperties: { type: "hook" },
|
|
16
|
-
serverStopped: { type: "hook" },
|
|
17
|
-
routes: {}, // routes are handled separately (used to populate the router)
|
|
18
|
-
},
|
|
32
|
+
properties: SERVER_PLUGIN_PROPERTIES,
|
|
19
33
|
},
|
|
20
34
|
});
|
|
21
|
-
return
|
|
35
|
+
return serverPluginsController;
|
|
22
36
|
};
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
2
2
|
import { createObservable } from "./interfacing_with_node/observable.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A response whose body is written over time (long polling, progress
|
|
6
|
+
* reporting). Return it from a route `fetch`: the headers are sent right away
|
|
7
|
+
* and the handler writes the body chunks whenever it wants.
|
|
8
|
+
*
|
|
9
|
+
* @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
|
|
10
|
+
* Receives `write` and `end`. If it returns a function, that function runs
|
|
11
|
+
* when the client disconnects before `end` was called (cleanup).
|
|
12
|
+
* @param {Object} [init]
|
|
13
|
+
* @param {number} [init.status=200]
|
|
14
|
+
* @param {string} [init.statusText]
|
|
15
|
+
* @param {Object} [init.headers] - `content-type` decides whether the first
|
|
16
|
+
* (empty) chunk is text or binary; defaults to text/plain.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* {
|
|
20
|
+
* endpoint: "GET /progress",
|
|
21
|
+
* fetch: () => new ProgressiveResponse(({ write, end }) => {
|
|
22
|
+
* const interval = setInterval(() => write("."), 100);
|
|
23
|
+
* setTimeout(() => { clearInterval(interval); end(); }, 1000);
|
|
24
|
+
* return () => clearInterval(interval);
|
|
25
|
+
* }),
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
4
28
|
export class ProgressiveResponse {
|
|
5
29
|
constructor(responseBodyHandler, { status = 200, statusText, headers } = {}) {
|
|
6
30
|
const contentType = headers ? headers["content-type"] : "text/plain";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
2
|
+
for (const permission of permissionsRequired) {
|
|
3
|
+
if (!permissionsSet.has(permission)) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
return true;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Both helpers share one cursor over the "grantPermissions" hooks so that each
|
|
11
|
+
// plugin runs at most once per request: hasPermissions stops as soon as the
|
|
12
|
+
// required permissions are all granted, getAllPermissions drains the plugins
|
|
13
|
+
// left.
|
|
14
|
+
export const createPermissionHelpers = (serverPluginsController, request) => {
|
|
15
|
+
const permissionsSet = new Set();
|
|
16
|
+
const nextPermissionsHook = serverPluginsController.createAsyncHookIterator(
|
|
17
|
+
"grantPermissions",
|
|
18
|
+
request,
|
|
19
|
+
);
|
|
20
|
+
const drainPermissionsUntil = async (permissionsRequired) => {
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (
|
|
23
|
+
permissionsRequired !== undefined &&
|
|
24
|
+
permissionsSatisfy(permissionsSet, permissionsRequired)
|
|
25
|
+
) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const { done, value } = await nextPermissionsHook();
|
|
29
|
+
if (done) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
for (const permission of value) {
|
|
34
|
+
permissionsSet.add(permission);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getAllPermissions = async () => {
|
|
42
|
+
await drainPermissionsUntil(undefined);
|
|
43
|
+
return permissionsSet;
|
|
44
|
+
};
|
|
45
|
+
const hasPermissions = async (permissionsRequired) => {
|
|
46
|
+
if (permissionsRequired.length === 0) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return drainPermissionsUntil(permissionsRequired);
|
|
50
|
+
};
|
|
51
|
+
return { getAllPermissions, hasPermissions };
|
|
52
|
+
};
|
package/src/router/router.js
CHANGED
|
@@ -13,6 +13,8 @@ import { pickContentEncoding } from "../content_negotiation/pick_content_encodin
|
|
|
13
13
|
import { pickContentLanguage } from "../content_negotiation/pick_content_language.js";
|
|
14
14
|
import { pickContentType } from "../content_negotiation/pick_content_type.js";
|
|
15
15
|
import { pickContentVersion } from "../content_negotiation/pick_content_version.js";
|
|
16
|
+
import { asReasonPhrase } from "../internal/reason_phrase.js";
|
|
17
|
+
import { permissionsSatisfy } from "./permissions.js";
|
|
16
18
|
|
|
17
19
|
const routeInspectorUrl = `/.internal/route_inspector`;
|
|
18
20
|
|
|
@@ -28,7 +30,7 @@ const HTTP_METHODS = [
|
|
|
28
30
|
|
|
29
31
|
export const createRouter = (
|
|
30
32
|
routeDescriptionArray,
|
|
31
|
-
{ optionsFallback, logLevel
|
|
33
|
+
{ optionsFallback, logLevel } = {},
|
|
32
34
|
) => {
|
|
33
35
|
const logger = createLogger({ logLevel });
|
|
34
36
|
const routeSet = new Set();
|
|
@@ -285,7 +287,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
285
287
|
|
|
286
288
|
if (fetchReturnValue instanceof Response) {
|
|
287
289
|
status = fetchReturnValue.status;
|
|
288
|
-
statusText
|
|
290
|
+
// a Response built without statusText has an empty one: the standard
|
|
291
|
+
// phrase for that status is used instead
|
|
292
|
+
statusText = fetchReturnValue.statusText || undefined;
|
|
289
293
|
headers = Object.fromEntries(fetchReturnValue.headers);
|
|
290
294
|
body = fetchReturnValue.body;
|
|
291
295
|
} else if (
|
|
@@ -293,7 +297,10 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
293
297
|
typeof fetchReturnValue === "object"
|
|
294
298
|
) {
|
|
295
299
|
status = fetchReturnValue.status || 404;
|
|
296
|
-
statusText =
|
|
300
|
+
statusText =
|
|
301
|
+
fetchReturnValue.statusText === undefined
|
|
302
|
+
? undefined
|
|
303
|
+
: asReasonPhrase(fetchReturnValue.statusText);
|
|
297
304
|
statusMessage = fetchReturnValue.statusMessage;
|
|
298
305
|
headers = fetchReturnValue.headers || {};
|
|
299
306
|
body = fetchReturnValue.body;
|
|
@@ -304,30 +311,6 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
304
311
|
);
|
|
305
312
|
}
|
|
306
313
|
|
|
307
|
-
if (
|
|
308
|
-
redirect === "follow" &&
|
|
309
|
-
isRedirectStatus(status) &&
|
|
310
|
-
headers["location"]
|
|
311
|
-
) {
|
|
312
|
-
const redirectUrl = new URL(headers["location"], request.url);
|
|
313
|
-
if (redirectUrl.origin === new URL(request.url).origin) {
|
|
314
|
-
const redirectRequest = {
|
|
315
|
-
...request,
|
|
316
|
-
url: redirectUrl.href,
|
|
317
|
-
resource:
|
|
318
|
-
redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
|
|
319
|
-
// GET for 301/302/303, preserve method for 307/308
|
|
320
|
-
method: status === 307 || status === 308 ? request.method : "GET",
|
|
321
|
-
params: {},
|
|
322
|
-
};
|
|
323
|
-
const redirectedResponse = await matchRoutes(redirectRequest);
|
|
324
|
-
onRouteMatch(route);
|
|
325
|
-
onResponseHeaders(request, route, redirectedResponse.headers || {});
|
|
326
|
-
return redirectedResponse;
|
|
327
|
-
}
|
|
328
|
-
// redirect to other origins are left to the client to handle
|
|
329
|
-
}
|
|
330
|
-
|
|
331
314
|
onRouteMatch(route);
|
|
332
315
|
onResponseHeaders(request, route, headers);
|
|
333
316
|
return { status, statusText, statusMessage, headers, body, timing };
|
|
@@ -650,25 +633,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
650
633
|
return router;
|
|
651
634
|
};
|
|
652
635
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
* @param {Object} params - Route configuration object
|
|
657
|
-
* @param {string} params.endpoint - String in format "METHOD /resource/path" (e.g. "GET /users/:id")
|
|
658
|
-
* @param {Object} [params.headers] - Optional headers pattern to match
|
|
659
|
-
* @param {Array<string>} [params.availableMediaTypes=[]] - Content types this route can produce
|
|
660
|
-
* @param {Array<string>} [params.availableLanguages=[]] - Languages this route can respond with
|
|
661
|
-
* @param {Array<string>} [params.availableEncodings=[]] - Encodings this route supports
|
|
662
|
-
* @param {Array<string>} [params.acceptedMediaTypes=[]] - Content types this route accepts (for POST/PATCH/PUT)
|
|
663
|
-
* @param {Function} params.fetch - Function to generate response for matching requests
|
|
664
|
-
* @throws {TypeError} If endpoint is not a string
|
|
665
|
-
* @returns {void}
|
|
666
|
-
*/
|
|
636
|
+
// Turns a route descriptor (documented on startServer's `routes` param) into
|
|
637
|
+
// a route: matchers for the method, the resource and the headers, plus what
|
|
638
|
+
// the inspector shows. Throws on a malformed descriptor.
|
|
667
639
|
const createRoute = ({
|
|
668
640
|
endpoint,
|
|
669
641
|
description,
|
|
670
642
|
headers,
|
|
671
|
-
service,
|
|
672
643
|
serverPlugin,
|
|
673
644
|
permissionsRequired,
|
|
674
645
|
permissionsToSee,
|
|
@@ -680,7 +651,6 @@ const createRoute = ({
|
|
|
680
651
|
fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
|
|
681
652
|
clientCodeExample,
|
|
682
653
|
isFallback,
|
|
683
|
-
subroutes,
|
|
684
654
|
declarationSource,
|
|
685
655
|
}) => {
|
|
686
656
|
if (!endpoint || typeof endpoint !== "string") {
|
|
@@ -720,7 +690,6 @@ const createRoute = ({
|
|
|
720
690
|
method,
|
|
721
691
|
resource,
|
|
722
692
|
description,
|
|
723
|
-
service,
|
|
724
693
|
serverPlugin,
|
|
725
694
|
permissionsRequired,
|
|
726
695
|
permissionsToSee,
|
|
@@ -803,14 +772,10 @@ const createRoute = ({
|
|
|
803
772
|
resourcePattern,
|
|
804
773
|
isForWebSocket,
|
|
805
774
|
isFallback,
|
|
806
|
-
subroutes,
|
|
807
775
|
};
|
|
808
776
|
return route;
|
|
809
777
|
};
|
|
810
778
|
|
|
811
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
812
|
-
const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
|
|
813
|
-
|
|
814
779
|
// Returns a denied response if the request does not satisfy route access/visible config.
|
|
815
780
|
// Returns null if access is granted.
|
|
816
781
|
const checkRouteAccess = async (
|
|
@@ -847,15 +812,6 @@ const checkRouteAccess = async (
|
|
|
847
812
|
return createRouteNotFoundResponse(request);
|
|
848
813
|
};
|
|
849
814
|
|
|
850
|
-
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
851
|
-
for (const p of permissionsRequired) {
|
|
852
|
-
if (!permissionsSet.has(p)) {
|
|
853
|
-
return false;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
return true;
|
|
857
|
-
};
|
|
858
|
-
|
|
859
815
|
const createForbiddenResponse = () => {
|
|
860
816
|
return {
|
|
861
817
|
status: 403,
|
|
@@ -931,7 +887,6 @@ const createNotAcceptableResponse = (
|
|
|
931
887
|
) => {
|
|
932
888
|
const unsupported = [];
|
|
933
889
|
const headers = {};
|
|
934
|
-
const data = {};
|
|
935
890
|
|
|
936
891
|
if (availableMediaTypes.length) {
|
|
937
892
|
const requestAcceptHeader = request.headers["accept"];
|
|
@@ -939,11 +894,6 @@ const createNotAcceptableResponse = (
|
|
|
939
894
|
// Use a non-standard but semantic header name
|
|
940
895
|
headers["available-media-types"] = availableMediaTypes.join(", ");
|
|
941
896
|
|
|
942
|
-
Object.assign(data, {
|
|
943
|
-
requestAcceptHeader,
|
|
944
|
-
availableMediaTypes,
|
|
945
|
-
});
|
|
946
|
-
|
|
947
897
|
unsupported.push({
|
|
948
898
|
type: "content-type",
|
|
949
899
|
message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
|
|
@@ -956,11 +906,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
|
|
|
956
906
|
// Use a non-standard but semantic header name
|
|
957
907
|
headers["available-languages"] = availableLanguages.join(", ");
|
|
958
908
|
|
|
959
|
-
Object.assign(data, {
|
|
960
|
-
requestAcceptLanguageHeader,
|
|
961
|
-
availableLanguages,
|
|
962
|
-
});
|
|
963
|
-
|
|
964
909
|
unsupported.push({
|
|
965
910
|
type: "language",
|
|
966
911
|
message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
|
|
@@ -973,11 +918,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
|
|
|
973
918
|
// Use a non-standard but semantic header name
|
|
974
919
|
headers["available-versions"] = availableVersions.join(", ");
|
|
975
920
|
|
|
976
|
-
Object.assign(data, {
|
|
977
|
-
requestAcceptVersionHeader,
|
|
978
|
-
availableLanguages,
|
|
979
|
-
});
|
|
980
|
-
|
|
981
921
|
unsupported.push({
|
|
982
922
|
type: "version",
|
|
983
923
|
message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
|
|
@@ -990,11 +930,6 @@ Available versions: ${availableVersions.join(", ")}.`,
|
|
|
990
930
|
// Use a non-standard but semantic header name
|
|
991
931
|
headers["available-encodings"] = availableEncodings.join(", ");
|
|
992
932
|
|
|
993
|
-
Object.assign(data, {
|
|
994
|
-
requestAcceptEncodingHeader,
|
|
995
|
-
availableEncodings,
|
|
996
|
-
});
|
|
997
|
-
|
|
998
933
|
unsupported.push({
|
|
999
934
|
type: "encoding",
|
|
1000
935
|
message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
|
|
@@ -1031,7 +966,7 @@ const createMethodNotAllowedResponse = (
|
|
|
1031
966
|
return {
|
|
1032
967
|
status: 405,
|
|
1033
968
|
statusText: "Method Not Allowed",
|
|
1034
|
-
|
|
969
|
+
statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
|
|
1035
970
|
Allowed methods: ${allowedMethods.join(", ")}`,
|
|
1036
971
|
headers: {
|
|
1037
972
|
allow: allowedMethods.join(", "),
|
package/src/server_events.js
CHANGED
|
@@ -311,7 +311,9 @@ const createServerEvents = ({
|
|
|
311
311
|
if (history) {
|
|
312
312
|
addEventToHistory(event);
|
|
313
313
|
}
|
|
314
|
-
logger.debug(
|
|
314
|
+
logger.debug(
|
|
315
|
+
`send "${event.type}" event to ${clientArray.length} client(s)`,
|
|
316
|
+
);
|
|
315
317
|
for (const client of clientArray) {
|
|
316
318
|
client.sendEvent(event);
|
|
317
319
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseMultipleHeader,
|
|
3
3
|
stringifyMultipleHeader,
|
|
4
|
-
} from "../internal/
|
|
4
|
+
} from "../internal/multiple_header.js";
|
|
5
5
|
|
|
6
6
|
// to predict order in chrome devtools we should put a,b,c,d,e or something
|
|
7
7
|
// because in chrome dev tools they are shown in alphabetic order
|