@antelopejs/dms-frontend 0.10.0 → 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 +34 -11
- package/dist/commands/build-action.d.ts +3 -0
- package/dist/commands/build-action.js +74 -6
- 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 +2 -2
- package/dist/commands/dev.js +1 -1
- package/dist/config.js +1 -0
- package/dist/derived-outputs.d.ts +1 -0
- package/dist/derived-outputs.js +3 -1
- package/dist/help.js +1 -1
- package/dist/internal/prepared-workspace.d.ts +12 -0
- package/dist/internal/prepared-workspace.js +74 -0
- package/dist/layer-watch.js +1 -1
- package/dist/locale-files.d.ts +4 -2
- package/dist/locale-files.js +11 -9
- package/dist/manifest.js +2 -1
- package/dist/ports.d.ts +3 -1
- package/dist/ports.js +1 -1
- package/dist/project-modules.d.ts +5 -1
- package/dist/project-modules.js +10 -7
- package/dist/verify-source-runner.js +2 -44
- 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 +3 -2
- package/package.json +7 -5
- package/templates/vue/backend-boundary.mjs +132 -0
- package/templates/vue/client-chunking.mjs +2 -0
- package/templates/vue/entry-weight.mjs +9 -5
- package/templates/vue/frontend-build.ts +4 -3
- package/templates/vue/head-order.mjs +3 -2
- package/templates/vue/icon-api.mjs +2 -0
- package/templates/vue/server/auth/backend.mjs +4 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +2 -2
- package/templates/vue/server/auth/session.mjs +5 -2
- package/templates/vue/server/auth-declarations.mjs +46 -0
- package/templates/vue/server/auth-hooks.mjs +41 -3
- package/templates/vue/server/backend-proxies.mjs +16 -12
- package/templates/vue/server/backend-response.mjs +3 -3
- package/templates/vue/server/cli-report.mjs +22 -1
- package/templates/vue/server/client-error.mjs +5 -0
- package/templates/vue/server/content.mjs +24 -11
- package/templates/vue/server/email-bundle-watch.mjs +9 -3
- package/templates/vue/server/email.mjs +5 -3
- package/templates/vue/server/generated-files.mjs +16 -6
- package/templates/vue/server/icons.mjs +15 -11
- package/templates/vue/server/in-place-render.mjs +3 -3
- package/templates/vue/server/inertia.mjs +17 -3
- package/templates/vue/server/module-event.mjs +28 -16
- package/templates/vue/server/module-route-types.mjs +2 -2
- package/templates/vue/server/module-routes.mjs +24 -76
- package/templates/vue/server/module-server.mjs +27 -17
- package/templates/vue/server/module-testing.mjs +23 -8
- package/templates/vue/server/page-props.mjs +6 -5
- package/templates/vue/server/page-redirects.mjs +1 -1
- package/templates/vue/server/path-patterns.mjs +22 -11
- package/templates/vue/server/proxy-headers.mjs +1 -1
- package/templates/vue/server/proxy-redirects.mjs +1 -0
- package/templates/vue/server/proxy-request.mjs +2 -1
- package/templates/vue/server/proxy-transfers.mjs +17 -11
- package/templates/vue/server/public-endpoints.mjs +12 -16
- package/templates/vue/server/request-failure.mjs +1 -1
- package/templates/vue/server/request-routing.mjs +6 -1
- package/templates/vue/server/request-target.mjs +22 -9
- package/templates/vue/server/route-rules.mjs +12 -6
- package/templates/vue/server/static-files.mjs +12 -7
- package/templates/vue/server/vite-errors.mjs +13 -2
- package/templates/vue/server/web-body.mjs +2 -1
- package/templates/vue/server-module.ts +2 -1
- package/templates/vue/server.mjs +41 -20
- package/templates/vue/ui-inertia-page.mjs +1 -1
- package/templates/vue/vite.config.ts +9 -0
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
|
|
4
4
|
import { serverAuth } from "./auth-hooks.mjs";
|
|
5
5
|
import { answerModuleRoute } from "./module-event.mjs";
|
|
6
|
-
import { isNonNullObject, isString } from "./type-guards.mjs";
|
|
6
|
+
import { isNonNullObject, isRecord, isString } from "./type-guards.mjs";
|
|
7
7
|
import {
|
|
8
8
|
matchModuleRoute,
|
|
9
9
|
mayHaveModuleRoute,
|
|
@@ -81,19 +81,24 @@ function readJsonFile(url, fallback) {
|
|
|
81
81
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* The object holding `path`'s last key, when every key up to it
|
|
84
|
+
* The object holding `path`'s last key, and that key, when every key up to it
|
|
85
|
+
* is own.
|
|
85
86
|
*
|
|
86
87
|
* @param {Record<string, unknown>} target
|
|
87
88
|
* @param {string[]} path
|
|
89
|
+
* @returns {{ holder: Record<string, unknown>, key: string } | undefined}
|
|
88
90
|
*/
|
|
89
|
-
function
|
|
91
|
+
function slotOf(target, path) {
|
|
92
|
+
const key = path.at(-1);
|
|
93
|
+
if (key === undefined || UNSAFE_KEYS.has(key)) return undefined;
|
|
90
94
|
let node = target;
|
|
91
|
-
for (const
|
|
92
|
-
if (UNSAFE_KEYS.has(
|
|
93
|
-
|
|
94
|
-
if (!
|
|
95
|
+
for (const step of path.slice(0, -1)) {
|
|
96
|
+
if (UNSAFE_KEYS.has(step) || !Object.hasOwn(node, step)) return undefined;
|
|
97
|
+
const next = node[step];
|
|
98
|
+
if (!isRecord(next)) return undefined;
|
|
99
|
+
node = next;
|
|
95
100
|
}
|
|
96
|
-
return
|
|
101
|
+
return { holder: node, key };
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
/**
|
|
@@ -104,10 +109,12 @@ function holderOf(target, path) {
|
|
|
104
109
|
* @param {string} value
|
|
105
110
|
*/
|
|
106
111
|
function fillPath(target, path, value) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
holder[key]
|
|
112
|
+
const slot = slotOf(target, path);
|
|
113
|
+
if (
|
|
114
|
+
slot &&
|
|
115
|
+
(slot.holder[slot.key] === undefined || slot.holder[slot.key] === "")
|
|
116
|
+
)
|
|
117
|
+
slot.holder[slot.key] = value;
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/**
|
|
@@ -118,10 +125,13 @@ function fillPath(target, path, value) {
|
|
|
118
125
|
* @param {string} value
|
|
119
126
|
*/
|
|
120
127
|
function fillEmptied(target, path, value) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
holder
|
|
128
|
+
const slot = slotOf(target, path);
|
|
129
|
+
if (
|
|
130
|
+
slot &&
|
|
131
|
+
Object.hasOwn(slot.holder, slot.key) &&
|
|
132
|
+
slot.holder[slot.key] === ""
|
|
133
|
+
)
|
|
134
|
+
slot.holder[slot.key] = value;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/**
|
|
@@ -327,7 +337,7 @@ export async function loadServerModules(devServer) {
|
|
|
327
337
|
* module route serves is the module's for every method: another method is
|
|
328
338
|
* answered `405`.
|
|
329
339
|
*
|
|
330
|
-
* @param {import("
|
|
340
|
+
* @param {import("./request-target.mjs").ServerRequest} request The Node request
|
|
331
341
|
* @param {import("node:http").ServerResponse} response The Node response
|
|
332
342
|
* @param {string} pathname The request's normalized path
|
|
333
343
|
* @param {ServerModules} modules What `loadServerModules` or `createServerModules` returned
|
|
@@ -2,7 +2,9 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
|
|
3
3
|
import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
|
|
4
4
|
import { createServerModules, handleModuleRoute } from "./module-server.mjs";
|
|
5
|
-
import { failureStatus } from "./request-failure.mjs";
|
|
5
|
+
import { failureStatus, writeBadRequest } from "./request-failure.mjs";
|
|
6
|
+
import { isServerRequest } from "./request-target.mjs";
|
|
7
|
+
import { isString } from "./type-guards.mjs";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* What the frontend server answers an API request whose handler failed.
|
|
@@ -52,6 +54,10 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
|
52
54
|
warn: (message) => warnings.push(message),
|
|
53
55
|
});
|
|
54
56
|
const server = createServer((request, response) => {
|
|
57
|
+
if (!isServerRequest(request)) {
|
|
58
|
+
writeBadRequest(response);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
55
61
|
const { pathname } = new URL(request.url, "http://frontend.local");
|
|
56
62
|
handleModuleRoute(request, response, pathname, modules)
|
|
57
63
|
.then((answered) => {
|
|
@@ -61,16 +67,25 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
|
61
67
|
/** @param {unknown} error */ (error) => writeFailure(error, response),
|
|
62
68
|
);
|
|
63
69
|
});
|
|
64
|
-
|
|
65
|
-
const
|
|
70
|
+
/** @type {Promise<void>} */
|
|
71
|
+
const listening = new Promise((settle) =>
|
|
72
|
+
server.listen(0, "127.0.0.1", settle),
|
|
73
|
+
);
|
|
74
|
+
await listening;
|
|
75
|
+
const address = server.address();
|
|
76
|
+
if (address === null || isString(address))
|
|
77
|
+
throw new TypeError("The module test server is not listening on a port");
|
|
78
|
+
const { port } = address;
|
|
79
|
+
/** @returns {Promise<void>} */
|
|
80
|
+
const close = () =>
|
|
81
|
+
new Promise((settle) => {
|
|
82
|
+
server.closeAllConnections();
|
|
83
|
+
server.close(() => settle());
|
|
84
|
+
});
|
|
66
85
|
return {
|
|
67
86
|
url: `http://127.0.0.1:${port}`,
|
|
68
87
|
modules,
|
|
69
88
|
warnings,
|
|
70
|
-
close
|
|
71
|
-
new Promise((settle) => {
|
|
72
|
-
server.closeAllConnections();
|
|
73
|
-
server.close(() => settle());
|
|
74
|
-
}),
|
|
89
|
+
close,
|
|
75
90
|
};
|
|
76
91
|
}
|
|
@@ -46,7 +46,7 @@ const NOT_FOUND = 404;
|
|
|
46
46
|
*
|
|
47
47
|
* @typedef {{
|
|
48
48
|
* signInPath: string,
|
|
49
|
-
* view: (session:
|
|
49
|
+
* view: (session: import("./auth/session.mjs").Session | undefined) => { user?: unknown, session?: unknown }
|
|
50
50
|
* }} PageAuth
|
|
51
51
|
*/
|
|
52
52
|
|
|
@@ -66,8 +66,9 @@ export function pageRoute(props) {
|
|
|
66
66
|
* browser holding the layout of another account or workspace, after a sign-in,
|
|
67
67
|
* a sign-out or a switch, holds another key, so it is sent the right one.
|
|
68
68
|
*
|
|
69
|
-
* @param {
|
|
70
|
-
* `accountId` and `activeTenantId` are
|
|
69
|
+
* @param {{ accountId?: string, activeTenantId?: string } | undefined} session
|
|
70
|
+
* The sealed session, of which only the `accountId` and `activeTenantId` are
|
|
71
|
+
* read, as empty when it holds none
|
|
71
72
|
* @returns {string}
|
|
72
73
|
*/
|
|
73
74
|
export function sharedOnceKey(session) {
|
|
@@ -118,7 +119,7 @@ function pagePath(url, withShared) {
|
|
|
118
119
|
}
|
|
119
120
|
for (const [name, value] of pageQuery) query.set(name, value);
|
|
120
121
|
if (!withShared) query.set(SHARED_PROP, "false");
|
|
121
|
-
return `/dms/page?${query}`;
|
|
122
|
+
return `/dms/page?${query.toString()}`;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/**
|
|
@@ -206,7 +207,7 @@ export async function propsForQuery(props, url, backend, auth) {
|
|
|
206
207
|
* session's layout, sent now or already held: declaring it otherwise would let
|
|
207
208
|
* a layout of another session pass for this one.
|
|
208
209
|
*
|
|
209
|
-
* @param {import("
|
|
210
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
210
211
|
* @param {PageBackend} backend
|
|
211
212
|
* @param {PageAuth} auth
|
|
212
213
|
* @returns {Promise<{
|
|
@@ -17,7 +17,7 @@ export function redirectToHomepage(request, response, pathname) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* @param {import("
|
|
20
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
21
21
|
* @param {import("node:http").ServerResponse} response
|
|
22
22
|
* @param {import("./page-props.mjs").PageProps} props
|
|
23
23
|
* @param {import("./module-server.mjs").ServerModules["serverAuth"]} auth
|
|
@@ -2,6 +2,19 @@ import { isString } from "./type-guards.mjs";
|
|
|
2
2
|
|
|
3
3
|
const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
4
4
|
|
|
5
|
+
/** The forms a path pattern takes, as the warnings name them. */
|
|
6
|
+
export const PATH_PATTERN_FORMS = "/x, /x/* or /x/**";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The wildcard a pattern may end with: what it matches below the literal
|
|
10
|
+
* segments, and how it ranks against a pattern with the same literals.
|
|
11
|
+
*/
|
|
12
|
+
const TAILS = new Map([
|
|
13
|
+
["*", { matcher: "/[^/]+", rank: 3 }],
|
|
14
|
+
["**", { matcher: "(?:/.*)?", rank: 0 }],
|
|
15
|
+
]);
|
|
16
|
+
const NO_TAIL = { matcher: "", rank: 1 };
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* @typedef {object} CompiledPathPattern
|
|
7
20
|
* @property {string} pattern The pattern as declared
|
|
@@ -17,7 +30,8 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
|
17
30
|
*
|
|
18
31
|
* Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
|
|
19
32
|
* page itself, `/x/*` one segment below it, `/x/**` the page and everything
|
|
20
|
-
* below it. Nothing else:
|
|
33
|
+
* below it. Nothing else: a wildcard only as the last segment, no prefix
|
|
34
|
+
* match inside a segment, no query string.
|
|
21
35
|
*
|
|
22
36
|
* @param {unknown} pattern
|
|
23
37
|
* @returns {CompiledPathPattern | undefined}
|
|
@@ -25,26 +39,23 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
|
25
39
|
export function compilePathPattern(pattern) {
|
|
26
40
|
if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
|
|
27
41
|
const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
28
|
-
const
|
|
29
|
-
const literal =
|
|
42
|
+
const last = TAILS.get(segments.at(-1) ?? "");
|
|
43
|
+
const literal = last ? segments.slice(0, -1) : segments;
|
|
30
44
|
if (
|
|
31
45
|
literal.some(
|
|
32
46
|
(segment) =>
|
|
33
|
-
segment
|
|
34
|
-
(!SEGMENT.test(segment) || segment === "." || segment === ".."),
|
|
47
|
+
!SEGMENT.test(segment) || segment === "." || segment === "..",
|
|
35
48
|
)
|
|
36
49
|
)
|
|
37
50
|
return undefined;
|
|
51
|
+
const tail = last ?? NO_TAIL;
|
|
38
52
|
const body = literal
|
|
39
|
-
.map((segment) =>
|
|
40
|
-
segment === "*" ? "/[^/]+" : `/${segment.replaceAll(".", "\\.")}`,
|
|
41
|
-
)
|
|
53
|
+
.map((segment) => `/${segment.replaceAll(".", "\\.")}`)
|
|
42
54
|
.join("");
|
|
43
|
-
const matcher = new RegExp(`^${body}${
|
|
44
|
-
const literals = literal.filter((segment) => segment !== "*").length;
|
|
55
|
+
const matcher = new RegExp(`^${body}${tail.matcher}/?$`);
|
|
45
56
|
return {
|
|
46
57
|
pattern,
|
|
47
|
-
specificity:
|
|
58
|
+
specificity: literal.length * 6 + tail.rank,
|
|
48
59
|
test: (pathname) => matcher.test(pathname === "" ? "/" : pathname),
|
|
49
60
|
};
|
|
50
61
|
}
|
|
@@ -57,7 +57,7 @@ export function writeProxyHeaders(
|
|
|
57
57
|
for (const name of [...HOP_BY_HOP_HEADERS, ...named]) delete headers[name];
|
|
58
58
|
delete headers["set-cookie"];
|
|
59
59
|
const cookies = upstream.headers.getSetCookie?.() ?? [];
|
|
60
|
-
if (cookies.length) headers
|
|
60
|
+
if (cookies.length) Object.assign(headers, { "set-cookie": cookies });
|
|
61
61
|
if (headers.location && upstream.url)
|
|
62
62
|
headers.location = relayedLocation(
|
|
63
63
|
headers.location,
|
|
@@ -21,7 +21,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
|
21
21
|
* the storage's signed URL, on the public origin the server itself may not
|
|
22
22
|
* reach, which turned every download into a 502.
|
|
23
23
|
*
|
|
24
|
-
* @param {import("
|
|
24
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
25
25
|
* @param {import("node:http").ServerResponse} response
|
|
26
26
|
* @param {import("./backend-proxies.mjs").BackendProxy} backendProxy
|
|
27
27
|
* @param {(path: string) => boolean} isProxiedPath
|
|
@@ -36,6 +36,7 @@ export async function proxy(request, response, backendProxy, isProxiedPath) {
|
|
|
36
36
|
if (response.closed) return;
|
|
37
37
|
const abandoned = new AbortController();
|
|
38
38
|
response.once("close", () => abandoned.abort());
|
|
39
|
+
/** @type {RequestInit} */
|
|
39
40
|
const init = {
|
|
40
41
|
signal: abandoned.signal,
|
|
41
42
|
method: request.method,
|
|
@@ -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
|
/**
|
|
@@ -41,7 +43,7 @@ async function* limitedChunks(request, maxBodyBytes) {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
|
-
* @param {import("
|
|
46
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
45
47
|
* @param {number | undefined} maxBodyBytes
|
|
46
48
|
*/
|
|
47
49
|
function requestBody(request, maxBodyBytes) {
|
|
@@ -70,7 +72,7 @@ function declaresTooLargeBody(request, maxBodyBytes) {
|
|
|
70
72
|
function isBodyTooLarge(error) {
|
|
71
73
|
return (
|
|
72
74
|
error instanceof BodyTooLargeError ||
|
|
73
|
-
error
|
|
75
|
+
(isObjectLike(error) && error.cause instanceof BodyTooLargeError)
|
|
74
76
|
);
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -93,7 +95,10 @@ function refuseTooLargeBody(request, response) {
|
|
|
93
95
|
connection: "close",
|
|
94
96
|
});
|
|
95
97
|
response.write(TOO_LARGE_BODY);
|
|
96
|
-
if (request.complete)
|
|
98
|
+
if (request.complete) {
|
|
99
|
+
response.end();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
97
102
|
const close = () => {
|
|
98
103
|
clearTimeout(timer);
|
|
99
104
|
response.end();
|
|
@@ -117,7 +122,7 @@ function refuseTooLargeBody(request, response) {
|
|
|
117
122
|
* before a byte is passed on, a chunked body is cut off as soon as it goes
|
|
118
123
|
* over.
|
|
119
124
|
*
|
|
120
|
-
* @param {import("
|
|
125
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
121
126
|
* @param {import("node:http").ServerResponse} response
|
|
122
127
|
* @param {number | undefined} maxBodyBytes
|
|
123
128
|
* @param {(
|
|
@@ -125,18 +130,19 @@ function refuseTooLargeBody(request, response) {
|
|
|
125
130
|
* ) => Promise<Response>} send The backend request with the body to stream:
|
|
126
131
|
* none for a GET or a HEAD, the request itself, or its bytes cut off past
|
|
127
132
|
* the limit
|
|
128
|
-
* @returns {Promise<Response |
|
|
129
|
-
*
|
|
130
|
-
* 413: `undefined`, or `response` itself when the request had been read
|
|
131
|
-
* already (`response.end()`'s own return)
|
|
133
|
+
* @returns {Promise<Response | undefined>} The backend's answer, or
|
|
134
|
+
* `undefined` once the body went over and `response` got the 413
|
|
132
135
|
*/
|
|
133
136
|
export async function sendWithinLimit(request, response, maxBodyBytes, send) {
|
|
134
|
-
if (declaresTooLargeBody(request, maxBodyBytes))
|
|
135
|
-
|
|
137
|
+
if (declaresTooLargeBody(request, maxBodyBytes)) {
|
|
138
|
+
refuseTooLargeBody(request, response);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
136
141
|
try {
|
|
137
142
|
return await send(requestBody(request, maxBodyBytes));
|
|
138
143
|
} catch (error) {
|
|
139
144
|
if (!isBodyTooLarge(error)) throw error;
|
|
140
|
-
|
|
145
|
+
refuseTooLargeBody(request, response);
|
|
146
|
+
return undefined;
|
|
141
147
|
}
|
|
142
148
|
}
|
|
@@ -2,13 +2,7 @@ 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
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";
|
|
5
|
+
import { isNumber, isObjectLike, isRecord, isString } from "./type-guards.mjs";
|
|
12
6
|
|
|
13
7
|
/**
|
|
14
8
|
* A public endpoint, compiled from a declaration that passed every check.
|
|
@@ -96,7 +90,7 @@ export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
|
96
90
|
* when the proxies are resolved.
|
|
97
91
|
*
|
|
98
92
|
* @param {unknown} entry
|
|
99
|
-
* @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number }}
|
|
93
|
+
* @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number, module?: unknown }}
|
|
100
94
|
*/
|
|
101
95
|
function isProxyDeclaration(entry) {
|
|
102
96
|
return (
|
|
@@ -252,11 +246,11 @@ function pathMatcher(path) {
|
|
|
252
246
|
* A declaration made usable, or `undefined` when any part of it is malformed:
|
|
253
247
|
* a bad entry is dropped whole, never widened to what it might have meant.
|
|
254
248
|
*
|
|
255
|
-
* @param {
|
|
249
|
+
* @param {unknown} entry
|
|
256
250
|
* @returns {PublicEndpoint | undefined}
|
|
257
251
|
*/
|
|
258
252
|
export function compilePublicEndpoint(entry) {
|
|
259
|
-
if (!
|
|
253
|
+
if (!isObjectLike(entry)) return undefined;
|
|
260
254
|
if (!isString(entry.path) || !PUBLIC_PATH.test(entry.path)) return undefined;
|
|
261
255
|
const methods = normalizedMethods(entry.methods);
|
|
262
256
|
const headers = normalizedHeaders(entry.headers);
|
|
@@ -284,7 +278,9 @@ export function allowedPublicEndpoints(
|
|
|
284
278
|
) {
|
|
285
279
|
return [...declared, ...environmentEndpoints(declaration)].flatMap(
|
|
286
280
|
(entry) => {
|
|
287
|
-
const endpoint =
|
|
281
|
+
const endpoint = isRecord(entry)
|
|
282
|
+
? compilePublicEndpoint(entry)
|
|
283
|
+
: undefined;
|
|
288
284
|
if (!endpoint)
|
|
289
285
|
console.warn(
|
|
290
286
|
`Ignoring malformed public endpoint ${JSON.stringify(entry)}: expected ` +
|
|
@@ -337,23 +333,23 @@ export function matchPublicEndpoint(
|
|
|
337
333
|
* Origin. Under `auth: "session"`, a state-changing request needs one unless
|
|
338
334
|
* it reaches a public endpoint, which is why the proxy never sends a public
|
|
339
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.
|
|
340
338
|
*
|
|
341
|
-
* @param {import("
|
|
339
|
+
* @param {import("./request-target.mjs").ServerRequest} request The incoming request
|
|
342
340
|
* @param {ReadonlySet<string>} safeMethods Methods that change nothing and need no Origin
|
|
343
341
|
* @param {import("./backend-proxies.mjs").BackendProxy} proxy The backend proxy the request reached
|
|
344
342
|
* @returns `{ anonymous, headers, isRefused }`
|
|
345
343
|
*/
|
|
346
344
|
export function proxyAccess(request, safeMethods, proxy) {
|
|
347
|
-
if (proxy.auth === "none")
|
|
348
|
-
return { anonymous: true, headers: [], isRefused: false };
|
|
349
345
|
const publicEndpoint = matchPublicEndpoint(
|
|
350
346
|
request.method,
|
|
351
347
|
new URL(request.url, "http://frontend.local").pathname,
|
|
352
348
|
);
|
|
353
|
-
if (publicEndpoint)
|
|
349
|
+
if (publicEndpoint || proxy.auth === "none")
|
|
354
350
|
return {
|
|
355
351
|
anonymous: true,
|
|
356
|
-
headers: publicEndpoint
|
|
352
|
+
headers: publicEndpoint?.headers ?? [],
|
|
357
353
|
isRefused: false,
|
|
358
354
|
};
|
|
359
355
|
return {
|
|
@@ -38,7 +38,7 @@ export function failureStatus(error) {
|
|
|
38
38
|
*/
|
|
39
39
|
export function isResponseGone(error, response) {
|
|
40
40
|
return (
|
|
41
|
-
error
|
|
41
|
+
(isObjectLike(error) && error.code === "ERR_STREAM_PREMATURE_CLOSE") ||
|
|
42
42
|
response.destroyed ||
|
|
43
43
|
response.writableEnded ||
|
|
44
44
|
response.headersSent
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { matchBackendProxy } from "./backend-proxies.mjs";
|
|
2
|
+
import { isIconRequest } from "./icons.mjs";
|
|
2
3
|
import { isRendererFilePath, moduleRouteMethods } from "./module-routes.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -37,7 +38,11 @@ const PAGE_METHODS = new Set(["GET", "HEAD"]);
|
|
|
37
38
|
* `{ kind: "not-found" }`
|
|
38
39
|
*/
|
|
39
40
|
export function requestOwner(method, pathname, modules, hasRendererFile) {
|
|
40
|
-
if (
|
|
41
|
+
if (
|
|
42
|
+
isIconRequest(pathname) ||
|
|
43
|
+
isRendererFilePath(pathname) ||
|
|
44
|
+
hasRendererFile(pathname)
|
|
45
|
+
)
|
|
41
46
|
return { kind: "renderer" };
|
|
42
47
|
const route = modules.routes.find((candidate) =>
|
|
43
48
|
candidate.matcher.test(pathname),
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { writeBadRequest } from "./request-failure.mjs";
|
|
2
1
|
import { isString } from "./type-guards.mjs";
|
|
3
2
|
|
|
4
3
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
@@ -30,7 +29,7 @@ export function isOriginForm(target) {
|
|
|
30
29
|
*/
|
|
31
30
|
export function backendUrl(
|
|
32
31
|
target,
|
|
33
|
-
backendBaseUrl = process.env.DMS_API_BASE_URL,
|
|
32
|
+
backendBaseUrl = process.env.DMS_API_BASE_URL ?? "",
|
|
34
33
|
) {
|
|
35
34
|
const { pathname, search } = new URL(target, FRONTEND_ORIGIN);
|
|
36
35
|
const url = new URL(new URL(backendBaseUrl).origin);
|
|
@@ -40,8 +39,24 @@ export function backendUrl(
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* A request a server received, whose `url` and `method` Node always sets
|
|
43
|
+
* (`isServerRequest`).
|
|
44
|
+
*
|
|
45
|
+
* @typedef {import("node:http").IncomingMessage & { url: string, method: string }} ServerRequest
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @template {import("node:http").IncomingMessage} Request
|
|
50
|
+
* @param {Request} request
|
|
51
|
+
* @returns {request is Request & ServerRequest}
|
|
52
|
+
*/
|
|
53
|
+
export function isServerRequest(request) {
|
|
54
|
+
return isString(request.url) && isString(request.method);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the request target is origin-form, the only kind the server routes:
|
|
59
|
+
* a request that fails it is answered 400 before any route reads it.
|
|
45
60
|
*
|
|
46
61
|
* The request target as the server reads it. Node hands `request.url` over
|
|
47
62
|
* verbatim, so it is not always a path: `//host/x`, `/\host/x` and the absolute
|
|
@@ -50,10 +65,8 @@ export function backendUrl(
|
|
|
50
65
|
* backend read the same normalized path from here.
|
|
51
66
|
*
|
|
52
67
|
* @param {import("node:http").IncomingMessage} request
|
|
53
|
-
* @
|
|
68
|
+
* @returns {request is ServerRequest}
|
|
54
69
|
*/
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
writeBadRequest(response);
|
|
58
|
-
return true;
|
|
70
|
+
export function hasOriginFormTarget(request) {
|
|
71
|
+
return isOriginForm(request.url) && isString(request.method);
|
|
59
72
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { compilePathPattern } from "./path-patterns.mjs";
|
|
2
|
-
import {
|
|
1
|
+
import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
|
|
2
|
+
import {
|
|
3
|
+
isNonNullObject,
|
|
4
|
+
isNumber,
|
|
5
|
+
isObjectLike,
|
|
6
|
+
isRecord,
|
|
7
|
+
isString,
|
|
8
|
+
} from "./type-guards.mjs";
|
|
3
9
|
|
|
4
10
|
/** @typedef {import("../server-module.ts").DmsRouteRule} RouteRule */
|
|
5
11
|
/** @typedef {RouteRule & { headers: Record<string, string> }} MergedRouteRule */
|
|
@@ -37,7 +43,7 @@ const RESERVED_HEADERS = new Set([
|
|
|
37
43
|
|
|
38
44
|
/** @param {unknown} value */
|
|
39
45
|
function isSeconds(value) {
|
|
40
|
-
return Number.isSafeInteger(value) && value >= 0;
|
|
46
|
+
return isNumber(value) && Number.isSafeInteger(value) && value >= 0;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** @param {unknown} headers */
|
|
@@ -63,7 +69,7 @@ function headersRejection(headers) {
|
|
|
63
69
|
function cacheRejection(cache) {
|
|
64
70
|
if (cache === undefined || cache === false) return undefined;
|
|
65
71
|
if (
|
|
66
|
-
!
|
|
72
|
+
!isRecord(cache) ||
|
|
67
73
|
cache.public !== true ||
|
|
68
74
|
!isSeconds(cache.maxAge) ||
|
|
69
75
|
(cache.staleWhileRevalidate !== undefined &&
|
|
@@ -85,8 +91,8 @@ function cacheRejection(cache) {
|
|
|
85
91
|
*/
|
|
86
92
|
export function ruleRejection(pattern, rule) {
|
|
87
93
|
if (!compilePathPattern(pattern))
|
|
88
|
-
return
|
|
89
|
-
if (!
|
|
94
|
+
return `the pattern is not ${PATH_PATTERN_FORMS}`;
|
|
95
|
+
if (!isRecord(rule)) return "it is not an object";
|
|
90
96
|
if (rule.ssr !== undefined && rule.ssr !== false)
|
|
91
97
|
return "ssr is either left out or false";
|
|
92
98
|
return headersRejection(rule.headers) ?? cacheRejection(rule.cache);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
2
2
|
import { extname } from "node:path";
|
|
3
|
+
import { acceptedCodings } from "./content.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* What a build or a module's `public/` directory may hold. A type missing
|
|
@@ -48,22 +49,26 @@ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
|
48
49
|
*/
|
|
49
50
|
const REVALIDATED_CACHE = "no-cache";
|
|
50
51
|
|
|
52
|
+
/** The suffix the build gives the copy of a file in each coding. */
|
|
53
|
+
const VARIANT_SUFFIXES = { br: ".br", gzip: ".gz" };
|
|
54
|
+
|
|
51
55
|
/** @param {string} path */
|
|
52
56
|
const isFile = (path) => existsSync(path) && statSync(path).isFile();
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
|
-
* The precompressed variant of `file` the client
|
|
59
|
+
* The precompressed variant of `file` the client wants most among those the
|
|
60
|
+
* build wrote, or `file` itself.
|
|
56
61
|
*
|
|
57
62
|
* @param {string} file
|
|
58
63
|
* @param {import("node:http").IncomingMessage} request
|
|
59
64
|
*/
|
|
60
65
|
function acceptedVariant(file, request) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const encoding = acceptedCodings(request).find((coding) =>
|
|
67
|
+
isFile(`${file}${VARIANT_SUFFIXES[coding]}`),
|
|
68
|
+
);
|
|
69
|
+
return encoding
|
|
70
|
+
? { file: `${file}${VARIANT_SUFFIXES[encoding]}`, encoding }
|
|
71
|
+
: { file, encoding: undefined };
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/**
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isObjectLike, isString } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const INTERNAL_ERROR_STATUS = 500;
|
|
2
4
|
const ERROR_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
3
5
|
const VITE_ERROR = Symbol("dms.viteError");
|
|
@@ -69,9 +71,18 @@ export function runViteMiddlewares(devServer, request, response) {
|
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Answers 500 with Vite's error message instead of rendering a page.
|
|
76
|
+
*
|
|
77
|
+
* @param {import("node:http").ServerResponse} response
|
|
78
|
+
* @param {unknown} error
|
|
79
|
+
*/
|
|
73
80
|
export function writeViteError(response, error) {
|
|
74
81
|
response.statusCode = INTERNAL_ERROR_STATUS;
|
|
75
82
|
response.setHeader("content-type", ERROR_CONTENT_TYPE);
|
|
76
|
-
response.end(
|
|
83
|
+
response.end(
|
|
84
|
+
isObjectLike(error) && isString(error.message)
|
|
85
|
+
? error.message
|
|
86
|
+
: String(error),
|
|
87
|
+
);
|
|
77
88
|
}
|
|
@@ -17,10 +17,11 @@ import { pipeline } from "node:stream/promises";
|
|
|
17
17
|
*/
|
|
18
18
|
export async function pipeWebBody(body, ...streams) {
|
|
19
19
|
const response = streams.at(-1);
|
|
20
|
+
if (!response) throw new TypeError("pipeWebBody needs a response to write");
|
|
20
21
|
if (response.closed || response.destroyed) {
|
|
21
22
|
for (const stream of streams.slice(0, -1)) stream.destroy();
|
|
22
23
|
await body.cancel().catch(() => {});
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
|
-
await pipeline(Readable.fromWeb(body), ...streams);
|
|
26
|
+
await pipeline([Readable.fromWeb(body), ...streams]);
|
|
26
27
|
}
|
|
@@ -186,7 +186,8 @@ export interface DmsServerRouteOptions {
|
|
|
186
186
|
* The session logic the frontend server runs outside any route, which the
|
|
187
187
|
* module owning sign-in provides. Without `publicSession`, pages carry no
|
|
188
188
|
* `user` and `session`; without `refreshSession`, a session the backend
|
|
189
|
-
* refuses is not renewed; `signInPath` is `/auth` by default.
|
|
189
|
+
* refuses is not renewed; `signInPath` is `/auth` by default. The server
|
|
190
|
+
* calls each hook with the registered object as `this`.
|
|
190
191
|
*/
|
|
191
192
|
export interface DmsServerAuth {
|
|
192
193
|
/** Where a signed-out visit to a private page goes, with `?redirect=`. */
|