@antelopejs/dms-frontend 0.9.3 → 0.10.1
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 +46 -29
- package/dist/commands/build-action.d.ts +3 -0
- package/dist/commands/build-action.js +77 -8
- package/dist/commands/build.js +7 -0
- package/dist/commands/clean-action.js +1 -1
- package/dist/commands/clean.js +1 -1
- package/dist/commands/dev-action.js +4 -3
- package/dist/commands/dev.js +1 -1
- package/dist/commands/prepare-action.js +1 -1
- package/dist/commands/start-action.js +3 -24
- package/dist/commands/workspace-task.js +2 -1
- package/dist/config.js +1 -0
- package/dist/derived-outputs.d.ts +1 -0
- package/dist/derived-outputs.js +6 -4
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- package/dist/help.js +1 -1
- package/dist/index.js +2 -1
- package/dist/internal/built-workspace.d.ts +4 -0
- package/dist/internal/built-workspace.js +43 -0
- package/dist/internal/ensure.d.ts +1 -0
- package/dist/internal/ensure.js +8 -0
- package/dist/internal/locale-catalogs.d.ts +8 -3
- package/dist/internal/locale-catalogs.js +16 -6
- package/dist/internal/manifest-decoder.d.ts +7 -0
- package/dist/internal/manifest-decoder.js +81 -0
- package/dist/internal/own-release.d.ts +5 -0
- package/dist/internal/own-release.js +20 -0
- package/dist/internal/plain-object.d.ts +1 -0
- package/dist/internal/plain-object.js +6 -0
- package/dist/internal/prepared-workspace.d.ts +12 -0
- package/dist/internal/prepared-workspace.js +74 -0
- package/dist/internal/text-list.d.ts +1 -0
- package/dist/internal/text-list.js +10 -0
- package/dist/internal/vite-manifest.d.ts +6 -0
- package/dist/internal/vite-manifest.js +24 -0
- package/dist/layer-watch.js +25 -24
- package/dist/layers.d.ts +1 -1
- package/dist/layers.js +3 -1
- package/dist/locale-files.d.ts +6 -3
- package/dist/locale-files.js +15 -9
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +46 -32
- package/dist/materialize.js +6 -2
- package/dist/module-set.js +2 -1
- package/dist/node-options.d.ts +1 -0
- package/dist/node-options.js +8 -0
- package/dist/output.d.ts +15 -9
- package/dist/output.js +30 -18
- package/dist/ports.d.ts +3 -1
- package/dist/ports.js +1 -1
- package/dist/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.d.ts +5 -1
- package/dist/project-modules.js +23 -15
- package/dist/renderer-range.js +5 -3
- package/dist/server-process.js +1 -1
- package/dist/verify-source-build.js +10 -2
- package/dist/verify-source-client-bundle.d.ts +1 -0
- package/dist/verify-source-client-bundle.js +30 -0
- package/dist/verify-source-runner.js +36 -94
- package/dist/verify-source-sources.js +49 -11
- package/dist/verify-source-workspace.d.ts +2 -0
- package/dist/verify-source-workspace.js +111 -0
- package/dist/workspace-setup.d.ts +1 -0
- package/dist/workspace-setup.js +4 -3
- package/package.json +11 -7
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +2 -9
- package/templates/vue/backend-boundary.mjs +132 -0
- package/templates/vue/client-chunking.mjs +2 -0
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +29 -3
- package/templates/vue/frontend-build.ts +4 -3
- package/templates/vue/frontend-module.ts +1 -0
- package/templates/vue/frontend-runtime/module-registration.ts +2 -2
- package/templates/vue/frontend-runtime/state.ts +2 -2
- package/templates/vue/frontend-runtime/types.ts +8 -2
- package/templates/vue/head-order.mjs +10 -2
- package/templates/vue/icon-api.mjs +2 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/server/auth/backend.mjs +42 -5
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +11 -7
- package/templates/vue/server/auth/session.mjs +86 -4
- package/templates/vue/server/auth-declarations.mjs +46 -0
- package/templates/vue/server/auth-hooks.mjs +55 -7
- package/templates/vue/server/backend-proxies.mjs +78 -18
- package/templates/vue/server/backend-request.mjs +61 -0
- package/templates/vue/server/backend-response.mjs +23 -6
- package/templates/vue/server/built-files.mjs +7 -0
- package/templates/vue/server/cli-report.mjs +75 -12
- package/templates/vue/server/client-error.mjs +5 -0
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +40 -10
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email-bundle-watch.mjs +9 -3
- package/templates/vue/server/email.mjs +14 -6
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +221 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +93 -16
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +76 -3
- package/templates/vue/server/module-event.mjs +118 -25
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +151 -96
- package/templates/vue/server/module-server.mjs +121 -40
- package/templates/vue/server/module-testing.mjs +36 -12
- package/templates/vue/server/page-props.mjs +94 -3
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +35 -12
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +12 -2
- package/templates/vue/server/proxy-redirects.mjs +21 -5
- package/templates/vue/server/proxy-request.mjs +58 -0
- package/templates/vue/server/proxy-transfers.mjs +44 -10
- package/templates/vue/server/public-endpoints.mjs +139 -28
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +24 -1
- package/templates/vue/server/request-routing.mjs +32 -10
- package/templates/vue/server/request-target.mjs +30 -11
- package/templates/vue/server/route-rules.mjs +48 -18
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +35 -7
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +45 -7
- package/templates/vue/server/web-body.mjs +5 -1
- package/templates/vue/server-module.ts +12 -2
- package/templates/vue/server.mjs +162 -157
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +2 -2
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +9 -0
|
@@ -20,11 +20,22 @@ const COMPRESSIBLE_TYPE =
|
|
|
20
20
|
/^(?:text\/(?!event-stream)[\w.+-]+|application\/(?:[\w.-]+\+)?(?:json|xml|javascript|ecmascript)|image\/svg\+xml)\s*(?:;|$)/i;
|
|
21
21
|
const BODILESS_STATUSES = new Set([204, 205, 304]);
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* What the proxy relays of a backend answer: a fetched `Response`, or the
|
|
25
|
+
* same fields without its content coding.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {Pick<Response, "status" | "headers" | "body" | "url">} ProxiedAnswer
|
|
28
|
+
*/
|
|
29
|
+
|
|
23
30
|
/**
|
|
24
31
|
* Whether the backend answer is one to compress on its way to the client.
|
|
25
32
|
*
|
|
26
33
|
* A response already encoded, or a range of the bytes, is not compressed:
|
|
27
34
|
* compressing would change what the encoding or the range describes.
|
|
35
|
+
*
|
|
36
|
+
* @param {import("node:http").IncomingMessage} request
|
|
37
|
+
* @param {ProxiedAnswer} upstream
|
|
38
|
+
* @returns {boolean}
|
|
28
39
|
*/
|
|
29
40
|
function isCompressible(request, upstream) {
|
|
30
41
|
if (request.method === "HEAD" || !upstream.body) return false;
|
|
@@ -48,6 +59,9 @@ function isCompressible(request, upstream) {
|
|
|
48
59
|
*
|
|
49
60
|
* Streaming: an answer of unknown length (chunked JSON, streamed text) reaches
|
|
50
61
|
* the client part by part, as the backend writes it.
|
|
62
|
+
*
|
|
63
|
+
* @param {import("node:http").IncomingMessage} request
|
|
64
|
+
* @param {ProxiedAnswer} upstream
|
|
51
65
|
*/
|
|
52
66
|
function proxyEncoder(request, upstream) {
|
|
53
67
|
return isCompressible(request, upstream)
|
|
@@ -64,6 +78,14 @@ function proxyEncoder(request, upstream) {
|
|
|
64
78
|
*
|
|
65
79
|
* A decoded partial representation cannot retain the encoded byte offsets, and
|
|
66
80
|
* a complete 200 response is not manufactured from a partial 206 either.
|
|
81
|
+
*
|
|
82
|
+
* @param {import("node:http").IncomingMessage} request
|
|
83
|
+
* @param {import("node:http").ServerResponse} response
|
|
84
|
+
* @param {ProxiedAnswer} upstream
|
|
85
|
+
* @param {{
|
|
86
|
+
* fetched?: boolean,
|
|
87
|
+
* isProxiedPath?: (pathname: string) => boolean
|
|
88
|
+
* }} [options]
|
|
67
89
|
*/
|
|
68
90
|
export async function relayAnswer(
|
|
69
91
|
request,
|
|
@@ -15,7 +15,11 @@ const HOP_BY_HOP_HEADERS = [
|
|
|
15
15
|
"upgrade",
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* `Accept-Encoding` added to the backend's own `Vary`, once.
|
|
20
|
+
*
|
|
21
|
+
* @param {string | undefined} vary
|
|
22
|
+
*/
|
|
19
23
|
function varyWithEncoding(vary) {
|
|
20
24
|
const names = (vary ?? "")
|
|
21
25
|
.split(",")
|
|
@@ -32,6 +36,12 @@ function varyWithEncoding(vary) {
|
|
|
32
36
|
* known, and a strong validator no longer names these bytes. A `Location` on
|
|
33
37
|
* the backend's origin becomes the same path on the frontend's when
|
|
34
38
|
* `isProxiedPath` says the frontend proxies it (see `relayedLocation`).
|
|
39
|
+
*
|
|
40
|
+
* @param {import("node:http").ServerResponse} response
|
|
41
|
+
* @param {Pick<Response, "status" | "headers" | "url">} upstream
|
|
42
|
+
* @param {string | undefined} encoding
|
|
43
|
+
* @param {boolean} [varies]
|
|
44
|
+
* @param {(pathname: string) => boolean} [isProxiedPath]
|
|
35
45
|
*/
|
|
36
46
|
export function writeProxyHeaders(
|
|
37
47
|
response,
|
|
@@ -47,7 +57,7 @@ export function writeProxyHeaders(
|
|
|
47
57
|
for (const name of [...HOP_BY_HOP_HEADERS, ...named]) delete headers[name];
|
|
48
58
|
delete headers["set-cookie"];
|
|
49
59
|
const cookies = upstream.headers.getSetCookie?.() ?? [];
|
|
50
|
-
if (cookies.length) headers
|
|
60
|
+
if (cookies.length) Object.assign(headers, { "set-cookie": cookies });
|
|
51
61
|
if (headers.location && upstream.url)
|
|
52
62
|
headers.location = relayedLocation(
|
|
53
63
|
headers.location,
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/** The statuses that send the client to their `Location`. */
|
|
2
2
|
export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @param {string | URL | undefined} url
|
|
6
|
+
* @returns {string | undefined}
|
|
7
|
+
*/
|
|
4
8
|
function originOf(url) {
|
|
9
|
+
if (url === undefined) return undefined;
|
|
5
10
|
try {
|
|
6
11
|
return new URL(url).origin;
|
|
7
12
|
} catch {
|
|
@@ -9,7 +14,13 @@ function originOf(url) {
|
|
|
9
14
|
}
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Whether `url` is on the backend's origin.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} url
|
|
21
|
+
* @param {string} [backendBaseUrl]
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
13
24
|
export function isBackendUrl(
|
|
14
25
|
url,
|
|
15
26
|
backendBaseUrl = process.env.DMS_API_BASE_URL,
|
|
@@ -34,16 +45,18 @@ export function isBackendUrl(
|
|
|
34
45
|
*
|
|
35
46
|
* `//host` would name another host, so `/.` keeps the location a path.
|
|
36
47
|
*
|
|
37
|
-
* @param location The backend's `Location`
|
|
38
|
-
* @param fetchedUrl The backend URL the answer came from
|
|
39
|
-
* @param
|
|
40
|
-
* sends that path to the backend
|
|
48
|
+
* @param {string} location The backend's `Location`
|
|
49
|
+
* @param {string | URL} fetchedUrl The backend URL the answer came from
|
|
50
|
+
* @param {(pathname: string) => boolean} [isProxiedPath] Whether the frontend
|
|
51
|
+
* server sends that path to the backend
|
|
52
|
+
* @returns {string}
|
|
41
53
|
*/
|
|
42
54
|
export function relayedLocation(
|
|
43
55
|
location,
|
|
44
56
|
fetchedUrl,
|
|
45
57
|
isProxiedPath = () => false,
|
|
46
58
|
) {
|
|
59
|
+
/** @type {URL} */
|
|
47
60
|
let target;
|
|
48
61
|
try {
|
|
49
62
|
target = new URL(location, fetchedUrl);
|
|
@@ -60,6 +73,9 @@ export function relayedLocation(
|
|
|
60
73
|
* The cookies of a `Response` a module route returns. A fetch that followed a
|
|
61
74
|
* redirect to another site would set that site's cookies on the frontend's
|
|
62
75
|
* origin: those are dropped.
|
|
76
|
+
*
|
|
77
|
+
* @param {Response} answer
|
|
78
|
+
* @returns {string[]}
|
|
63
79
|
*/
|
|
64
80
|
export function relayableCookies(answer) {
|
|
65
81
|
if (answer.redirected && !isBackendUrl(answer.url)) return [];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { backendHeaders } from "./backend-request.mjs";
|
|
2
|
+
import { PLAIN_ANSWER_HEADERS, relayAnswer } from "./proxy-compression.mjs";
|
|
3
|
+
import { sendWithinLimit } from "./proxy-transfers.mjs";
|
|
4
|
+
import { proxyAccess, refuseCrossOrigin } from "./public-endpoints.mjs";
|
|
5
|
+
import { backendUrl } from "./request-target.mjs";
|
|
6
|
+
|
|
7
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sends a request to the backend through the proxy it reached.
|
|
11
|
+
* `isProxiedPath` says which backend paths a relayed `Location` may name on
|
|
12
|
+
* the frontend's origin.
|
|
13
|
+
*
|
|
14
|
+
* A client that left while the request was routed closed the response already,
|
|
15
|
+
* and would never abort the request below.
|
|
16
|
+
*
|
|
17
|
+
* Cancelling the body alone misses a client that leaves before the backend
|
|
18
|
+
* answers: its stream, an SSE feed above all, then stayed open for good.
|
|
19
|
+
*
|
|
20
|
+
* A backend redirect is the browser's to follow: Media sends a file's link to
|
|
21
|
+
* the storage's signed URL, on the public origin the server itself may not
|
|
22
|
+
* reach, which turned every download into a 502.
|
|
23
|
+
*
|
|
24
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
25
|
+
* @param {import("node:http").ServerResponse} response
|
|
26
|
+
* @param {import("./backend-proxies.mjs").BackendProxy} backendProxy
|
|
27
|
+
* @param {(path: string) => boolean} isProxiedPath
|
|
28
|
+
* @returns {Promise<true | undefined>}
|
|
29
|
+
*/
|
|
30
|
+
export async function proxy(request, response, backendProxy, isProxiedPath) {
|
|
31
|
+
const access = proxyAccess(request, SAFE_METHODS, backendProxy);
|
|
32
|
+
if (access.isRefused) {
|
|
33
|
+
refuseCrossOrigin(response);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (response.closed) return;
|
|
37
|
+
const abandoned = new AbortController();
|
|
38
|
+
response.once("close", () => abandoned.abort());
|
|
39
|
+
/** @type {RequestInit} */
|
|
40
|
+
const init = {
|
|
41
|
+
signal: abandoned.signal,
|
|
42
|
+
method: request.method,
|
|
43
|
+
headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
|
|
44
|
+
duplex: "half",
|
|
45
|
+
redirect: "manual",
|
|
46
|
+
};
|
|
47
|
+
const upstream = await sendWithinLimit(
|
|
48
|
+
request,
|
|
49
|
+
response,
|
|
50
|
+
backendProxy.maxBodyBytes,
|
|
51
|
+
(body) => fetch(backendUrl(request.url), { ...init, body }),
|
|
52
|
+
);
|
|
53
|
+
if (!upstream) return true;
|
|
54
|
+
await relayAnswer(request, response, upstream, {
|
|
55
|
+
fetched: true,
|
|
56
|
+
isProxiedPath,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isObjectLike } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const PAYLOAD_TOO_LARGE = 413;
|
|
2
4
|
const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
|
|
3
5
|
/**
|
|
@@ -26,6 +28,11 @@ export const SERVER_OPTIONS = Object.freeze({
|
|
|
26
28
|
|
|
27
29
|
class BodyTooLargeError extends Error {}
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* @param {import("node:http").IncomingMessage} request
|
|
33
|
+
* @param {number} maxBodyBytes
|
|
34
|
+
* @returns {AsyncGenerator<Buffer>}
|
|
35
|
+
*/
|
|
29
36
|
async function* limitedChunks(request, maxBodyBytes) {
|
|
30
37
|
let received = 0;
|
|
31
38
|
for await (const chunk of request.iterator(BODY_ITERATION)) {
|
|
@@ -35,6 +42,10 @@ async function* limitedChunks(request, maxBodyBytes) {
|
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/**
|
|
46
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
47
|
+
* @param {number | undefined} maxBodyBytes
|
|
48
|
+
*/
|
|
38
49
|
function requestBody(request, maxBodyBytes) {
|
|
39
50
|
if (BODILESS_METHODS.has(request.method)) return undefined;
|
|
40
51
|
return maxBodyBytes === undefined
|
|
@@ -42,6 +53,11 @@ function requestBody(request, maxBodyBytes) {
|
|
|
42
53
|
: limitedChunks(request, maxBodyBytes);
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/**
|
|
57
|
+
* @param {import("node:http").IncomingMessage} request
|
|
58
|
+
* @param {number | undefined} maxBodyBytes
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
45
61
|
function declaresTooLargeBody(request, maxBodyBytes) {
|
|
46
62
|
return (
|
|
47
63
|
maxBodyBytes !== undefined &&
|
|
@@ -49,10 +65,14 @@ function declaresTooLargeBody(request, maxBodyBytes) {
|
|
|
49
65
|
);
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown} error
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
52
72
|
function isBodyTooLarge(error) {
|
|
53
73
|
return (
|
|
54
74
|
error instanceof BodyTooLargeError ||
|
|
55
|
-
error
|
|
75
|
+
(isObjectLike(error) && error.cause instanceof BodyTooLargeError)
|
|
56
76
|
);
|
|
57
77
|
}
|
|
58
78
|
|
|
@@ -65,6 +85,9 @@ function isBodyTooLarge(error) {
|
|
|
65
85
|
* the browser's upload failing without a reason. The rest of the body is read
|
|
66
86
|
* and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
|
|
67
87
|
* at most; the response ends with it and the connection then closes cleanly.
|
|
88
|
+
*
|
|
89
|
+
* @param {import("node:http").IncomingMessage} request
|
|
90
|
+
* @param {import("node:http").ServerResponse} response
|
|
68
91
|
*/
|
|
69
92
|
function refuseTooLargeBody(request, response) {
|
|
70
93
|
response.writeHead(PAYLOAD_TOO_LARGE, {
|
|
@@ -72,7 +95,10 @@ function refuseTooLargeBody(request, response) {
|
|
|
72
95
|
connection: "close",
|
|
73
96
|
});
|
|
74
97
|
response.write(TOO_LARGE_BODY);
|
|
75
|
-
if (request.complete)
|
|
98
|
+
if (request.complete) {
|
|
99
|
+
response.end();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
76
102
|
const close = () => {
|
|
77
103
|
clearTimeout(timer);
|
|
78
104
|
response.end();
|
|
@@ -96,19 +122,27 @@ function refuseTooLargeBody(request, response) {
|
|
|
96
122
|
* before a byte is passed on, a chunked body is cut off as soon as it goes
|
|
97
123
|
* over.
|
|
98
124
|
*
|
|
99
|
-
* @param
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* @
|
|
103
|
-
*
|
|
125
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
126
|
+
* @param {import("node:http").ServerResponse} response
|
|
127
|
+
* @param {number | undefined} maxBodyBytes
|
|
128
|
+
* @param {(
|
|
129
|
+
* body: import("node:http").IncomingMessage | AsyncGenerator<Buffer> | undefined
|
|
130
|
+
* ) => Promise<Response>} send The backend request with the body to stream:
|
|
131
|
+
* none for a GET or a HEAD, the request itself, or its bytes cut off past
|
|
132
|
+
* the limit
|
|
133
|
+
* @returns {Promise<Response | undefined>} The backend's answer, or
|
|
134
|
+
* `undefined` once the body went over and `response` got the 413
|
|
104
135
|
*/
|
|
105
136
|
export async function sendWithinLimit(request, response, maxBodyBytes, send) {
|
|
106
|
-
if (declaresTooLargeBody(request, maxBodyBytes))
|
|
107
|
-
|
|
137
|
+
if (declaresTooLargeBody(request, maxBodyBytes)) {
|
|
138
|
+
refuseTooLargeBody(request, response);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
108
141
|
try {
|
|
109
142
|
return await send(requestBody(request, maxBodyBytes));
|
|
110
143
|
} catch (error) {
|
|
111
144
|
if (!isBodyTooLarge(error)) throw error;
|
|
112
|
-
|
|
145
|
+
refuseTooLargeBody(request, response);
|
|
146
|
+
return undefined;
|
|
113
147
|
}
|
|
114
148
|
}
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { json } from "./auth/backend.mjs";
|
|
3
3
|
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./auth/client-ip.mjs";
|
|
4
|
+
import { PROXY_AUTH_MODES } from "./backend-proxies.mjs";
|
|
5
|
+
import { isNumber, isObjectLike, isRecord, isString } from "./type-guards.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A public endpoint, compiled from a declaration that passed every check.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {object} PublicEndpoint
|
|
11
|
+
* @property {string} path
|
|
12
|
+
* @property {string[]} methods
|
|
13
|
+
* @property {string[]} headers
|
|
14
|
+
* @property {RegExp} matcher
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A declaration as the manifest or the environment holds it, before any check.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {object} PublicEndpointDeclaration
|
|
21
|
+
* @property {unknown} [path]
|
|
22
|
+
* @property {unknown} [methods]
|
|
23
|
+
* @property {unknown} [headers]
|
|
24
|
+
*/
|
|
4
25
|
|
|
5
26
|
/**
|
|
6
27
|
* Written at the workspace root by the loader, from the endpoints each
|
|
@@ -41,39 +62,101 @@ const HEADER_NAME = /^x-[a-z0-9-]+$/;
|
|
|
41
62
|
const RESERVED_HEADER =
|
|
42
63
|
/^x-(?:forwarded-|real-ip$|dms-|inertia|http-method|method-override|original-|rewrite-)/;
|
|
43
64
|
|
|
65
|
+
/** @type {unknown[] | undefined} */
|
|
44
66
|
let declaredEndpoints;
|
|
67
|
+
/** @type {import("./backend-proxies.mjs").ManifestProxy[] | undefined} */
|
|
45
68
|
let declaredProxies;
|
|
46
69
|
|
|
47
70
|
/**
|
|
48
71
|
* The public endpoints the modules of this deployment declared, unchecked.
|
|
49
72
|
*
|
|
50
|
-
* @param file Generated file to read
|
|
51
|
-
* @returns The declared entries, or none when the file is absent or broken
|
|
73
|
+
* @param {URL | string} [file] Generated file to read
|
|
74
|
+
* @returns {unknown[]} The declared entries, or none when the file is absent or broken
|
|
52
75
|
*/
|
|
53
76
|
export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
54
77
|
try {
|
|
78
|
+
/** @type {unknown} */
|
|
55
79
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
-
|
|
80
|
+
const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
|
|
81
|
+
return Array.isArray(endpoints) ? endpoints : [];
|
|
57
82
|
} catch {
|
|
58
83
|
return [];
|
|
59
84
|
}
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Whether `entry` names a path and, when it says so, an auth mode and a body
|
|
89
|
+
* limit of the right type. The path pattern and the limit's range are judged
|
|
90
|
+
* when the proxies are resolved.
|
|
91
|
+
*
|
|
92
|
+
* @param {unknown} entry
|
|
93
|
+
* @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number, module?: unknown }}
|
|
94
|
+
*/
|
|
95
|
+
function isProxyDeclaration(entry) {
|
|
96
|
+
return (
|
|
97
|
+
isRecord(entry) &&
|
|
98
|
+
!Array.isArray(entry) &&
|
|
99
|
+
isString(entry.path) &&
|
|
100
|
+
(entry.auth === undefined ||
|
|
101
|
+
PROXY_AUTH_MODES.some((mode) => mode === entry.auth)) &&
|
|
102
|
+
(entry.maxBodyBytes === undefined || isNumber(entry.maxBodyBytes))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {unknown[]} entries
|
|
108
|
+
* @param {string} source Where they were declared, for the warning
|
|
109
|
+
* @param {(entry: unknown) => boolean} isUsable
|
|
110
|
+
*/
|
|
111
|
+
function warnUnusableProxies(entries, source, isUsable) {
|
|
112
|
+
const unusable = entries.filter((entry) => !isUsable(entry)).length;
|
|
113
|
+
if (unusable)
|
|
114
|
+
console.warn(
|
|
115
|
+
`Ignoring ${unusable} ${unusable === 1 ? "entry" : "entries"} of ${source}: not a backend proxy declaration (a path, an auth of session or none, a body limit in bytes)`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {unknown} entry
|
|
121
|
+
* @returns {entry is import("./backend-proxies.mjs").ManifestProxy}
|
|
122
|
+
*/
|
|
123
|
+
function isDeclaredProxy(entry) {
|
|
124
|
+
return (
|
|
125
|
+
isRecord(entry) &&
|
|
126
|
+
isProxyDeclaration(entry) &&
|
|
127
|
+
(entry.module === undefined || isString(entry.module))
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
62
131
|
/**
|
|
63
132
|
* The backend proxies the backend modules declared in the manifest, as
|
|
64
|
-
* `{ module, path, auth?, maxBodyBytes? }
|
|
133
|
+
* `{ module, path, auth?, maxBodyBytes? }`. An entry that is not one is
|
|
134
|
+
* reported and left out.
|
|
65
135
|
*
|
|
66
136
|
* A missing or broken file declares no proxy.
|
|
67
137
|
*
|
|
68
|
-
* @param file Generated file to read
|
|
138
|
+
* @param {URL | string} [file] Generated file to read
|
|
139
|
+
* @returns {import("./backend-proxies.mjs").ManifestProxy[]}
|
|
69
140
|
*/
|
|
70
141
|
export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
|
|
71
142
|
if (file === DECLARED_ENDPOINTS_FILE && declaredProxies)
|
|
72
143
|
return declaredProxies;
|
|
144
|
+
/** @type {import("./backend-proxies.mjs").ManifestProxy[]} */
|
|
73
145
|
let proxies = [];
|
|
74
146
|
try {
|
|
147
|
+
/** @type {unknown} */
|
|
75
148
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
76
|
-
|
|
149
|
+
const listed = isObjectLike(parsed) ? parsed.proxies : undefined;
|
|
150
|
+
if (Array.isArray(listed)) {
|
|
151
|
+
/** @type {unknown[]} */
|
|
152
|
+
const entries = listed;
|
|
153
|
+
warnUnusableProxies(
|
|
154
|
+
entries,
|
|
155
|
+
"the generated manifest file",
|
|
156
|
+
isDeclaredProxy,
|
|
157
|
+
);
|
|
158
|
+
proxies = entries.filter(isDeclaredProxy);
|
|
159
|
+
}
|
|
77
160
|
} catch {}
|
|
78
161
|
if (file === DECLARED_ENDPOINTS_FILE) declaredProxies = proxies;
|
|
79
162
|
return proxies;
|
|
@@ -82,29 +165,40 @@ export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
|
|
|
82
165
|
/**
|
|
83
166
|
* The backend proxies `DMS_BACKEND_PROXIES` adds: a JSON array of
|
|
84
167
|
* `{ path, auth?, maxBodyBytes? }`, as the manifest's `backendProxies`, for an
|
|
85
|
-
* operator. A value that is not a JSON array is reported and ignored
|
|
168
|
+
* operator. A value that is not a JSON array is reported and ignored, and so
|
|
169
|
+
* is each entry that is not a declaration.
|
|
86
170
|
*
|
|
87
|
-
* @param declaration The variable's value
|
|
171
|
+
* @param {string} [declaration] The variable's value
|
|
172
|
+
* @returns {import("./backend-proxies.mjs").ManifestProxy[]}
|
|
88
173
|
*/
|
|
89
174
|
export function environmentBackendProxies(
|
|
90
175
|
declaration = process.env.DMS_BACKEND_PROXIES,
|
|
91
176
|
) {
|
|
92
177
|
if (!declaration?.trim()) return [];
|
|
93
178
|
try {
|
|
179
|
+
/** @type {unknown} */
|
|
94
180
|
const parsed = JSON.parse(declaration);
|
|
95
|
-
if (Array.isArray(parsed))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
181
|
+
if (Array.isArray(parsed)) {
|
|
182
|
+
/** @type {unknown[]} */
|
|
183
|
+
const entries = parsed;
|
|
184
|
+
warnUnusableProxies(entries, "DMS_BACKEND_PROXIES", isProxyDeclaration);
|
|
185
|
+
return entries
|
|
186
|
+
.filter(isProxyDeclaration)
|
|
187
|
+
.map((entry) => ({ ...entry, module: "DMS_BACKEND_PROXIES" }));
|
|
188
|
+
}
|
|
100
189
|
} catch {}
|
|
101
190
|
console.warn("Ignoring DMS_BACKEND_PROXIES: not a JSON array");
|
|
102
191
|
return [];
|
|
103
192
|
}
|
|
104
193
|
|
|
194
|
+
/**
|
|
195
|
+
* @param {string | undefined} declaration
|
|
196
|
+
* @returns {unknown[]}
|
|
197
|
+
*/
|
|
105
198
|
function environmentEndpoints(declaration) {
|
|
106
199
|
if (!declaration?.trim()) return [];
|
|
107
200
|
try {
|
|
201
|
+
/** @type {unknown} */
|
|
108
202
|
const parsed = JSON.parse(declaration);
|
|
109
203
|
return Array.isArray(parsed) ? parsed : [];
|
|
110
204
|
} catch {
|
|
@@ -113,6 +207,7 @@ function environmentEndpoints(declaration) {
|
|
|
113
207
|
}
|
|
114
208
|
}
|
|
115
209
|
|
|
210
|
+
/** @param {unknown} methods */
|
|
116
211
|
function normalizedMethods(methods) {
|
|
117
212
|
if (methods === undefined) return DEFAULT_METHODS;
|
|
118
213
|
if (!Array.isArray(methods) || methods.length === 0) return undefined;
|
|
@@ -122,6 +217,7 @@ function normalizedMethods(methods) {
|
|
|
122
217
|
: undefined;
|
|
123
218
|
}
|
|
124
219
|
|
|
220
|
+
/** @param {unknown} headers */
|
|
125
221
|
function normalizedHeaders(headers) {
|
|
126
222
|
if (headers === undefined) return [];
|
|
127
223
|
if (!Array.isArray(headers)) return undefined;
|
|
@@ -133,6 +229,7 @@ function normalizedHeaders(headers) {
|
|
|
133
229
|
: undefined;
|
|
134
230
|
}
|
|
135
231
|
|
|
232
|
+
/** @param {string} path */
|
|
136
233
|
function pathMatcher(path) {
|
|
137
234
|
const pattern = path
|
|
138
235
|
.split("/")
|
|
@@ -148,11 +245,13 @@ function pathMatcher(path) {
|
|
|
148
245
|
/**
|
|
149
246
|
* A declaration made usable, or `undefined` when any part of it is malformed:
|
|
150
247
|
* a bad entry is dropped whole, never widened to what it might have meant.
|
|
248
|
+
*
|
|
249
|
+
* @param {unknown} entry
|
|
250
|
+
* @returns {PublicEndpoint | undefined}
|
|
151
251
|
*/
|
|
152
252
|
export function compilePublicEndpoint(entry) {
|
|
153
|
-
if (
|
|
154
|
-
if (
|
|
155
|
-
return undefined;
|
|
253
|
+
if (!isObjectLike(entry)) return undefined;
|
|
254
|
+
if (!isString(entry.path) || !PUBLIC_PATH.test(entry.path)) return undefined;
|
|
156
255
|
const methods = normalizedMethods(entry.methods);
|
|
157
256
|
const headers = normalizedHeaders(entry.headers);
|
|
158
257
|
if (!methods || !headers) return undefined;
|
|
@@ -168,6 +267,10 @@ export function compilePublicEndpoint(entry) {
|
|
|
168
267
|
* The backend endpoints this deployment serves to anyone: what the backend's
|
|
169
268
|
* modules declared, plus what `DMS_PUBLIC_ENDPOINTS` adds (a JSON array of the
|
|
170
269
|
* same `{ path, methods?, headers? }` entries).
|
|
270
|
+
*
|
|
271
|
+
* @param {string} [declaration]
|
|
272
|
+
* @param {unknown[]} [declared]
|
|
273
|
+
* @returns {PublicEndpoint[]}
|
|
171
274
|
*/
|
|
172
275
|
export function allowedPublicEndpoints(
|
|
173
276
|
declaration = process.env.DMS_PUBLIC_ENDPOINTS,
|
|
@@ -175,7 +278,9 @@ export function allowedPublicEndpoints(
|
|
|
175
278
|
) {
|
|
176
279
|
return [...declared, ...environmentEndpoints(declaration)].flatMap(
|
|
177
280
|
(entry) => {
|
|
178
|
-
const endpoint =
|
|
281
|
+
const endpoint = isRecord(entry)
|
|
282
|
+
? compilePublicEndpoint(entry)
|
|
283
|
+
: undefined;
|
|
179
284
|
if (!endpoint)
|
|
180
285
|
console.warn(
|
|
181
286
|
`Ignoring malformed public endpoint ${JSON.stringify(entry)}: expected ` +
|
|
@@ -189,6 +294,8 @@ export function allowedPublicEndpoints(
|
|
|
189
294
|
/**
|
|
190
295
|
* Compiled once per value of the environment variable, so a malformed entry
|
|
191
296
|
* is reported once rather than on every request.
|
|
297
|
+
*
|
|
298
|
+
* @type {{ declaration: string | undefined, endpoints: PublicEndpoint[] } | undefined}
|
|
192
299
|
*/
|
|
193
300
|
let compiled;
|
|
194
301
|
|
|
@@ -202,9 +309,9 @@ function currentPublicEndpoints() {
|
|
|
202
309
|
/**
|
|
203
310
|
* The public endpoint a request reaches, if any.
|
|
204
311
|
*
|
|
205
|
-
* @param method The request's method
|
|
206
|
-
* @param pathname The request's normalized path, without its query string
|
|
207
|
-
* @param endpoints The endpoints to match, this deployment's by default
|
|
312
|
+
* @param {string} method The request's method
|
|
313
|
+
* @param {string} pathname The request's normalized path, without its query string
|
|
314
|
+
* @param {PublicEndpoint[]} [endpoints] The endpoints to match, this deployment's by default
|
|
208
315
|
*/
|
|
209
316
|
export function matchPublicEndpoint(
|
|
210
317
|
method,
|
|
@@ -226,23 +333,23 @@ export function matchPublicEndpoint(
|
|
|
226
333
|
* Origin. Under `auth: "session"`, a state-changing request needs one unless
|
|
227
334
|
* it reaches a public endpoint, which is why the proxy never sends a public
|
|
228
335
|
* endpoint the session: a cross-site form posting to it acts as nobody.
|
|
336
|
+
* Under either mode, a request that reaches a public endpoint carries the
|
|
337
|
+
* endpoint's declared headers.
|
|
229
338
|
*
|
|
230
|
-
* @param request The incoming request
|
|
231
|
-
* @param safeMethods Methods that change nothing and need no Origin
|
|
232
|
-
* @param proxy The backend proxy the request reached
|
|
339
|
+
* @param {import("./request-target.mjs").ServerRequest} request The incoming request
|
|
340
|
+
* @param {ReadonlySet<string>} safeMethods Methods that change nothing and need no Origin
|
|
341
|
+
* @param {import("./backend-proxies.mjs").BackendProxy} proxy The backend proxy the request reached
|
|
233
342
|
* @returns `{ anonymous, headers, isRefused }`
|
|
234
343
|
*/
|
|
235
344
|
export function proxyAccess(request, safeMethods, proxy) {
|
|
236
|
-
if (proxy.auth === "none")
|
|
237
|
-
return { anonymous: true, headers: [], isRefused: false };
|
|
238
345
|
const publicEndpoint = matchPublicEndpoint(
|
|
239
346
|
request.method,
|
|
240
347
|
new URL(request.url, "http://frontend.local").pathname,
|
|
241
348
|
);
|
|
242
|
-
if (publicEndpoint)
|
|
349
|
+
if (publicEndpoint || proxy.auth === "none")
|
|
243
350
|
return {
|
|
244
351
|
anonymous: true,
|
|
245
|
-
headers: publicEndpoint
|
|
352
|
+
headers: publicEndpoint?.headers ?? [],
|
|
246
353
|
isRefused: false,
|
|
247
354
|
};
|
|
248
355
|
return {
|
|
@@ -252,7 +359,11 @@ export function proxyAccess(request, safeMethods, proxy) {
|
|
|
252
359
|
};
|
|
253
360
|
}
|
|
254
361
|
|
|
255
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* Answers the 403 of a request `proxyAccess` refused.
|
|
364
|
+
*
|
|
365
|
+
* @param {import("node:http").ServerResponse} response
|
|
366
|
+
*/
|
|
256
367
|
export function refuseCrossOrigin(response) {
|
|
257
368
|
json(response, 403, CROSS_ORIGIN_ERROR);
|
|
258
369
|
}
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import { isRecord } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const VARIABLE = "DMS_PUBLIC_OPTIONS";
|
|
2
4
|
export const PUBLIC_OPTIONS_ELEMENT_ID = "dms-public-options";
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The registry the workspace was materialized with.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {object} FrontendRegistry
|
|
10
|
+
* @property {{ id: string, options?: unknown }[]} modules The modules, highest priority first
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string | undefined} raw
|
|
15
|
+
* @returns {Record<string, unknown> | undefined}
|
|
16
|
+
*/
|
|
4
17
|
function parse(raw) {
|
|
5
18
|
if (!raw) return undefined;
|
|
6
19
|
try {
|
|
20
|
+
/** @type {unknown} */
|
|
7
21
|
const parsed = JSON.parse(raw);
|
|
8
|
-
return
|
|
9
|
-
parsed !== null &&
|
|
10
|
-
!Array.isArray(parsed)
|
|
11
|
-
? parsed
|
|
12
|
-
: undefined;
|
|
22
|
+
return isRecord(parsed) && !Array.isArray(parsed) ? parsed : undefined;
|
|
13
23
|
} catch {
|
|
14
24
|
console.warn(`${VARIABLE} is not a JSON object of options by module id`);
|
|
15
25
|
return undefined;
|
|
@@ -27,7 +37,11 @@ function parse(raw) {
|
|
|
27
37
|
*/
|
|
28
38
|
export const RUNTIME_PUBLIC_OPTIONS = parse(process.env[VARIABLE]);
|
|
29
39
|
|
|
30
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The options of each module of the registry, the runtime ones first.
|
|
42
|
+
*
|
|
43
|
+
* @param {FrontendRegistry} registry
|
|
44
|
+
*/
|
|
31
45
|
export function publicOptionsOf(registry) {
|
|
32
46
|
return Object.fromEntries(
|
|
33
47
|
registry.modules.map((module) => [
|