@antelopejs/dms-frontend 0.7.1 → 0.8.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 +4 -4
- package/dist/commands/build-action.d.ts +8 -5
- package/dist/commands/build-action.js +13 -41
- package/dist/commands/build.js +9 -15
- package/dist/commands/dev-action.js +5 -1
- package/dist/commands/start-action.d.ts +2 -0
- package/dist/commands/start-action.js +36 -49
- package/dist/commands/start.js +8 -6
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -0
- package/dist/derived-outputs.js +2 -2
- package/dist/fs-sync.d.ts +1 -0
- package/dist/fs-sync.js +6 -0
- package/dist/help.js +8 -4
- package/dist/index.js +4 -6
- package/dist/layer-watch.js +4 -0
- package/dist/layers.d.ts +1 -3
- package/dist/layers.js +1 -95
- package/dist/manifest.d.ts +8 -8
- package/dist/manifest.js +15 -39
- package/dist/materialize.js +6 -0
- package/dist/module-set.d.ts +14 -12
- package/dist/module-set.js +39 -31
- package/dist/private-values.d.ts +2 -2
- package/dist/private-values.js +1 -6
- package/dist/project-modules.d.ts +46 -0
- package/dist/project-modules.js +233 -0
- package/dist/workspace-setup.d.ts +3 -7
- package/dist/workspace-setup.js +41 -62
- package/dist/workspace.d.ts +2 -4
- package/dist/workspace.js +1 -1
- package/package.json +5 -7
- package/skills/dms-frontend/SKILL.md +2 -2
- package/templates/vue/client-chunking.mjs +61 -0
- package/templates/vue/email-renderer.ts +6 -1
- package/templates/vue/frontend-build-loader.ts +1 -1
- package/templates/vue/frontend-module.ts +24 -0
- package/templates/vue/main.ts +11 -2
- package/templates/vue/package.json +2 -2
- package/templates/vue/prepare-types.mjs +2 -7
- package/templates/vue/public-options.ts +19 -0
- package/templates/vue/server/auth/backend.mjs +20 -3
- package/templates/vue/server/auth/client-ip.mjs +64 -22
- package/templates/vue/server/auth/session.mjs +4 -11
- package/templates/vue/server/backend-proxies.mjs +13 -2
- package/templates/vue/server/backend-response.mjs +2 -13
- package/templates/vue/server/cli-report.mjs +42 -18
- package/templates/vue/server/client-manifest.mjs +22 -58
- package/templates/vue/server/dev-private-files.mjs +1 -1
- package/templates/vue/server/dev-styles.mjs +16 -5
- package/templates/vue/server/homepage.mjs +4 -1
- package/templates/vue/server/module-event.mjs +23 -8
- package/templates/vue/server/module-server.mjs +4 -4
- package/templates/vue/server/module-testing.mjs +5 -3
- package/templates/vue/server/proxy-compression.mjs +15 -8
- package/templates/vue/server/proxy-headers.mjs +12 -1
- package/templates/vue/server/proxy-redirects.mjs +66 -0
- package/templates/vue/server/proxy-transfers.mjs +84 -0
- package/templates/vue/server/public-endpoints.mjs +2 -2
- package/templates/vue/server/public-options.mjs +49 -0
- package/templates/vue/server/request-routing.mjs +20 -0
- package/templates/vue/server/web-body.mjs +24 -0
- package/templates/vue/server-module.ts +19 -7
- package/templates/vue/server.mjs +59 -49
- package/templates/vue/ssr-renderer.ts +19 -1
- package/templates/vue/vite.config.ts +72 -46
- package/templates/vue/vite.email.config.ts +6 -3
- package/templates/vue/vite.server.config.ts +5 -2
- package/dist/commands/snapshot-action.d.ts +0 -6
- package/dist/commands/snapshot-action.js +0 -53
- package/dist/commands/snapshot.d.ts +0 -2
- package/dist/commands/snapshot.js +0 -55
- package/dist/snapshot.d.ts +0 -25
- package/dist/snapshot.js +0 -90
- package/templates/vue/server/backend-wake.mjs +0 -129
package/templates/vue/main.ts
CHANGED
|
@@ -9,11 +9,14 @@ import {
|
|
|
9
9
|
configureDmsApp,
|
|
10
10
|
resolveDmsInertiaPage,
|
|
11
11
|
} from "./app-runtime";
|
|
12
|
-
import { setupFrontendModules } from "./frontend-module";
|
|
12
|
+
import { setupFrontendModules, withPublicOptions } from "./frontend-module";
|
|
13
|
+
import { parsePublicOptions } from "./public-options";
|
|
13
14
|
import { frontendModules } from "./frontend-modules.generated";
|
|
14
15
|
import { sameOriginIconProvider } from "./icon-api.mjs";
|
|
15
16
|
|
|
16
17
|
const DEV_STYLE_ATTRIBUTE = "data-dms-dev-style";
|
|
18
|
+
/** Mirrors `PUBLIC_OPTIONS_ELEMENT_ID` in `server/public-options.mjs`. */
|
|
19
|
+
const PUBLIC_OPTIONS_ELEMENT_ID = "dms-public-options";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Hand each development stylesheet over to Vite.
|
|
@@ -67,7 +70,13 @@ addAPIProvider("", sameOriginIconProvider(location.origin));
|
|
|
67
70
|
* browser requested one by one.
|
|
68
71
|
*/
|
|
69
72
|
async function startDmsApp(): Promise<void> {
|
|
70
|
-
|
|
73
|
+
const runtimeOptions = parsePublicOptions(
|
|
74
|
+
document.getElementById(PUBLIC_OPTIONS_ELEMENT_ID)?.textContent,
|
|
75
|
+
);
|
|
76
|
+
await setupFrontendModules(
|
|
77
|
+
withPublicOptions(frontendModules, runtimeOptions),
|
|
78
|
+
appConfigs,
|
|
79
|
+
);
|
|
71
80
|
// A layer's app config edited during development reaches the page without a
|
|
72
81
|
// reload. A server render imports the new configs instead.
|
|
73
82
|
syncAppConfigs(applyLayerAppConfigs);
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@nuxt/ui": "4.10.0",
|
|
24
24
|
"@tailwindcss/typography": "^0.5.19",
|
|
25
25
|
"@unhead/vue": "^2.0.14",
|
|
26
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
26
|
+
"@vitejs/plugin-vue": "^6.0.6",
|
|
27
27
|
"@vue/server-renderer": "^3.5.0",
|
|
28
28
|
"@vueuse/core": "^14.3.0",
|
|
29
29
|
"defu": "^6.1.4",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"ufo": "^1.6.1",
|
|
35
35
|
"unplugin-auto-import": "^20.2.0",
|
|
36
36
|
"unplugin-vue-components": "^29.0.0",
|
|
37
|
-
"vite": "^
|
|
37
|
+
"vite": "^8.3.0",
|
|
38
38
|
"vue": "^3.5.0",
|
|
39
39
|
"vue-i18n": "^11.1.12"
|
|
40
40
|
},
|
|
@@ -23,12 +23,7 @@ await build({
|
|
|
23
23
|
build: {
|
|
24
24
|
write: false,
|
|
25
25
|
emptyOutDir: false,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// The entry is empty on purpose.
|
|
29
|
-
onwarn: (warning, warn) => {
|
|
30
|
-
if (warning.code !== "EMPTY_BUNDLE") warn(warning);
|
|
31
|
-
},
|
|
32
|
-
},
|
|
26
|
+
// The entry is empty on purpose; Rolldown does not warn about it.
|
|
27
|
+
rolldownOptions: { input: ENTRY },
|
|
33
28
|
},
|
|
34
29
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Public options by module id, as `ajs dms start` computes them. */
|
|
2
|
+
export type DmsPublicOptionsById = Record<string, Record<string, unknown>>;
|
|
3
|
+
|
|
4
|
+
/** `DMS_PUBLIC_OPTIONS` as options by module id, undefined when unset. */
|
|
5
|
+
export function parsePublicOptions(
|
|
6
|
+
raw: string | null | undefined,
|
|
7
|
+
): DmsPublicOptionsById | undefined {
|
|
8
|
+
if (!raw) return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const parsed: unknown = JSON.parse(raw);
|
|
11
|
+
return typeof parsed === "object" &&
|
|
12
|
+
parsed !== null &&
|
|
13
|
+
!Array.isArray(parsed)
|
|
14
|
+
? (parsed as DmsPublicOptionsById)
|
|
15
|
+
: undefined;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { REDIRECT_STATUSES } from "../proxy-redirects.mjs";
|
|
2
|
+
import { clientCountryHeader, clientIp, clientIpHeader } from "./client-ip.mjs";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The client headers every backend request carries, the proxy's and a module
|
|
6
|
+
* route's alike: the browser's user agent, the interface language, and the
|
|
7
|
+
* realtime session and the browser connection it rides on.
|
|
8
|
+
*/
|
|
9
|
+
export const CLIENT_HEADERS = [
|
|
4
10
|
"user-agent",
|
|
5
11
|
"x-content-language",
|
|
6
12
|
"x-realtime-session",
|
|
13
|
+
"x-realtime-connection",
|
|
7
14
|
];
|
|
8
15
|
|
|
9
16
|
export class UpstreamError extends Error {
|
|
@@ -48,6 +55,7 @@ export function relayedHeaders(request, names) {
|
|
|
48
55
|
*
|
|
49
56
|
* `options.headers` adds request headers; the client's address and the
|
|
50
57
|
* relayed client headers are set after them, so no caller replaces those.
|
|
58
|
+
* A redirect is not followed: it is no JSON answer, so it fails with a 502.
|
|
51
59
|
*/
|
|
52
60
|
export async function backend(path, request, options = {}) {
|
|
53
61
|
const headers = Object.fromEntries(
|
|
@@ -56,8 +64,9 @@ export async function backend(path, request, options = {}) {
|
|
|
56
64
|
String(value),
|
|
57
65
|
]),
|
|
58
66
|
);
|
|
59
|
-
Object.assign(headers, relayedHeaders(request,
|
|
67
|
+
Object.assign(headers, relayedHeaders(request, CLIENT_HEADERS));
|
|
60
68
|
headers["x-forwarded-for"] = clientIp(request);
|
|
69
|
+
Object.assign(headers, clientIpHeader(request));
|
|
61
70
|
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
62
71
|
if (options.relay)
|
|
63
72
|
headers["x-dms-oauth-relay"] = process.env.DMS_OAUTH_RELAY_SECRET ?? "";
|
|
@@ -66,7 +75,15 @@ export async function backend(path, request, options = {}) {
|
|
|
66
75
|
method: options.method ?? "GET",
|
|
67
76
|
headers,
|
|
68
77
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
78
|
+
redirect: "manual",
|
|
69
79
|
});
|
|
80
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
81
|
+
await response.body?.cancel();
|
|
82
|
+
console.error(
|
|
83
|
+
`DMS backend request redirected: ${response.status} ${options.method ?? "GET"} ${path}`,
|
|
84
|
+
);
|
|
85
|
+
throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
|
|
86
|
+
}
|
|
70
87
|
const text = await response.text();
|
|
71
88
|
let data;
|
|
72
89
|
try {
|
|
@@ -1,10 +1,36 @@
|
|
|
1
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
2
|
+
|
|
3
|
+
function headerName(value) {
|
|
4
|
+
const name = (value ?? "").trim().toLowerCase();
|
|
5
|
+
return HEADER_NAME.test(name) ? name : undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
function trustedHops() {
|
|
2
9
|
const value = Number.parseInt(process.env.DMS_TRUSTED_PROXY_HOPS ?? "0", 10);
|
|
3
10
|
return Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
4
11
|
}
|
|
5
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The client's address as the platform wrote it in the header
|
|
15
|
+
* `DMS_CLIENT_IP_HEADER` names (antelopejs.cloud: `X-Client-IP`), if the
|
|
16
|
+
* request carries it. The platform overwrites that header on every request,
|
|
17
|
+
* so its presence also vouches for the platform's `X-Forwarded-*`. On a path
|
|
18
|
+
* the platform does not front (a private call from another service), it is
|
|
19
|
+
* absent and the request falls back to `DMS_TRUSTED_PROXY_HOPS`.
|
|
20
|
+
*/
|
|
21
|
+
function platformClientIp(request) {
|
|
22
|
+
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
23
|
+
if (!name) return undefined;
|
|
24
|
+
const value = request.headers?.[name];
|
|
25
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isProxyTrusted(request) {
|
|
29
|
+
return Boolean(platformClientIp(request)) || trustedHops() > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
export function requestScheme(request) {
|
|
7
|
-
if (
|
|
33
|
+
if (isProxyTrusted(request)) {
|
|
8
34
|
const forwarded = String(request.headers["x-forwarded-proto"] ?? "")
|
|
9
35
|
.split(",")
|
|
10
36
|
.map((value) => value.trim().toLowerCase())
|
|
@@ -16,7 +42,7 @@ export function requestScheme(request) {
|
|
|
16
42
|
}
|
|
17
43
|
|
|
18
44
|
function requestHost(request) {
|
|
19
|
-
if (!
|
|
45
|
+
if (!isProxyTrusted(request)) return request.headers.host;
|
|
20
46
|
return (
|
|
21
47
|
String(request.headers["x-forwarded-host"] ?? "")
|
|
22
48
|
.split(",")
|
|
@@ -31,12 +57,26 @@ function requestHost(request) {
|
|
|
31
57
|
* the client a request comes from: its address, to limit each client, and
|
|
32
58
|
* the scheme and host it asked, to check an Origin against.
|
|
33
59
|
*
|
|
34
|
-
*
|
|
60
|
+
* Behind the platform, `X-Forwarded-For` is the address it wrote. Otherwise
|
|
61
|
+
* the client's own `X-Forwarded-For` is extended only when a trusted reverse
|
|
35
62
|
* proxy wrote it (`DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller
|
|
36
63
|
* could have, and the socket's address replaces it. The scheme and the host
|
|
37
64
|
* are read the same way as the CSRF origin check reads them.
|
|
38
65
|
*/
|
|
39
66
|
export function forwardedHeaders(request) {
|
|
67
|
+
const headers = {
|
|
68
|
+
"x-forwarded-for": forwardedFor(request),
|
|
69
|
+
"x-forwarded-proto": requestScheme(request),
|
|
70
|
+
...clientIpHeader(request),
|
|
71
|
+
};
|
|
72
|
+
const host = requestHost(request);
|
|
73
|
+
if (host) headers["x-forwarded-host"] = host;
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function forwardedFor(request) {
|
|
78
|
+
const platformIp = platformClientIp(request);
|
|
79
|
+
if (platformIp) return platformIp;
|
|
40
80
|
const socketIp = request.socket?.remoteAddress || "unknown";
|
|
41
81
|
const incoming = trustedHops()
|
|
42
82
|
? String(request.headers["x-forwarded-for"] ?? "")
|
|
@@ -44,13 +84,18 @@ export function forwardedHeaders(request) {
|
|
|
44
84
|
.map((value) => value.trim())
|
|
45
85
|
.filter(Boolean)
|
|
46
86
|
: [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
return [...incoming, socketIp].join(", ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `DMS_CLIENT_IP_HEADER` header of a backend request, set to the client
|
|
92
|
+
* address this server resolved, so the backend reads the same address
|
|
93
|
+
* without counting hops. Always written, never relayed: a value the caller
|
|
94
|
+
* sent is replaced. None when the variable is unset.
|
|
95
|
+
*/
|
|
96
|
+
export function clientIpHeader(request) {
|
|
97
|
+
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
98
|
+
return name ? { [name]: clientIp(request) } : {};
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
export function isSameOrigin(request) {
|
|
@@ -67,6 +112,8 @@ export function isSameOrigin(request) {
|
|
|
67
112
|
}
|
|
68
113
|
|
|
69
114
|
export function clientIp(request) {
|
|
115
|
+
const platformIp = platformClientIp(request);
|
|
116
|
+
if (platformIp) return platformIp;
|
|
70
117
|
const socketIp = request.socket?.remoteAddress || "unknown";
|
|
71
118
|
const hops = trustedHops();
|
|
72
119
|
if (!hops) return socketIp;
|
|
@@ -79,14 +126,6 @@ export function clientIp(request) {
|
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
|
|
82
|
-
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
83
|
-
|
|
84
|
-
function countryHeader() {
|
|
85
|
-
const name = (process.env.DMS_COUNTRY_HEADER ?? DEFAULT_COUNTRY_HEADER)
|
|
86
|
-
.trim()
|
|
87
|
-
.toLowerCase();
|
|
88
|
-
return HEADER_NAME.test(name) ? name : undefined;
|
|
89
|
-
}
|
|
90
129
|
|
|
91
130
|
/**
|
|
92
131
|
* The client's country header, as the backend request headers to add.
|
|
@@ -94,12 +133,15 @@ function countryHeader() {
|
|
|
94
133
|
* The header is the one `DMS_COUNTRY_HEADER` names (`CF-IPCountry` by
|
|
95
134
|
* default, empty to relay none), the name the DMS reads from
|
|
96
135
|
* `auth.signInCountry.header`. Like `X-Forwarded-For`, it is only believed
|
|
97
|
-
* from a trusted reverse proxy
|
|
98
|
-
*
|
|
136
|
+
* from a trusted reverse proxy (the platform's client IP header, or
|
|
137
|
+
* `DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller could have set
|
|
138
|
+
* it, so nothing is relayed.
|
|
99
139
|
*/
|
|
100
140
|
export function clientCountryHeader(request) {
|
|
101
|
-
const name =
|
|
102
|
-
|
|
141
|
+
const name = headerName(
|
|
142
|
+
process.env.DMS_COUNTRY_HEADER ?? DEFAULT_COUNTRY_HEADER,
|
|
143
|
+
);
|
|
144
|
+
if (!name || !isProxyTrusted(request)) return {};
|
|
103
145
|
const value = request.headers?.[name];
|
|
104
146
|
return typeof value === "string" && value.trim()
|
|
105
147
|
? { [name]: value.trim() }
|
|
@@ -92,16 +92,9 @@ function attributes(maxAge, path = "/") {
|
|
|
92
92
|
return `Path=${path}; HttpOnly; SameSite=Lax${lifetime}${secure}`;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*/
|
|
99
|
-
function isPersistent(session) {
|
|
100
|
-
return session?.persistent !== false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function lifetime(persistent) {
|
|
104
|
-
return persistent ? MAX_AGE : undefined;
|
|
95
|
+
/** A session outlives the browser only when it is written `persistent: true`. */
|
|
96
|
+
function lifetime(session) {
|
|
97
|
+
return session.persistent === true ? MAX_AGE : undefined;
|
|
105
98
|
}
|
|
106
99
|
|
|
107
100
|
function appendCookie(response, name, value, cookieAttributes) {
|
|
@@ -130,7 +123,7 @@ export function writeSession(response, session) {
|
|
|
130
123
|
response,
|
|
131
124
|
SESSION_COOKIE,
|
|
132
125
|
encode(session),
|
|
133
|
-
attributes(lifetime(
|
|
126
|
+
attributes(lifetime(session)),
|
|
134
127
|
);
|
|
135
128
|
}
|
|
136
129
|
|
|
@@ -39,6 +39,11 @@ export function proxyRejection(pattern, options) {
|
|
|
39
39
|
return "its options are not an object";
|
|
40
40
|
if (options?.auth !== undefined && !PROXY_AUTH_MODES.includes(options.auth))
|
|
41
41
|
return `auth is not one of ${PROXY_AUTH_MODES.map((mode) => `"${mode}"`).join(", ")}`;
|
|
42
|
+
if (
|
|
43
|
+
options?.maxBodyBytes !== undefined &&
|
|
44
|
+
!(Number.isSafeInteger(options.maxBodyBytes) && options.maxBodyBytes > 0)
|
|
45
|
+
)
|
|
46
|
+
return "maxBodyBytes is not a positive whole number of bytes";
|
|
42
47
|
return undefined;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -48,6 +53,7 @@ function compileProxy(pattern, options, owner) {
|
|
|
48
53
|
path: pattern,
|
|
49
54
|
owner,
|
|
50
55
|
auth: options?.auth ?? DEFAULT_AUTH,
|
|
56
|
+
maxBodyBytes: options?.maxBodyBytes,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -56,7 +62,8 @@ function compileProxy(pattern, options, owner) {
|
|
|
56
62
|
*
|
|
57
63
|
* @param declared `{ id, pattern, options }` per `registerBackendProxy` call,
|
|
58
64
|
* from the highest-priority module down
|
|
59
|
-
* @param manifest `{ module, path, auth? }` per backend
|
|
65
|
+
* @param manifest `{ module, path, auth?, maxBodyBytes? }` per backend
|
|
66
|
+
* module declaration
|
|
60
67
|
* @param warn Reports a declaration problem
|
|
61
68
|
* @returns The proxies, the most specific pattern first: a request reaches
|
|
62
69
|
* the first that matches
|
|
@@ -77,7 +84,11 @@ export function resolveBackendProxies(declared, manifest, warn) {
|
|
|
77
84
|
for (const { id, pattern, options } of declared)
|
|
78
85
|
add(pattern, options, `Frontend module ${id}`);
|
|
79
86
|
for (const entry of manifest)
|
|
80
|
-
add(
|
|
87
|
+
add(
|
|
88
|
+
entry?.path,
|
|
89
|
+
{ auth: entry?.auth, maxBodyBytes: entry?.maxBodyBytes },
|
|
90
|
+
`Backend module ${entry?.module}`,
|
|
91
|
+
);
|
|
81
92
|
return [...kept.values()].sort(
|
|
82
93
|
(left, right) => right.pattern.specificity - left.pattern.specificity,
|
|
83
94
|
);
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// allows.
|
|
5
5
|
|
|
6
6
|
import { GENERIC_ERROR_MESSAGE } from "./auth/backend.mjs";
|
|
7
|
-
import { isWaitable } from "./backend-wake.mjs";
|
|
8
7
|
import { isFrontendVisit, redirectFrontendVisit } from "./inertia.mjs";
|
|
9
8
|
|
|
10
9
|
// What a JSON caller gets when the backend fails: the generic i18n key of the
|
|
@@ -20,13 +19,10 @@ const FORBIDDEN = 403;
|
|
|
20
19
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
21
20
|
|
|
22
21
|
export class BackendResponseError extends Error {
|
|
23
|
-
constructor(status, code
|
|
22
|
+
constructor(status, code) {
|
|
24
23
|
super(`DMS backend returned ${status}`);
|
|
25
24
|
this.status = status;
|
|
26
25
|
this.code = code;
|
|
27
|
-
// Set only for a cold-start answer, so the visitor is told when to try
|
|
28
|
-
// again instead of getting an opaque failure.
|
|
29
|
-
this.retryAfter = retryAfter;
|
|
30
26
|
}
|
|
31
27
|
}
|
|
32
28
|
|
|
@@ -47,14 +43,7 @@ async function refusalCode(response) {
|
|
|
47
43
|
|
|
48
44
|
/** The error for a failed backend response, with its typed code if any. */
|
|
49
45
|
export async function backendResponseError(response) {
|
|
50
|
-
|
|
51
|
-
? (response.headers.get("retry-after") ?? undefined)
|
|
52
|
-
: undefined;
|
|
53
|
-
return new BackendResponseError(
|
|
54
|
-
response.status,
|
|
55
|
-
await refusalCode(response),
|
|
56
|
-
retryAfter,
|
|
57
|
-
);
|
|
46
|
+
return new BackendResponseError(response.status, await refusalCode(response));
|
|
58
47
|
}
|
|
59
48
|
|
|
60
49
|
/**
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// `ajs dms start`, it reports over IPC and the CLI words what the user reads.
|
|
3
3
|
// Started on its own, it prints the line itself.
|
|
4
4
|
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
5
6
|
import { resolve } from "node:path";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
6
8
|
|
|
7
9
|
const WILDCARD_ADDRESSES = ["0.0.0.0", "::"];
|
|
8
10
|
|
|
@@ -45,31 +47,53 @@ export async function reportListenError(error, host, port) {
|
|
|
45
47
|
process.exit(1);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** The `[PARSE_ERROR] ` Rolldown puts in front of what it says. */
|
|
51
|
+
const RULE_PREFIX = /^\[[A-Z_]+\]\s*/;
|
|
52
|
+
|
|
53
|
+
/** The first problem Rolldown reports when it cannot bundle a config. */
|
|
54
|
+
function bundlerError(error) {
|
|
55
|
+
const first = error?.errors?.[0];
|
|
56
|
+
return first && typeof first === "object" ? first : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
/**
|
|
49
|
-
* Where a startup error points, when it says:
|
|
50
|
-
* it could not bundle
|
|
60
|
+
* Where a startup error points, when it says: Rolldown's location for a config
|
|
61
|
+
* it could not bundle (the line is read back from the file, which Rolldown
|
|
62
|
+
* only draws in a colored frame), Rollup's for a plugin that failed on a file.
|
|
51
63
|
*/
|
|
52
64
|
function errorLocation(error) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
file: resolve(location.file),
|
|
57
|
-
line: location.line,
|
|
58
|
-
column: location.column,
|
|
59
|
-
lineText: location.lineText,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
const file = error?.loc?.file ?? error?.id;
|
|
65
|
+
const source = bundlerError(error) ?? error;
|
|
66
|
+
const file = source?.loc?.file ?? source?.id;
|
|
63
67
|
if (typeof file !== "string") return undefined;
|
|
64
|
-
|
|
68
|
+
const { line, column } = source.loc ?? {};
|
|
69
|
+
return {
|
|
70
|
+
file: resolve(file),
|
|
71
|
+
line,
|
|
72
|
+
column,
|
|
73
|
+
lineText: line === undefined ? undefined : readLine(file, line),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Line `line` (1-based) of `file`, `undefined` when it cannot be read. */
|
|
78
|
+
function readLine(file, line) {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(file, "utf8").split(/\r?\n/)[line - 1];
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
/** The first line of what went wrong, without
|
|
86
|
+
/** The first line of what went wrong, without Rolldown's rule and frame. */
|
|
68
87
|
function errorText(error) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
const reported = bundlerError(error);
|
|
89
|
+
const message = reported
|
|
90
|
+
? String(reported.message)
|
|
91
|
+
: error instanceof Error
|
|
92
|
+
? error.message
|
|
93
|
+
: String(error);
|
|
94
|
+
return stripVTControlCharacters(message)
|
|
95
|
+
.split(/\r?\n/)[0]
|
|
96
|
+
.replace(RULE_PREFIX, "");
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Reading the built client manifest and turning
|
|
2
|
-
//
|
|
1
|
+
// Reading the built client manifest and turning the components a server render
|
|
2
|
+
// created into the stylesheet links its HTML needs.
|
|
3
3
|
//
|
|
4
4
|
// Lives apart from server.mjs so the file stays under the size the linter
|
|
5
5
|
// allows. Production-only: in dev, Vite serves these itself.
|
|
@@ -30,29 +30,6 @@ export function productionHtmlTemplate() {
|
|
|
30
30
|
return productionTemplate;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function normalizeDmsName(name) {
|
|
34
|
-
return name
|
|
35
|
-
.replace(/^lazy/i, "")
|
|
36
|
-
.replace(/^dms[-_]?/i, "")
|
|
37
|
-
.replace(/[^a-z0-9]/gi, "")
|
|
38
|
-
.toLowerCase();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function collectRenderedComponentNames(value, names) {
|
|
42
|
-
if (!value || typeof value !== "object") return;
|
|
43
|
-
if (Array.isArray(value)) {
|
|
44
|
-
value.forEach((entry) => {
|
|
45
|
-
collectRenderedComponentNames(entry, names);
|
|
46
|
-
});
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (typeof value.componentName === "string")
|
|
50
|
-
names.add(normalizeDmsName(value.componentName));
|
|
51
|
-
Object.values(value).forEach((entry) => {
|
|
52
|
-
collectRenderedComponentNames(entry, names);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
33
|
function productionClientManifest() {
|
|
57
34
|
if (clientManifest) return clientManifest;
|
|
58
35
|
if (!existsSync(CLIENT_MANIFEST_PATH)) return undefined;
|
|
@@ -60,34 +37,6 @@ function productionClientManifest() {
|
|
|
60
37
|
return clientManifest;
|
|
61
38
|
}
|
|
62
39
|
|
|
63
|
-
function sourceComponentName(source) {
|
|
64
|
-
const filename =
|
|
65
|
-
source
|
|
66
|
-
.split("/")
|
|
67
|
-
.at(-1)
|
|
68
|
-
?.replace(/\.vue$/, "") ?? "";
|
|
69
|
-
return normalizeDmsName(filename);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function matchedPageManifestEntries(page, manifest) {
|
|
73
|
-
const names = new Set();
|
|
74
|
-
const layout = page.props?.page?.layout;
|
|
75
|
-
collectRenderedComponentNames(layout, names);
|
|
76
|
-
[
|
|
77
|
-
page.props?.page?.componentName,
|
|
78
|
-
layout?.componentName,
|
|
79
|
-
layout?.layout?.componentName,
|
|
80
|
-
].forEach((name) => {
|
|
81
|
-
if (typeof name === "string") names.add(normalizeDmsName(name));
|
|
82
|
-
});
|
|
83
|
-
return Object.entries(manifest)
|
|
84
|
-
.filter(
|
|
85
|
-
([source, entry]) =>
|
|
86
|
-
entry.isDynamicEntry && names.has(sourceComponentName(source)),
|
|
87
|
-
)
|
|
88
|
-
.map(([key]) => key);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
40
|
function collectManifestStyles(manifest, key, styles, visited) {
|
|
92
41
|
if (visited.has(key)) return;
|
|
93
42
|
visited.add(key);
|
|
@@ -98,18 +47,33 @@ function collectManifestStyles(manifest, key, styles, visited) {
|
|
|
98
47
|
collectManifestStyles(manifest, imported, styles, visited);
|
|
99
48
|
}
|
|
100
49
|
|
|
101
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The stylesheets the client build split out for the components a server
|
|
52
|
+
* render created, each named by the module id `@vitejs/plugin-vue` records in
|
|
53
|
+
* the render's `ssrContext.modules`. That id is the component's path from the
|
|
54
|
+
* workspace root, the very key the client manifest gives a lazily loaded
|
|
55
|
+
* component, so the lookup needs no guessing from names. A component bundled
|
|
56
|
+
* into another chunk is no key of its own: its stylesheet comes with that
|
|
57
|
+
* chunk's, reached through the `imports` of the component that loaded it.
|
|
58
|
+
*/
|
|
59
|
+
function renderedModuleStyles(modules = []) {
|
|
102
60
|
const manifest = productionClientManifest();
|
|
103
61
|
const styles = new Set();
|
|
104
62
|
if (!manifest) return styles;
|
|
105
63
|
const visited = new Set();
|
|
106
|
-
for (const
|
|
107
|
-
collectManifestStyles(manifest,
|
|
64
|
+
for (const id of modules)
|
|
65
|
+
collectManifestStyles(manifest, id, styles, visited);
|
|
108
66
|
return styles;
|
|
109
67
|
}
|
|
110
68
|
|
|
111
|
-
|
|
112
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Links the stylesheets of every component the server rendered and the built
|
|
71
|
+
* template does not link already, so that none of them waits for its chunk's
|
|
72
|
+
* JavaScript to arrive: the HTML would otherwise paint unstyled and shift once
|
|
73
|
+
* it does. A component the render did not reach gets no link.
|
|
74
|
+
*/
|
|
75
|
+
export function renderedModuleStyleTags(modules, template) {
|
|
76
|
+
return [...renderedModuleStyles(modules)]
|
|
113
77
|
.filter((file) => !template.includes(`href="/${file}"`))
|
|
114
78
|
.map((file) => `<link rel="stylesheet" href="/${file}">`)
|
|
115
79
|
.join("");
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// written, and the modules' server code is materialized under it. Vite serves
|
|
6
6
|
// any file under its root, as is, `?raw`, `?import` or `?url`. Its
|
|
7
7
|
// `server.fs.deny` list covers most of these, but not every query combination
|
|
8
|
-
// (`?import&raw`
|
|
8
|
+
// (`?import&raw` got through in Vite 7.2; Vite 8.3 refuses it itself), so a middleware ahead of Vite's own
|
|
9
9
|
// answers 403 by path, whatever the query, and a load guard refuses them to
|
|
10
10
|
// any client request that reaches the transform pipeline another way.
|
|
11
11
|
// `ssrLoadModule`, which loads the server modules, is left alone.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { join, sep } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import {
|
|
21
|
+
import { renderedModuleStyleTags } from "./client-manifest.mjs";
|
|
22
22
|
|
|
23
23
|
const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
24
24
|
const STYLE_ATTRIBUTE = "data-dms-dev-style";
|
|
@@ -47,7 +47,18 @@ function ssrModuleGraph(devServer) {
|
|
|
47
47
|
return devServer.environments?.ssr?.moduleGraph ?? devServer.moduleGraph;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Vite only treats an id as a stylesheet when the language is the LAST thing in
|
|
51
|
+
// it, and a component's style block ends in `&lang.css`. Appending `&direct`
|
|
52
|
+
// after that makes Vite skip its stylesheet pipeline, so the block comes back
|
|
53
|
+
// as written: `@import "package/dist/style.css"` is left for the browser, which
|
|
54
|
+
// resolves it against the page URL and gets a 404. `direct` therefore goes in
|
|
55
|
+
// front of the trailing language segment.
|
|
56
|
+
const TRAILING_LANG = /&(lang\.[^&?]*)$/;
|
|
57
|
+
|
|
50
58
|
function directUrl(url) {
|
|
59
|
+
if (SFC_STYLE.test(url) && TRAILING_LANG.test(url)) {
|
|
60
|
+
return url.replace(TRAILING_LANG, "&direct&$1");
|
|
61
|
+
}
|
|
51
62
|
return `${url}${url.includes("?") ? "&" : "?"}direct`;
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -96,11 +107,11 @@ export async function developmentStyleTags(devServer) {
|
|
|
96
107
|
|
|
97
108
|
/**
|
|
98
109
|
* The style tags a document needs, whichever server is answering it: collected
|
|
99
|
-
* from the live module graph in development, read from the build manifest
|
|
100
|
-
* production.
|
|
110
|
+
* from the live module graph in development, read from the build manifest for
|
|
111
|
+
* the components the render created (`modules`) in production.
|
|
101
112
|
*/
|
|
102
|
-
export async function documentStyleTags(devServer,
|
|
113
|
+
export async function documentStyleTags(devServer, modules, template) {
|
|
103
114
|
return devServer
|
|
104
115
|
? developmentStyleTags(devServer)
|
|
105
|
-
:
|
|
116
|
+
: renderedModuleStyleTags(modules, template);
|
|
106
117
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { publicOptionsOf } from "./public-options.mjs";
|
|
14
15
|
|
|
15
16
|
const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
16
17
|
const REGISTRY_PATH = join(PROJECT_ROOT, "generated-frontend-modules.json");
|
|
@@ -26,7 +27,9 @@ function findHomepage(options) {
|
|
|
26
27
|
.find((homepage) => homepage !== undefined);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
const OPTIONS = publicOptionsOf(REGISTRY);
|
|
31
|
+
|
|
29
32
|
export const HOMEPAGE =
|
|
30
33
|
REGISTRY.modules
|
|
31
|
-
.map((module) => findHomepage(module.
|
|
34
|
+
.map((module) => findHomepage(OPTIONS[module.id]))
|
|
32
35
|
.find(Boolean) ?? "/";
|