@antelopejs/dms-frontend 0.9.2 → 0.10.0
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 +18 -24
- package/dist/commands/build-action.js +3 -2
- package/dist/commands/dev-action.js +2 -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 +3 -0
- package/dist/derived-outputs.js +3 -3
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- 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/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 +2 -1
- package/dist/locale-files.js +4 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +44 -31
- 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/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.js +13 -8
- 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 +44 -60
- package/dist/verify-source-sources.js +49 -11
- package/dist/workspace-setup.js +1 -1
- package/package.json +6 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +90 -16
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +22 -0
- 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/globals.d.ts +5 -0
- package/templates/vue/head-order.mjs +7 -0
- package/templates/vue/icon-bundle.ts +31 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/message-subset.ts +65 -0
- package/templates/vue/network-error.ts +4 -0
- package/templates/vue/server/auth/backend.mjs +41 -4
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
- package/templates/vue/server/auth/session.mjs +81 -2
- package/templates/vue/server/auth-hooks.mjs +15 -5
- package/templates/vue/server/backend-proxies.mjs +65 -9
- 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 +53 -11
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +18 -1
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email.mjs +9 -3
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +211 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +80 -7
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +61 -2
- package/templates/vue/server/module-event.mjs +98 -17
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +138 -31
- package/templates/vue/server/module-server.mjs +98 -27
- package/templates/vue/server/module-testing.mjs +14 -5
- package/templates/vue/server/page-props.mjs +92 -2
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +13 -1
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +11 -1
- package/templates/vue/server/proxy-redirects.mjs +20 -5
- package/templates/vue/server/proxy-request.mjs +57 -0
- package/templates/vue/server/proxy-transfers.mjs +33 -5
- package/templates/vue/server/public-endpoints.mjs +138 -23
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +23 -0
- package/templates/vue/server/request-routing.mjs +26 -9
- package/templates/vue/server/request-target.mjs +9 -3
- package/templates/vue/server/route-rules.mjs +39 -15
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +24 -1
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +32 -5
- package/templates/vue/server/web-body.mjs +3 -0
- package/templates/vue/server-module.ts +10 -1
- package/templates/vue/server.mjs +132 -148
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +12 -3
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-lazy-icons.mjs +44 -0
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +2 -0
|
@@ -1,6 +1,10 @@
|
|
|
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) {
|
|
5
9
|
try {
|
|
6
10
|
return new URL(url).origin;
|
|
@@ -9,7 +13,13 @@ function originOf(url) {
|
|
|
9
13
|
}
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Whether `url` is on the backend's origin.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} url
|
|
20
|
+
* @param {string} [backendBaseUrl]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
13
23
|
export function isBackendUrl(
|
|
14
24
|
url,
|
|
15
25
|
backendBaseUrl = process.env.DMS_API_BASE_URL,
|
|
@@ -34,16 +44,18 @@ export function isBackendUrl(
|
|
|
34
44
|
*
|
|
35
45
|
* `//host` would name another host, so `/.` keeps the location a path.
|
|
36
46
|
*
|
|
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
|
|
47
|
+
* @param {string} location The backend's `Location`
|
|
48
|
+
* @param {string | URL} fetchedUrl The backend URL the answer came from
|
|
49
|
+
* @param {(pathname: string) => boolean} [isProxiedPath] Whether the frontend
|
|
50
|
+
* server sends that path to the backend
|
|
51
|
+
* @returns {string}
|
|
41
52
|
*/
|
|
42
53
|
export function relayedLocation(
|
|
43
54
|
location,
|
|
44
55
|
fetchedUrl,
|
|
45
56
|
isProxiedPath = () => false,
|
|
46
57
|
) {
|
|
58
|
+
/** @type {URL} */
|
|
47
59
|
let target;
|
|
48
60
|
try {
|
|
49
61
|
target = new URL(location, fetchedUrl);
|
|
@@ -60,6 +72,9 @@ export function relayedLocation(
|
|
|
60
72
|
* The cookies of a `Response` a module route returns. A fetch that followed a
|
|
61
73
|
* redirect to another site would set that site's cookies on the frontend's
|
|
62
74
|
* origin: those are dropped.
|
|
75
|
+
*
|
|
76
|
+
* @param {Response} answer
|
|
77
|
+
* @returns {string[]}
|
|
63
78
|
*/
|
|
64
79
|
export function relayableCookies(answer) {
|
|
65
80
|
if (answer.redirected && !isBackendUrl(answer.url)) return [];
|
|
@@ -0,0 +1,57 @@
|
|
|
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("node:http").IncomingMessage} 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
|
+
const init = {
|
|
40
|
+
signal: abandoned.signal,
|
|
41
|
+
method: request.method,
|
|
42
|
+
headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
|
|
43
|
+
duplex: "half",
|
|
44
|
+
redirect: "manual",
|
|
45
|
+
};
|
|
46
|
+
const upstream = await sendWithinLimit(
|
|
47
|
+
request,
|
|
48
|
+
response,
|
|
49
|
+
backendProxy.maxBodyBytes,
|
|
50
|
+
(body) => fetch(backendUrl(request.url), { ...init, body }),
|
|
51
|
+
);
|
|
52
|
+
if (!upstream) return true;
|
|
53
|
+
await relayAnswer(request, response, upstream, {
|
|
54
|
+
fetched: true,
|
|
55
|
+
isProxiedPath,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -26,6 +26,11 @@ export const SERVER_OPTIONS = Object.freeze({
|
|
|
26
26
|
|
|
27
27
|
class BodyTooLargeError extends Error {}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @param {import("node:http").IncomingMessage} request
|
|
31
|
+
* @param {number} maxBodyBytes
|
|
32
|
+
* @returns {AsyncGenerator<Buffer>}
|
|
33
|
+
*/
|
|
29
34
|
async function* limitedChunks(request, maxBodyBytes) {
|
|
30
35
|
let received = 0;
|
|
31
36
|
for await (const chunk of request.iterator(BODY_ITERATION)) {
|
|
@@ -35,6 +40,10 @@ async function* limitedChunks(request, maxBodyBytes) {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {import("node:http").IncomingMessage} request
|
|
45
|
+
* @param {number | undefined} maxBodyBytes
|
|
46
|
+
*/
|
|
38
47
|
function requestBody(request, maxBodyBytes) {
|
|
39
48
|
if (BODILESS_METHODS.has(request.method)) return undefined;
|
|
40
49
|
return maxBodyBytes === undefined
|
|
@@ -42,6 +51,11 @@ function requestBody(request, maxBodyBytes) {
|
|
|
42
51
|
: limitedChunks(request, maxBodyBytes);
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* @param {import("node:http").IncomingMessage} request
|
|
56
|
+
* @param {number | undefined} maxBodyBytes
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
45
59
|
function declaresTooLargeBody(request, maxBodyBytes) {
|
|
46
60
|
return (
|
|
47
61
|
maxBodyBytes !== undefined &&
|
|
@@ -49,6 +63,10 @@ function declaresTooLargeBody(request, maxBodyBytes) {
|
|
|
49
63
|
);
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} error
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
52
70
|
function isBodyTooLarge(error) {
|
|
53
71
|
return (
|
|
54
72
|
error instanceof BodyTooLargeError ||
|
|
@@ -65,6 +83,9 @@ function isBodyTooLarge(error) {
|
|
|
65
83
|
* the browser's upload failing without a reason. The rest of the body is read
|
|
66
84
|
* and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
|
|
67
85
|
* at most; the response ends with it and the connection then closes cleanly.
|
|
86
|
+
*
|
|
87
|
+
* @param {import("node:http").IncomingMessage} request
|
|
88
|
+
* @param {import("node:http").ServerResponse} response
|
|
68
89
|
*/
|
|
69
90
|
function refuseTooLargeBody(request, response) {
|
|
70
91
|
response.writeHead(PAYLOAD_TOO_LARGE, {
|
|
@@ -96,11 +117,18 @@ function refuseTooLargeBody(request, response) {
|
|
|
96
117
|
* before a byte is passed on, a chunked body is cut off as soon as it goes
|
|
97
118
|
* over.
|
|
98
119
|
*
|
|
99
|
-
* @param
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* @
|
|
103
|
-
*
|
|
120
|
+
* @param {import("node:http").IncomingMessage} request
|
|
121
|
+
* @param {import("node:http").ServerResponse} response
|
|
122
|
+
* @param {number | undefined} maxBodyBytes
|
|
123
|
+
* @param {(
|
|
124
|
+
* body: import("node:http").IncomingMessage | AsyncGenerator<Buffer> | undefined
|
|
125
|
+
* ) => Promise<Response>} send The backend request with the body to stream:
|
|
126
|
+
* none for a GET or a HEAD, the request itself, or its bytes cut off past
|
|
127
|
+
* the limit
|
|
128
|
+
* @returns {Promise<Response | import("node:http").ServerResponse | undefined>}
|
|
129
|
+
* The backend's answer, or once the body went over and `response` got the
|
|
130
|
+
* 413: `undefined`, or `response` itself when the request had been read
|
|
131
|
+
* already (`response.end()`'s own return)
|
|
104
132
|
*/
|
|
105
133
|
export async function sendWithinLimit(request, response, maxBodyBytes, send) {
|
|
106
134
|
if (declaresTooLargeBody(request, maxBodyBytes))
|
|
@@ -1,6 +1,33 @@
|
|
|
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 {
|
|
6
|
+
isNonNullObject,
|
|
7
|
+
isNumber,
|
|
8
|
+
isObjectLike,
|
|
9
|
+
isRecord,
|
|
10
|
+
isString,
|
|
11
|
+
} from "./type-guards.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A public endpoint, compiled from a declaration that passed every check.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} PublicEndpoint
|
|
17
|
+
* @property {string} path
|
|
18
|
+
* @property {string[]} methods
|
|
19
|
+
* @property {string[]} headers
|
|
20
|
+
* @property {RegExp} matcher
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A declaration as the manifest or the environment holds it, before any check.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {object} PublicEndpointDeclaration
|
|
27
|
+
* @property {unknown} [path]
|
|
28
|
+
* @property {unknown} [methods]
|
|
29
|
+
* @property {unknown} [headers]
|
|
30
|
+
*/
|
|
4
31
|
|
|
5
32
|
/**
|
|
6
33
|
* Written at the workspace root by the loader, from the endpoints each
|
|
@@ -41,39 +68,101 @@ const HEADER_NAME = /^x-[a-z0-9-]+$/;
|
|
|
41
68
|
const RESERVED_HEADER =
|
|
42
69
|
/^x-(?:forwarded-|real-ip$|dms-|inertia|http-method|method-override|original-|rewrite-)/;
|
|
43
70
|
|
|
71
|
+
/** @type {unknown[] | undefined} */
|
|
44
72
|
let declaredEndpoints;
|
|
73
|
+
/** @type {import("./backend-proxies.mjs").ManifestProxy[] | undefined} */
|
|
45
74
|
let declaredProxies;
|
|
46
75
|
|
|
47
76
|
/**
|
|
48
77
|
* The public endpoints the modules of this deployment declared, unchecked.
|
|
49
78
|
*
|
|
50
|
-
* @param file Generated file to read
|
|
51
|
-
* @returns The declared entries, or none when the file is absent or broken
|
|
79
|
+
* @param {URL | string} [file] Generated file to read
|
|
80
|
+
* @returns {unknown[]} The declared entries, or none when the file is absent or broken
|
|
52
81
|
*/
|
|
53
82
|
export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
54
83
|
try {
|
|
84
|
+
/** @type {unknown} */
|
|
55
85
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
-
|
|
86
|
+
const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
|
|
87
|
+
return Array.isArray(endpoints) ? endpoints : [];
|
|
57
88
|
} catch {
|
|
58
89
|
return [];
|
|
59
90
|
}
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Whether `entry` names a path and, when it says so, an auth mode and a body
|
|
95
|
+
* limit of the right type. The path pattern and the limit's range are judged
|
|
96
|
+
* when the proxies are resolved.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} entry
|
|
99
|
+
* @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number }}
|
|
100
|
+
*/
|
|
101
|
+
function isProxyDeclaration(entry) {
|
|
102
|
+
return (
|
|
103
|
+
isRecord(entry) &&
|
|
104
|
+
!Array.isArray(entry) &&
|
|
105
|
+
isString(entry.path) &&
|
|
106
|
+
(entry.auth === undefined ||
|
|
107
|
+
PROXY_AUTH_MODES.some((mode) => mode === entry.auth)) &&
|
|
108
|
+
(entry.maxBodyBytes === undefined || isNumber(entry.maxBodyBytes))
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {unknown[]} entries
|
|
114
|
+
* @param {string} source Where they were declared, for the warning
|
|
115
|
+
* @param {(entry: unknown) => boolean} isUsable
|
|
116
|
+
*/
|
|
117
|
+
function warnUnusableProxies(entries, source, isUsable) {
|
|
118
|
+
const unusable = entries.filter((entry) => !isUsable(entry)).length;
|
|
119
|
+
if (unusable)
|
|
120
|
+
console.warn(
|
|
121
|
+
`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)`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {unknown} entry
|
|
127
|
+
* @returns {entry is import("./backend-proxies.mjs").ManifestProxy}
|
|
128
|
+
*/
|
|
129
|
+
function isDeclaredProxy(entry) {
|
|
130
|
+
return (
|
|
131
|
+
isRecord(entry) &&
|
|
132
|
+
isProxyDeclaration(entry) &&
|
|
133
|
+
(entry.module === undefined || isString(entry.module))
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
62
137
|
/**
|
|
63
138
|
* The backend proxies the backend modules declared in the manifest, as
|
|
64
|
-
* `{ module, path, auth?, maxBodyBytes? }
|
|
139
|
+
* `{ module, path, auth?, maxBodyBytes? }`. An entry that is not one is
|
|
140
|
+
* reported and left out.
|
|
65
141
|
*
|
|
66
142
|
* A missing or broken file declares no proxy.
|
|
67
143
|
*
|
|
68
|
-
* @param file Generated file to read
|
|
144
|
+
* @param {URL | string} [file] Generated file to read
|
|
145
|
+
* @returns {import("./backend-proxies.mjs").ManifestProxy[]}
|
|
69
146
|
*/
|
|
70
147
|
export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
|
|
71
148
|
if (file === DECLARED_ENDPOINTS_FILE && declaredProxies)
|
|
72
149
|
return declaredProxies;
|
|
150
|
+
/** @type {import("./backend-proxies.mjs").ManifestProxy[]} */
|
|
73
151
|
let proxies = [];
|
|
74
152
|
try {
|
|
153
|
+
/** @type {unknown} */
|
|
75
154
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
76
|
-
|
|
155
|
+
const listed = isObjectLike(parsed) ? parsed.proxies : undefined;
|
|
156
|
+
if (Array.isArray(listed)) {
|
|
157
|
+
/** @type {unknown[]} */
|
|
158
|
+
const entries = listed;
|
|
159
|
+
warnUnusableProxies(
|
|
160
|
+
entries,
|
|
161
|
+
"the generated manifest file",
|
|
162
|
+
isDeclaredProxy,
|
|
163
|
+
);
|
|
164
|
+
proxies = entries.filter(isDeclaredProxy);
|
|
165
|
+
}
|
|
77
166
|
} catch {}
|
|
78
167
|
if (file === DECLARED_ENDPOINTS_FILE) declaredProxies = proxies;
|
|
79
168
|
return proxies;
|
|
@@ -82,29 +171,40 @@ export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
|
|
|
82
171
|
/**
|
|
83
172
|
* The backend proxies `DMS_BACKEND_PROXIES` adds: a JSON array of
|
|
84
173
|
* `{ path, auth?, maxBodyBytes? }`, as the manifest's `backendProxies`, for an
|
|
85
|
-
* operator. A value that is not a JSON array is reported and ignored
|
|
174
|
+
* operator. A value that is not a JSON array is reported and ignored, and so
|
|
175
|
+
* is each entry that is not a declaration.
|
|
86
176
|
*
|
|
87
|
-
* @param declaration The variable's value
|
|
177
|
+
* @param {string} [declaration] The variable's value
|
|
178
|
+
* @returns {import("./backend-proxies.mjs").ManifestProxy[]}
|
|
88
179
|
*/
|
|
89
180
|
export function environmentBackendProxies(
|
|
90
181
|
declaration = process.env.DMS_BACKEND_PROXIES,
|
|
91
182
|
) {
|
|
92
183
|
if (!declaration?.trim()) return [];
|
|
93
184
|
try {
|
|
185
|
+
/** @type {unknown} */
|
|
94
186
|
const parsed = JSON.parse(declaration);
|
|
95
|
-
if (Array.isArray(parsed))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
187
|
+
if (Array.isArray(parsed)) {
|
|
188
|
+
/** @type {unknown[]} */
|
|
189
|
+
const entries = parsed;
|
|
190
|
+
warnUnusableProxies(entries, "DMS_BACKEND_PROXIES", isProxyDeclaration);
|
|
191
|
+
return entries
|
|
192
|
+
.filter(isProxyDeclaration)
|
|
193
|
+
.map((entry) => ({ ...entry, module: "DMS_BACKEND_PROXIES" }));
|
|
194
|
+
}
|
|
100
195
|
} catch {}
|
|
101
196
|
console.warn("Ignoring DMS_BACKEND_PROXIES: not a JSON array");
|
|
102
197
|
return [];
|
|
103
198
|
}
|
|
104
199
|
|
|
200
|
+
/**
|
|
201
|
+
* @param {string | undefined} declaration
|
|
202
|
+
* @returns {unknown[]}
|
|
203
|
+
*/
|
|
105
204
|
function environmentEndpoints(declaration) {
|
|
106
205
|
if (!declaration?.trim()) return [];
|
|
107
206
|
try {
|
|
207
|
+
/** @type {unknown} */
|
|
108
208
|
const parsed = JSON.parse(declaration);
|
|
109
209
|
return Array.isArray(parsed) ? parsed : [];
|
|
110
210
|
} catch {
|
|
@@ -113,6 +213,7 @@ function environmentEndpoints(declaration) {
|
|
|
113
213
|
}
|
|
114
214
|
}
|
|
115
215
|
|
|
216
|
+
/** @param {unknown} methods */
|
|
116
217
|
function normalizedMethods(methods) {
|
|
117
218
|
if (methods === undefined) return DEFAULT_METHODS;
|
|
118
219
|
if (!Array.isArray(methods) || methods.length === 0) return undefined;
|
|
@@ -122,6 +223,7 @@ function normalizedMethods(methods) {
|
|
|
122
223
|
: undefined;
|
|
123
224
|
}
|
|
124
225
|
|
|
226
|
+
/** @param {unknown} headers */
|
|
125
227
|
function normalizedHeaders(headers) {
|
|
126
228
|
if (headers === undefined) return [];
|
|
127
229
|
if (!Array.isArray(headers)) return undefined;
|
|
@@ -133,6 +235,7 @@ function normalizedHeaders(headers) {
|
|
|
133
235
|
: undefined;
|
|
134
236
|
}
|
|
135
237
|
|
|
238
|
+
/** @param {string} path */
|
|
136
239
|
function pathMatcher(path) {
|
|
137
240
|
const pattern = path
|
|
138
241
|
.split("/")
|
|
@@ -148,11 +251,13 @@ function pathMatcher(path) {
|
|
|
148
251
|
/**
|
|
149
252
|
* A declaration made usable, or `undefined` when any part of it is malformed:
|
|
150
253
|
* a bad entry is dropped whole, never widened to what it might have meant.
|
|
254
|
+
*
|
|
255
|
+
* @param {PublicEndpointDeclaration | null | undefined} entry
|
|
256
|
+
* @returns {PublicEndpoint | undefined}
|
|
151
257
|
*/
|
|
152
258
|
export function compilePublicEndpoint(entry) {
|
|
153
|
-
if (
|
|
154
|
-
if (
|
|
155
|
-
return undefined;
|
|
259
|
+
if (!isNonNullObject(entry)) return undefined;
|
|
260
|
+
if (!isString(entry.path) || !PUBLIC_PATH.test(entry.path)) return undefined;
|
|
156
261
|
const methods = normalizedMethods(entry.methods);
|
|
157
262
|
const headers = normalizedHeaders(entry.headers);
|
|
158
263
|
if (!methods || !headers) return undefined;
|
|
@@ -168,6 +273,10 @@ export function compilePublicEndpoint(entry) {
|
|
|
168
273
|
* The backend endpoints this deployment serves to anyone: what the backend's
|
|
169
274
|
* modules declared, plus what `DMS_PUBLIC_ENDPOINTS` adds (a JSON array of the
|
|
170
275
|
* same `{ path, methods?, headers? }` entries).
|
|
276
|
+
*
|
|
277
|
+
* @param {string} [declaration]
|
|
278
|
+
* @param {unknown[]} [declared]
|
|
279
|
+
* @returns {PublicEndpoint[]}
|
|
171
280
|
*/
|
|
172
281
|
export function allowedPublicEndpoints(
|
|
173
282
|
declaration = process.env.DMS_PUBLIC_ENDPOINTS,
|
|
@@ -189,6 +298,8 @@ export function allowedPublicEndpoints(
|
|
|
189
298
|
/**
|
|
190
299
|
* Compiled once per value of the environment variable, so a malformed entry
|
|
191
300
|
* is reported once rather than on every request.
|
|
301
|
+
*
|
|
302
|
+
* @type {{ declaration: string | undefined, endpoints: PublicEndpoint[] } | undefined}
|
|
192
303
|
*/
|
|
193
304
|
let compiled;
|
|
194
305
|
|
|
@@ -202,9 +313,9 @@ function currentPublicEndpoints() {
|
|
|
202
313
|
/**
|
|
203
314
|
* The public endpoint a request reaches, if any.
|
|
204
315
|
*
|
|
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
|
|
316
|
+
* @param {string} method The request's method
|
|
317
|
+
* @param {string} pathname The request's normalized path, without its query string
|
|
318
|
+
* @param {PublicEndpoint[]} [endpoints] The endpoints to match, this deployment's by default
|
|
208
319
|
*/
|
|
209
320
|
export function matchPublicEndpoint(
|
|
210
321
|
method,
|
|
@@ -227,9 +338,9 @@ export function matchPublicEndpoint(
|
|
|
227
338
|
* it reaches a public endpoint, which is why the proxy never sends a public
|
|
228
339
|
* endpoint the session: a cross-site form posting to it acts as nobody.
|
|
229
340
|
*
|
|
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
|
|
341
|
+
* @param {import("node:http").IncomingMessage} request The incoming request
|
|
342
|
+
* @param {ReadonlySet<string>} safeMethods Methods that change nothing and need no Origin
|
|
343
|
+
* @param {import("./backend-proxies.mjs").BackendProxy} proxy The backend proxy the request reached
|
|
233
344
|
* @returns `{ anonymous, headers, isRefused }`
|
|
234
345
|
*/
|
|
235
346
|
export function proxyAccess(request, safeMethods, proxy) {
|
|
@@ -252,7 +363,11 @@ export function proxyAccess(request, safeMethods, proxy) {
|
|
|
252
363
|
};
|
|
253
364
|
}
|
|
254
365
|
|
|
255
|
-
/**
|
|
366
|
+
/**
|
|
367
|
+
* Answers the 403 of a request `proxyAccess` refused.
|
|
368
|
+
*
|
|
369
|
+
* @param {import("node:http").ServerResponse} response
|
|
370
|
+
*/
|
|
256
371
|
export function refuseCrossOrigin(response) {
|
|
257
372
|
json(response, 403, CROSS_ORIGIN_ERROR);
|
|
258
373
|
}
|
|
@@ -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) => [
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { isNumber, isObjectLike } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
2
4
|
const TEXT_TYPE = "text/plain; charset=utf-8";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Whether the path of `url` holds a percent-escape no decoder accepts, such as
|
|
6
8
|
* `/projects/%E0%A4%A`. Vite and the router decode the path, and throw on it.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} url
|
|
7
11
|
*/
|
|
8
12
|
export function hasMalformedPath(url) {
|
|
9
13
|
try {
|
|
@@ -14,9 +18,23 @@ export function hasMalformedPath(url) {
|
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The status an error asks to be answered with: its integer `status`, else 502.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} error
|
|
25
|
+
* @returns {number}
|
|
26
|
+
*/
|
|
27
|
+
export function failureStatus(error) {
|
|
28
|
+
const status = isObjectLike(error) ? error.status : undefined;
|
|
29
|
+
return isNumber(status) && Number.isInteger(status) ? status : 502;
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* Whether a failed request can no longer be answered: the client went away, or
|
|
19
34
|
* the response already started.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} error
|
|
37
|
+
* @param {import("node:http").ServerResponse} response
|
|
20
38
|
*/
|
|
21
39
|
export function isResponseGone(error, response) {
|
|
22
40
|
return (
|
|
@@ -30,6 +48,8 @@ export function isResponseGone(error, response) {
|
|
|
30
48
|
/**
|
|
31
49
|
* Answers for requests the server cannot serve at all: an unreadable URL, or
|
|
32
50
|
* a failure while it was already rendering the error page.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:http").ServerResponse} response
|
|
33
53
|
*/
|
|
34
54
|
export function writeBadRequest(response) {
|
|
35
55
|
response.writeHead(400, { "content-type": TEXT_TYPE });
|
|
@@ -40,6 +60,9 @@ export function writeBadRequest(response) {
|
|
|
40
60
|
* The last resort once even the error page failed: a bare 500, or a dropped
|
|
41
61
|
* connection when the response already started. The server keeps running
|
|
42
62
|
* either way.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} error
|
|
65
|
+
* @param {import("node:http").ServerResponse} response
|
|
43
66
|
*/
|
|
44
67
|
export function abandonResponse(error, response) {
|
|
45
68
|
console.error("DMS error response failed", error);
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { matchBackendProxy } from "./backend-proxies.mjs";
|
|
2
2
|
import { isRendererFilePath, moduleRouteMethods } from "./module-routes.mjs";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} RoutingModules
|
|
6
|
+
* @property {import("./module-route-types.mjs").ServerRoute[]} routes
|
|
7
|
+
* @property {import("./backend-proxies.mjs").BackendProxy[]} [proxies]
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ kind: "renderer" }
|
|
11
|
+
* | { kind: "module", id: string }
|
|
12
|
+
* | { kind: "backend", proxy: import("./backend-proxies.mjs").BackendProxy }
|
|
13
|
+
* | { kind: "page" }
|
|
14
|
+
* | { kind: "not-found" }} RequestOwner
|
|
15
|
+
*/
|
|
16
|
+
|
|
4
17
|
const PAGE_METHODS = new Set(["GET", "HEAD"]);
|
|
5
18
|
|
|
6
19
|
/**
|
|
@@ -14,12 +27,12 @@ const PAGE_METHODS = new Set(["GET", "HEAD"]);
|
|
|
14
27
|
* `backendProxies`); 4. the pages, for everything else: a method other than
|
|
15
28
|
* `GET` and `HEAD` on a page path is a `404`.
|
|
16
29
|
*
|
|
17
|
-
* @param method The request's method
|
|
18
|
-
* @param pathname The request's normalized path
|
|
19
|
-
* @param modules What `loadServerModules` returned: `routes` and `proxies`
|
|
20
|
-
* @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
30
|
+
* @param {string} method The request's method
|
|
31
|
+
* @param {string} pathname The request's normalized path
|
|
32
|
+
* @param {RoutingModules} modules What `loadServerModules` returned: `routes` and `proxies`
|
|
33
|
+
* @param {(pathname: string) => boolean} hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
21
34
|
* file at that path
|
|
22
|
-
* @returns `{ kind: "renderer" }`, `{ kind: "module", id }`,
|
|
35
|
+
* @returns {RequestOwner} `{ kind: "renderer" }`, `{ kind: "module", id }`,
|
|
23
36
|
* `{ kind: "backend", proxy }`, `{ kind: "page" }` or
|
|
24
37
|
* `{ kind: "not-found" }`
|
|
25
38
|
*/
|
|
@@ -41,9 +54,9 @@ export function requestOwner(method, pathname, modules, hasRendererFile) {
|
|
|
41
54
|
* `Location` on such a path can become a frontend path (see
|
|
42
55
|
* `proxy-redirects.mjs`).
|
|
43
56
|
*
|
|
44
|
-
* @param pathname A normalized path
|
|
45
|
-
* @param modules What `loadServerModules` returned: `routes` and `proxies`
|
|
46
|
-
* @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
57
|
+
* @param {string} pathname A normalized path
|
|
58
|
+
* @param {RoutingModules} modules What `loadServerModules` returned: `routes` and `proxies`
|
|
59
|
+
* @param {(pathname: string) => boolean} [hasRendererFile] `(pathname) => boolean`, whether the renderer has a
|
|
47
60
|
* file at that path
|
|
48
61
|
*/
|
|
49
62
|
export function reachesBackend(
|
|
@@ -56,7 +69,11 @@ export function reachesBackend(
|
|
|
56
69
|
);
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The `x-dms-route-owner` value of an owner, for the development server.
|
|
74
|
+
*
|
|
75
|
+
* @param {RequestOwner} owner
|
|
76
|
+
*/
|
|
60
77
|
export function ownerLabel(owner) {
|
|
61
78
|
if (owner.kind === "module") return `module:${owner.id}`;
|
|
62
79
|
if (owner.kind === "backend") return `backend:${owner.proxy.path}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeBadRequest } from "./request-failure.mjs";
|
|
2
|
+
import { isString } from "./type-guards.mjs";
|
|
2
3
|
|
|
3
4
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
4
5
|
|
|
@@ -7,9 +8,11 @@ const FRONTEND_ORIGIN = "http://frontend.local";
|
|
|
7
8
|
* query, that stays on this server once resolved. Anything else (an absolute
|
|
8
9
|
* or authority form, `*`, a path a URL parser reads as another host) is not a
|
|
9
10
|
* request a browser sends to the frontend.
|
|
11
|
+
*
|
|
12
|
+
* @param {string | undefined} target
|
|
10
13
|
*/
|
|
11
14
|
export function isOriginForm(target) {
|
|
12
|
-
if (
|
|
15
|
+
if (!isString(target) || !target.startsWith("/")) return false;
|
|
13
16
|
try {
|
|
14
17
|
return new URL(target, FRONTEND_ORIGIN).origin === FRONTEND_ORIGIN;
|
|
15
18
|
} catch {
|
|
@@ -22,8 +25,8 @@ export function isOriginForm(target) {
|
|
|
22
25
|
* and query on the backend's origin. The path is set, not resolved, so a path
|
|
23
26
|
* normalizing to `//host` (`/x/..//host`) stays a path on the backend.
|
|
24
27
|
*
|
|
25
|
-
* @param target The request target, origin-form
|
|
26
|
-
* @param backendBaseUrl The backend's base URL
|
|
28
|
+
* @param {string} target The request target, origin-form
|
|
29
|
+
* @param {string} [backendBaseUrl] The backend's base URL
|
|
27
30
|
*/
|
|
28
31
|
export function backendUrl(
|
|
29
32
|
target,
|
|
@@ -45,6 +48,9 @@ export function backendUrl(
|
|
|
45
48
|
* form `http://host/x` all name another host once resolved, which turned the
|
|
46
49
|
* proxy into an open one. Every routing decision and the URL sent to the
|
|
47
50
|
* backend read the same normalized path from here.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:http").IncomingMessage} request
|
|
53
|
+
* @param {import("node:http").ServerResponse} response
|
|
48
54
|
*/
|
|
49
55
|
export function refuseForeignTarget(request, response) {
|
|
50
56
|
if (isOriginForm(request.url)) return false;
|