@antelopejs/dms-frontend 0.7.1 → 0.9.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.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/dist/commands/build-action.d.ts +8 -5
  3. package/dist/commands/build-action.js +16 -41
  4. package/dist/commands/build.js +9 -15
  5. package/dist/commands/dev-action.d.ts +2 -0
  6. package/dist/commands/dev-action.js +14 -3
  7. package/dist/commands/dev.js +3 -1
  8. package/dist/commands/start-action.d.ts +2 -0
  9. package/dist/commands/start-action.js +65 -47
  10. package/dist/commands/start.js +8 -6
  11. package/dist/common.d.ts +1 -0
  12. package/dist/common.js +1 -0
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +9 -0
  15. package/dist/derived-outputs.d.ts +1 -5
  16. package/dist/derived-outputs.js +48 -35
  17. package/dist/dev-modules.d.ts +5 -0
  18. package/dist/dev-modules.js +88 -0
  19. package/dist/entry-weight.d.ts +24 -0
  20. package/dist/entry-weight.js +86 -0
  21. package/dist/fs-sync.d.ts +1 -0
  22. package/dist/fs-sync.js +6 -0
  23. package/dist/help.js +12 -4
  24. package/dist/index.js +4 -6
  25. package/dist/layer-watch.js +7 -2
  26. package/dist/layers.d.ts +2 -3
  27. package/dist/layers.js +1 -95
  28. package/dist/locale-files.d.ts +6 -0
  29. package/dist/locale-files.js +75 -0
  30. package/dist/manifest.d.ts +8 -8
  31. package/dist/manifest.js +17 -39
  32. package/dist/materialize.js +6 -0
  33. package/dist/module-set.d.ts +14 -12
  34. package/dist/module-set.js +39 -31
  35. package/dist/private-values.d.ts +3 -2
  36. package/dist/private-values.js +16 -5
  37. package/dist/project-modules-child.d.ts +13 -0
  38. package/dist/project-modules-child.js +26 -0
  39. package/dist/project-modules.d.ts +47 -0
  40. package/dist/project-modules.js +268 -0
  41. package/dist/workspace-setup.d.ts +3 -7
  42. package/dist/workspace-setup.js +79 -62
  43. package/dist/workspace.d.ts +8 -4
  44. package/dist/workspace.js +1 -1
  45. package/package.json +13 -8
  46. package/skills/dms-frontend/SKILL.md +2 -2
  47. package/templates/vue/app-runtime.ts +8 -1
  48. package/templates/vue/client-chunking.mjs +61 -0
  49. package/templates/vue/email-renderer.ts +6 -1
  50. package/templates/vue/entry-weight.mjs +132 -0
  51. package/templates/vue/frontend-build-loader.ts +1 -1
  52. package/templates/vue/frontend-module.ts +168 -1
  53. package/templates/vue/gitignore +1 -0
  54. package/templates/vue/main.ts +32 -2
  55. package/templates/vue/network-error.ts +3 -1
  56. package/templates/vue/package.json +3 -3
  57. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  58. package/templates/vue/pnpm-workspace.yaml +5 -0
  59. package/templates/vue/prepare-types.mjs +2 -7
  60. package/templates/vue/public-options.ts +19 -0
  61. package/templates/vue/server/auth/backend.mjs +20 -3
  62. package/templates/vue/server/auth/client-ip.mjs +64 -22
  63. package/templates/vue/server/auth/session.mjs +4 -11
  64. package/templates/vue/server/backend-proxies.mjs +13 -2
  65. package/templates/vue/server/backend-response.mjs +2 -13
  66. package/templates/vue/server/cli-report.mjs +42 -18
  67. package/templates/vue/server/client-manifest.mjs +34 -58
  68. package/templates/vue/server/dev-private-files.mjs +1 -1
  69. package/templates/vue/server/dev-styles.mjs +16 -5
  70. package/templates/vue/server/homepage.mjs +4 -1
  71. package/templates/vue/server/in-place-render.mjs +110 -0
  72. package/templates/vue/server/module-event.mjs +23 -8
  73. package/templates/vue/server/module-server.mjs +4 -4
  74. package/templates/vue/server/module-testing.mjs +5 -3
  75. package/templates/vue/server/page-props.mjs +10 -0
  76. package/templates/vue/server/proxy-compression.mjs +15 -8
  77. package/templates/vue/server/proxy-headers.mjs +12 -1
  78. package/templates/vue/server/proxy-redirects.mjs +66 -0
  79. package/templates/vue/server/proxy-transfers.mjs +110 -0
  80. package/templates/vue/server/public-endpoints.mjs +2 -2
  81. package/templates/vue/server/public-options.mjs +49 -0
  82. package/templates/vue/server/request-routing.mjs +20 -0
  83. package/templates/vue/server/web-body.mjs +24 -0
  84. package/templates/vue/server-module.ts +19 -7
  85. package/templates/vue/server.mjs +98 -78
  86. package/templates/vue/sfc-global-types.mjs +119 -0
  87. package/templates/vue/ssr-renderer.ts +27 -1
  88. package/templates/vue/ui-kbd.mjs +45 -0
  89. package/templates/vue/vite.config.ts +104 -47
  90. package/templates/vue/vite.email.config.ts +6 -3
  91. package/templates/vue/vite.server.config.ts +5 -2
  92. package/dist/commands/snapshot-action.d.ts +0 -6
  93. package/dist/commands/snapshot-action.js +0 -53
  94. package/dist/commands/snapshot.d.ts +0 -2
  95. package/dist/commands/snapshot.js +0 -55
  96. package/dist/snapshot.d.ts +0 -25
  97. package/dist/snapshot.js +0 -90
  98. package/templates/vue/server/backend-wake.mjs +0 -129
@@ -1,9 +1,16 @@
1
- import { clientCountryHeader, clientIp } from "./client-ip.mjs";
1
+ import { REDIRECT_STATUSES } from "../proxy-redirects.mjs";
2
+ import { clientCountryHeader, clientIp, clientIpHeader } from "./client-ip.mjs";
2
3
 
3
- const FORWARDED_HEADERS = [
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, FORWARDED_HEADERS));
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 (trustedHops()) {
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 (!trustedHops()) return request.headers.host;
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
- * The client's own `X-Forwarded-For` is extended only when a trusted reverse
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
- const headers = {
48
- "x-forwarded-for": [...incoming, socketIp].join(", "),
49
- "x-forwarded-proto": requestScheme(request),
50
- };
51
- const host = requestHost(request);
52
- if (host) headers["x-forwarded-host"] = host;
53
- return headers;
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: with `DMS_TRUSTED_PROXY_HOPS` at `0`, any
98
- * caller could have set it, so nothing is relayed.
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 = countryHeader();
102
- if (!name || !trustedHops()) return {};
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
- * Whether a session outlives the browser. Only a session written with
97
- * `persistent: false` says no.
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(isPersistent(session))),
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 module declaration
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(entry?.path, { auth: entry?.auth }, `Backend module ${entry?.module}`);
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, retryAfter) {
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
- const retryAfter = isWaitable(response)
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: esbuild's location for a config
50
- * it could not bundle, Rollup's for a plugin that failed on a file.
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 location = error?.errors?.[0]?.location;
54
- if (location?.file) {
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
- return { file, line: error.loc?.line, column: error.loc?.column };
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 esbuild's error count. */
86
+ /** The first line of what went wrong, without Rolldown's rule and frame. */
68
87
  function errorText(error) {
69
- const text = error?.errors?.[0]?.text;
70
- if (typeof text === "string") return text;
71
- const message = error instanceof Error ? error.message : String(error);
72
- return message.split(/\r?\n/)[0];
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 a rendered page into the style
2
- // and module-preload tags its HTML needs.
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,19 +47,46 @@ function collectManifestStyles(manifest, key, styles, visited) {
98
47
  collectManifestStyles(manifest, imported, styles, visited);
99
48
  }
100
49
 
101
- export function pageModuleStyles(page) {
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 key of matchedPageManifestEntries(page, manifest))
107
- collectManifestStyles(manifest, key, styles, visited);
64
+ for (const id of modules)
65
+ collectManifestStyles(manifest, id, styles, visited);
108
66
  return styles;
109
67
  }
110
68
 
111
- export function pageModulePreloads(page, template) {
112
- return [...pageModuleStyles(page)]
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("");
116
80
  }
81
+
82
+ /**
83
+ * The link that preloads the built catalog of `locale`, the one the page was
84
+ * rendered with: the browser fetches it next to the entry rather than once the
85
+ * entry runs and asks for it, which would put a round trip before hydration.
86
+ * Nothing without a production build (Vite serves the sources in development).
87
+ */
88
+ export function localeCatalogPreloadTag(locale) {
89
+ const manifest = productionClientManifest();
90
+ const file = locale && manifest?.[`locales.generated/${locale}.ts`]?.file;
91
+ return file ? `<link rel="modulepreload" crossorigin href="/${file}">` : "";
92
+ }
@@ -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` gets through in Vite 7.2), so a middleware ahead of Vite's own
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 { pageModulePreloads } from "./client-manifest.mjs";
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 in
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, page, template) {
113
+ export async function documentStyleTags(devServer, modules, template) {
103
114
  return devServer
104
115
  ? developmentStyleTags(devServer)
105
- : pageModulePreloads(page, template);
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.options))
34
+ .map((module) => findHomepage(OPTIONS[module.id]))
32
35
  .find(Boolean) ?? "/";