@antelopejs/dms-frontend 0.2.0 → 0.2.2

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 CHANGED
@@ -163,11 +163,13 @@ started by `dev`, and the production server started by `start`, because those
163
163
  child processes inherit the environment. A missing file is not an error; an
164
164
  unreadable one is reported and skipped.
165
165
 
166
- `DMS_SESSION_SECRET` is mandatory for anything that touches a session. The
167
- generated server encrypts its session cookie with it, and with no value — or
168
- one shorter than 32 characters the login page at `/auth` fails the first
169
- sign-in attempt rather than starting degraded. Generate one with
170
- `openssl rand -hex 32`.
166
+ `ajs dms dev` generates a fresh ephemeral 32-byte secret when
167
+ `DMS_SESSION_SECRET` is absent; all sessions are invalidated when that dev
168
+ server restarts. An explicit value is preserved, but empty or shorter than 32
169
+ characters is rejected. `build` and `start` require a configured secret and
170
+ never generate one, which is required for production so sessions survive
171
+ restarts. The generated server also validates the value as a safety net.
172
+ Generate one with `openssl rand -hex 32`.
171
173
 
172
174
  ### Opening a session from a module flow
173
175
 
@@ -21,6 +21,7 @@ function cmdBuild() {
21
21
  (0, cli_ui_1.error)("Backend URL is required. Use -b <url> or set DMS_API_BASE_URL.");
22
22
  process.exit(1);
23
23
  }
24
+ const sessionSecret = (0, common_1.resolveSessionSecret)("build");
24
25
  const spinner = new cli_ui_1.Spinner("Setting up workspace...");
25
26
  await spinner.start();
26
27
  try {
@@ -44,6 +45,7 @@ function cmdBuild() {
44
45
  cwd: workspaceDir,
45
46
  env: {
46
47
  ...process.env,
48
+ DMS_SESSION_SECRET: sessionSecret,
47
49
  NODE_OPTIONS: "--max-old-space-size=4096",
48
50
  NODE_PATH: nodeModulesDir,
49
51
  },
@@ -44,6 +44,7 @@ function cmdDev() {
44
44
  .action(async (options) => {
45
45
  const { backendUrl, workspaceKey } = resolveBackend(options);
46
46
  const bootstrapSecret = (0, common_1.resolveBootstrapSecret)(options.bootstrapSecret, backendUrl);
47
+ const sessionSecret = (0, common_1.resolveSessionSecret)("dev");
47
48
  const requestedPort = Number.parseInt(options.port, 10);
48
49
  if (Number.isNaN(requestedPort)) {
49
50
  (0, cli_ui_1.error)(`Invalid port: ${options.port}`);
@@ -101,6 +102,7 @@ function cmdDev() {
101
102
  DMS_COOKIE_SECURE: process.env.DMS_COOKIE_SECURE ?? "false",
102
103
  DMS_API_BASE_URL: backendUrl,
103
104
  DMS_BOOTSTRAP_SECRET: bootstrapSecret,
105
+ DMS_SESSION_SECRET: sessionSecret,
104
106
  NODE_OPTIONS: "--max-old-space-size=4096",
105
107
  NODE_PATH: nodeModulesDir,
106
108
  },
@@ -20,6 +20,7 @@ function cmdStart() {
20
20
  (0, cli_ui_1.error)("Backend URL is required. Use -b <url> or set DMS_API_BASE_URL.");
21
21
  process.exit(1);
22
22
  }
23
+ const sessionSecret = (0, common_1.resolveSessionSecret)("start");
23
24
  const workspaceDir = (0, common_1.getWorkspaceDir)(options.backendUrl);
24
25
  const serverPath = (0, node_path_1.join)(workspaceDir, "server.mjs");
25
26
  const clientPath = (0, node_path_1.join)(workspaceDir, "dist", "client", "index.html");
@@ -41,6 +42,7 @@ function cmdStart() {
41
42
  ...process.env,
42
43
  PORT: options.port,
43
44
  DMS_API_BASE_URL: options.backendUrl,
45
+ DMS_SESSION_SECRET: sessionSecret,
44
46
  DMS_COOKIE_SECURE: process.env.DMS_COOKIE_SECURE ?? "true",
45
47
  },
46
48
  });
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Options = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
7
7
  exports.writeSecretBearingFile = writeSecretBearingFile;
8
8
  exports.normalizeBootstrapSecret = normalizeBootstrapSecret;
9
+ exports.resolveSessionSecret = resolveSessionSecret;
9
10
  exports.resolveBootstrapSecret = resolveBootstrapSecret;
10
11
  exports.sha256Hex = sha256Hex;
11
12
  exports.canonicalizeBackendUrl = canonicalizeBackendUrl;
@@ -38,6 +39,7 @@ exports.TEMPLATE_FILES = [
38
39
  ["frontend-module.ts", "frontend-module.ts"],
39
40
  ["globals.d.ts", "globals.d.ts"],
40
41
  ["compress-assets.mjs", "compress-assets.mjs"],
42
+ ["head-order.mjs", "head-order.mjs"],
41
43
  ["email-renderer.ts", "email-renderer.ts"],
42
44
  ["email-runtime.ts", "email-runtime.ts"],
43
45
  ["email-locales.ts", "email-locales.ts"],
@@ -109,6 +111,18 @@ function normalizeBootstrapSecret(value, source = DEFAULT_CREDENTIAL_SOURCE) {
109
111
  }
110
112
  return trimmed;
111
113
  }
114
+ function resolveSessionSecret(mode, value = process.env.DMS_SESSION_SECRET) {
115
+ if (value === undefined && mode === "dev") {
116
+ return (0, node_crypto_1.randomBytes)(32).toString("hex");
117
+ }
118
+ if (value === undefined || value.length < 32) {
119
+ throw new Error("DMS_SESSION_SECRET must contain at least 32 characters; " +
120
+ (mode === "dev"
121
+ ? "set it explicitly or omit it to generate an ephemeral dev secret"
122
+ : "set it explicitly for build and start"));
123
+ }
124
+ return value;
125
+ }
112
126
  function resolveBootstrapSecret(explicit, backendUrl, options = {}) {
113
127
  const normalized = normalizeBootstrapSecret(explicit);
114
128
  if (normalized)
package/dist/index.js CHANGED
@@ -38,6 +38,9 @@ Environment:
38
38
  the other variables below can live in the project's .env. A variable already
39
39
  set in the environment always wins over a file, and .env.local wins over .env.
40
40
  The generated workspace never loads a .env of its own.
41
+ 'dev' generates an ephemeral 32-byte DMS_SESSION_SECRET when it is absent;
42
+ restarting dev invalidates its sessions. 'build' and 'start' require a
43
+ configured secret of at least 32 characters.
41
44
 
42
45
  Workspaces:
43
46
  Each canonical backend URL gets its own workspace under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -0,0 +1,39 @@
1
+ // Restoring "paint before hydrate" in the built document.
2
+ //
3
+ // The source index.html marks the entry script `fetchpriority="low"` so the
4
+ // browser spends its bandwidth on the render-blocking stylesheet first. Vite
5
+ // rewrites that script tag when it builds and drops the attribute, and it emits
6
+ // the script *before* the stylesheet — so the production document did the
7
+ // opposite of what the template asked: it discovered a 250 kB hydration bundle
8
+ // first and let it compete with the 27 kB the first paint actually waits on,
9
+ // over the plain HTTP/1.1 the generated server speaks.
10
+ //
11
+ // Kept beside vite.config.ts rather than inside it so the transform can be
12
+ // exercised on its own, the way client-manifest.mjs is.
13
+
14
+ const SCRIPT = /<script[^>]*\stype="module"[^>]*><\/script>/;
15
+ const STYLESHEET = /[ \t]*<link rel="stylesheet"[^>]*>\n?/;
16
+
17
+ function deprioritize(html) {
18
+ return html.replace(SCRIPT, (tag) =>
19
+ tag.includes("fetchpriority")
20
+ ? tag
21
+ : tag.replace("<script ", '<script fetchpriority="low" '),
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Give the entry script back its low fetch priority and move the stylesheet
27
+ * ahead of it. A document that already has them in that order is returned
28
+ * unchanged, so the transform is safe to run twice.
29
+ */
30
+ export function orderHeadForFirstPaint(html) {
31
+ const prioritized = deprioritize(html);
32
+ const stylesheet = prioritized.match(STYLESHEET);
33
+ const script = prioritized.match(SCRIPT);
34
+ if (!stylesheet || !script || stylesheet.index < script.index)
35
+ return prioritized;
36
+ return prioritized
37
+ .replace(STYLESHEET, "")
38
+ .replace(SCRIPT, (tag) => `${stylesheet[0].trim()}\n ${tag}`);
39
+ }
@@ -6,10 +6,47 @@ import { configureDmsApp, resolveDmsInertiaPage } from "./app-runtime";
6
6
  import { setupFrontendModules } from "./frontend-module";
7
7
  import { frontendModules } from "./frontend-modules.generated";
8
8
 
9
+ const DEV_STYLE_ATTRIBUTE = "data-dms-dev-style";
10
+
11
+ /**
12
+ * Hand each development stylesheet over to Vite.
13
+ *
14
+ * The development server puts the page's stylesheets in the document so it
15
+ * never paints unstyled (see `server/dev-styles.mjs`). Vite then injects its
16
+ * own copy of each one as a `<style data-vite-dev-id>`, and keeping both would
17
+ * make a later CSS edit look like it removed nothing: Vite rewrites its copy,
18
+ * ours keeps the deleted rules alive.
19
+ *
20
+ * We drop ours the moment Vite's appears, keyed by module id, rather than at a
21
+ * fixed point in the boot. A component reached through a dynamic import — most
22
+ * of them — has not been loaded when the app mounts, so removing its scoped
23
+ * rules then would flash exactly what this whole change exists to prevent.
24
+ */
25
+ function adoptViteDevStyles(): void {
26
+ const pending = new Map<string, Element>();
27
+ for (const node of document.querySelectorAll(`[${DEV_STYLE_ATTRIBUTE}]`)) {
28
+ pending.set(node.getAttribute(DEV_STYLE_ATTRIBUTE) ?? "", node);
29
+ }
30
+ if (pending.size === 0) return;
31
+ const adopt = (): void => {
32
+ for (const style of document.querySelectorAll("style[data-vite-dev-id]")) {
33
+ const id = style.getAttribute("data-vite-dev-id") ?? "";
34
+ pending.get(id)?.remove();
35
+ pending.delete(id);
36
+ }
37
+ if (pending.size === 0) observer.disconnect();
38
+ };
39
+ const observer = new MutationObserver(adopt);
40
+ observer.observe(document.head, { childList: true });
41
+ adopt();
42
+ }
43
+
9
44
  function markDmsReady(): void {
10
45
  document.documentElement.dataset.dmsReady = "true";
11
46
  }
12
47
 
48
+ if (import.meta.env.DEV) adoptViteDevStyles();
49
+
13
50
  await setupFrontendModules(frontendModules);
14
51
 
15
52
  createInertiaApp({
@@ -0,0 +1,98 @@
1
+ // Putting the page's stylesheets in the document the development server
2
+ // returns, instead of letting the client entry inject them from JavaScript
3
+ // once it has executed.
4
+ //
5
+ // Vite serves every stylesheet as a JavaScript module in development, so a
6
+ // plain SSR document carries no styles at all and the browser paints raw HTML
7
+ // until `/main.ts` and its module graph have run. Production has no such gap:
8
+ // the built `index.html` already links the entry stylesheet, which is why this
9
+ // module is development-only and lives beside client-manifest.mjs rather than
10
+ // inside server.mjs, whose size the linter caps.
11
+ //
12
+ // Each tag carries the module id Vite will use for its own copy
13
+ // (`data-vite-dev-id`), under our own attribute name. `main.ts` hands the
14
+ // stylesheet over as soon as Vite injects that copy — see `adoptViteDevStyles`.
15
+ // We deliberately do NOT reuse `data-vite-dev-id` itself: Vite's client adopts
16
+ // any pre-existing link with that attribute and then skips `updateStyle` for
17
+ // it, which would leave CSS edits stranded.
18
+
19
+ import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { pageModulePreloads } from "./client-manifest.mjs";
22
+
23
+ const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url));
24
+ const STYLE_ATTRIBUTE = "data-dms-dev-style";
25
+ const SFC_STYLE = /[?&]vue&type=style/;
26
+ const STYLE_EXTENSION =
27
+ /\.(?:css|less|pcss|postcss|sass|scss|styl|stylus)(?:$|\?)/;
28
+
29
+ /**
30
+ * The workspace's generated entry stylesheet. `main.ts` is its only importer,
31
+ * so it never enters the SSR module graph, yet it carries Tailwind, Nuxt UI and
32
+ * one `@source` per materialized module — nearly every rule a page needs.
33
+ * Naming it is the one enumeration this module accepts, and the comment is the
34
+ * reason why.
35
+ */
36
+ const ENTRY_STYLESHEET = "/dms-main.css";
37
+
38
+ function ssrModuleGraph(devServer) {
39
+ return devServer.environments?.ssr?.moduleGraph ?? devServer.moduleGraph;
40
+ }
41
+
42
+ function directUrl(url) {
43
+ return `${url}${url.includes("?") ? "&" : "?"}direct`;
44
+ }
45
+
46
+ function styleModules(devServer) {
47
+ const graph = ssrModuleGraph(devServer);
48
+ if (!graph) return [];
49
+ return [...graph.urlToModuleMap.entries()]
50
+ .filter(([url]) => SFC_STYLE.test(url) || STYLE_EXTENSION.test(url))
51
+ .map(([url, module]) => ({ url, id: module?.id ?? url }));
52
+ }
53
+
54
+ function linkTag({ url, id }) {
55
+ return `<link rel="stylesheet" ${STYLE_ATTRIBUTE}="${id}" href="${directUrl(url)}">`;
56
+ }
57
+
58
+ /**
59
+ * A single-file component's `<style>` block is served with a JavaScript content
60
+ * type even when requested directly, so a browser would refuse it as a
61
+ * stylesheet. They are small; inline them instead. A block that could close its
62
+ * own tag is dropped rather than escaped — CSS has no escape that is valid in
63
+ * every position, and a scoped component rule is not worth the risk.
64
+ */
65
+ async function inlineTag(devServer, { url, id }) {
66
+ const result = await devServer
67
+ .transformRequest(directUrl(url))
68
+ .catch(() => undefined);
69
+ const css = result?.code;
70
+ if (!css || /<\/style/i.test(css)) return "";
71
+ return `<style ${STYLE_ATTRIBUTE}="${id}">${css}</style>`;
72
+ }
73
+
74
+ /** Style tags for everything the development render touched, entry sheet first. */
75
+ export async function developmentStyleTags(devServer) {
76
+ const modules = styleModules(devServer);
77
+ const links = [
78
+ { url: ENTRY_STYLESHEET, id: join(PROJECT_ROOT, "dms-main.css") },
79
+ ...modules.filter(({ url }) => !SFC_STYLE.test(url)),
80
+ ].map(linkTag);
81
+ const inlined = await Promise.all(
82
+ modules
83
+ .filter(({ url }) => SFC_STYLE.test(url))
84
+ .map((module) => inlineTag(devServer, module)),
85
+ );
86
+ return [...links, ...inlined].join("");
87
+ }
88
+
89
+ /**
90
+ * The style tags a document needs, whichever server is answering it: collected
91
+ * from the live module graph in development, read from the build manifest in
92
+ * production.
93
+ */
94
+ export async function documentStyleTags(devServer, page, template) {
95
+ return devServer
96
+ ? developmentStyleTags(devServer)
97
+ : pageModulePreloads(page, template);
98
+ }
@@ -0,0 +1,32 @@
1
+ // Working out which page a visit to `/` means.
2
+ //
3
+ // The answer lives in the module registry the workspace was materialized with:
4
+ // a module declares `homepage` somewhere in its options, at whatever depth its
5
+ // own configuration shape puts it, and the highest-priority declaration wins —
6
+ // the registry is already sorted. Read once at boot, because the registry is a
7
+ // build artifact and cannot change while the server is up.
8
+ //
9
+ // Split out of server.mjs, whose size the linter caps.
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url));
16
+ const REGISTRY_PATH = join(PROJECT_ROOT, "generated-frontend-modules.json");
17
+ const REGISTRY = existsSync(REGISTRY_PATH)
18
+ ? JSON.parse(readFileSync(REGISTRY_PATH, "utf8"))
19
+ : { modules: [] };
20
+
21
+ function findHomepage(options) {
22
+ if (!options || typeof options !== "object") return undefined;
23
+ if (typeof options.homepage === "string") return options.homepage;
24
+ return Object.values(options)
25
+ .map(findHomepage)
26
+ .find((homepage) => homepage !== undefined);
27
+ }
28
+
29
+ export const HOMEPAGE =
30
+ REGISTRY.modules
31
+ .map((module) => findHomepage(module.options))
32
+ .find(Boolean) ?? "/";
@@ -7,6 +7,17 @@ const CLIENT_MANIFEST_PATH = fileURLToPath(
7
7
  );
8
8
  let productionAssetVersion;
9
9
 
10
+ /**
11
+ * Every document and Inertia payload this server writes carries the visitor's
12
+ * own session: the user, the session, and the page tree their permissions
13
+ * allow. `private` is what keeps a shared cache from ever holding it;
14
+ * `no-cache` keeps the browser's own copy from being reused without asking us
15
+ * first, so a revoked session cannot resurface from disk. We stop short of
16
+ * `no-store`, which would additionally forfeit the back-forward cache on every
17
+ * page of a dashboard people navigate constantly.
18
+ */
19
+ export const PRIVATE_CACHE_CONTROL = "private, no-cache";
20
+
10
21
  export function assetVersion() {
11
22
  if (process.env.DMS_DEV === "true" || !existsSync(CLIENT_MANIFEST_PATH))
12
23
  return "development";
@@ -32,6 +43,7 @@ export function createInertiaPage(url, props, version = assetVersion()) {
32
43
 
33
44
  export function inertiaHeaders(version = assetVersion()) {
34
45
  return {
46
+ "cache-control": PRIVATE_CACHE_CONTROL,
35
47
  "content-type": "application/json",
36
48
  "x-inertia": "true",
37
49
  vary: "X-Inertia",
@@ -39,6 +51,15 @@ export function inertiaHeaders(version = assetVersion()) {
39
51
  };
40
52
  }
41
53
 
54
+ /** Headers for a rendered document: same cache stance as an Inertia payload. */
55
+ export function htmlHeaders() {
56
+ return {
57
+ "cache-control": PRIVATE_CACHE_CONTROL,
58
+ "content-type": "text/html",
59
+ vary: "X-Inertia",
60
+ };
61
+ }
62
+
42
63
  export function handleAssetVersionMismatch(request, response) {
43
64
  if (
44
65
  !request.headers["x-inertia"] ||
@@ -18,14 +18,14 @@ import {
18
18
  refreshSession,
19
19
  } from "./server/auth/routes.mjs";
20
20
  import { readSession } from "./server/auth/session.mjs";
21
- import {
22
- pageModulePreloads,
23
- productionHtmlTemplate,
24
- } from "./server/client-manifest.mjs";
21
+ import { productionHtmlTemplate } from "./server/client-manifest.mjs";
22
+ import { documentStyleTags } from "./server/dev-styles.mjs";
25
23
  import { handleEmailRender } from "./server/email.mjs";
24
+ import { HOMEPAGE } from "./server/homepage.mjs";
26
25
  import { handleTester } from "./server/tester.mjs";
27
26
  import {
28
27
  createInertiaPage,
28
+ htmlHeaders,
29
29
  inertiaAppHtml,
30
30
  inertiaHeaders,
31
31
  redirectFrontendVisit,
@@ -36,13 +36,6 @@ export * from "./server/inertia.mjs";
36
36
  const INERTIA_HEADER = "x-inertia";
37
37
  const JSON_TYPE = "application/json";
38
38
  const PROJECT_ROOT = fileURLToPath(new URL(".", import.meta.url));
39
- const MODULE_REGISTRY_PATH = join(
40
- PROJECT_ROOT,
41
- "generated-frontend-modules.json",
42
- );
43
- const MODULE_REGISTRY = existsSync(MODULE_REGISTRY_PATH)
44
- ? JSON.parse(readFileSync(MODULE_REGISTRY_PATH, "utf8"))
45
- : { modules: [] };
46
39
  const MIME_TYPES = {
47
40
  ".css": "text/css",
48
41
  ".js": "text/javascript",
@@ -74,19 +67,6 @@ let vite;
74
67
  let vitePromise;
75
68
  let frontendHttpServer;
76
69
 
77
- function findHomepage(options) {
78
- if (!options || typeof options !== "object") return undefined;
79
- if (typeof options.homepage === "string") return options.homepage;
80
- return Object.values(options)
81
- .map(findHomepage)
82
- .find((homepage) => homepage !== undefined);
83
- }
84
-
85
- const HOMEPAGE =
86
- MODULE_REGISTRY.modules
87
- .map((module) => findHomepage(module.options))
88
- .find(Boolean) ?? "/";
89
-
90
70
  class BackendResponseError extends Error {
91
71
  constructor(status) {
92
72
  super(`DMS backend returned ${status}`);
@@ -358,7 +338,7 @@ export async function renderHtml(page, requestUrl, serverFetch) {
358
338
  }
359
339
  if (rendered.redirect)
360
340
  return { html: "", status: 200, redirect: rendered.redirect };
361
- const preloads = devServer ? "" : pageModulePreloads(page, template);
341
+ const preloads = await documentStyleTags(devServer, page, template);
362
342
  const html = template
363
343
  .replace("<title>Antelope DMS</title>", rendered.head.headTags)
364
344
  .replace("<html", `<html ${rendered.head.htmlAttrs}`)
@@ -410,13 +390,7 @@ async function writeBackendError(error, request, response) {
410
390
  request.url,
411
391
  serverComponentFetch(request),
412
392
  );
413
- return writeContent(
414
- request,
415
- response,
416
- status,
417
- { "content-type": "text/html", vary: "X-Inertia" },
418
- html,
419
- );
393
+ return writeContent(request, response, status, htmlHeaders(), html);
420
394
  }
421
395
  const payload =
422
396
  error instanceof UpstreamError || error instanceof RequestBodyError
@@ -481,7 +455,7 @@ export async function handleRequest(request, response) {
481
455
  request,
482
456
  response,
483
457
  rendered.status,
484
- { "content-type": "text/html", vary: "X-Inertia" },
458
+ htmlHeaders(),
485
459
  rendered.html,
486
460
  );
487
461
  }
@@ -2,7 +2,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import ui from "@nuxt/ui/vite";
4
4
  import vue from "@vitejs/plugin-vue";
5
- import { defineConfig } from "vite";
5
+ import { defineConfig, type Plugin } from "vite";
6
+ import { orderHeadForFirstPaint } from "./head-order.mjs";
6
7
 
7
8
  interface FrontendModuleRegistryEntry {
8
9
  id: string;
@@ -66,6 +67,38 @@ const optimizedDependencies = [
66
67
  "json-schema-to-zod",
67
68
  "striptags",
68
69
  ];
70
+
71
+ /**
72
+ * Entry points the dependency optimizer crawls at startup.
73
+ *
74
+ * Every materialized module brings its own dependency set — subpath exports and
75
+ * transitive CommonJS included — and none of it can be listed in
76
+ * `optimizedDependencies` by hand, because the modules are only known at
77
+ * materialization time. Leaving the crawl off and letting Vite discover them as
78
+ * pages load meant the first visit to a cold page re-ran the optimizer
79
+ * mid-request: the chunks already in flight answered 504 and the client was
80
+ * told to reload the whole document. Crawling the module sources once, at
81
+ * startup, pays that cost a single time and in a place where it reads as
82
+ * startup rather than as a crash.
83
+ */
84
+ const optimizerEntries = [
85
+ resolve(__dirname, "main.ts"),
86
+ ...moduleRoots.map((root) => resolve(root, "dms.frontend.ts")),
87
+ ...frontendSourceRoots.flatMap((root) => [
88
+ resolve(root, "app/**/*.vue"),
89
+ resolve(root, "app/**/*.ts"),
90
+ ]),
91
+ ];
92
+
93
+ function paintBeforeHydrate(): Plugin {
94
+ return {
95
+ name: "dms-paint-before-hydrate",
96
+ apply: "build",
97
+ enforce: "post",
98
+ transformIndexHtml: { order: "post", handler: orderHeadForFirstPaint },
99
+ };
100
+ }
101
+
69
102
  const uiLinkImport = "@nuxt/ui/components/Link.vue";
70
103
  const uiInertiaLinkImport = resolve(
71
104
  __dirname,
@@ -131,6 +164,7 @@ export default defineConfig({
131
164
  vueTemplate: true,
132
165
  },
133
166
  }),
167
+ paintBeforeHydrate(),
134
168
  ],
135
169
  resolve: {
136
170
  dedupe: ["vue", "reka-ui", "@nuxt/ui"],
@@ -152,8 +186,7 @@ export default defineConfig({
152
186
  },
153
187
  optimizeDeps: {
154
188
  include: optimizedDependencies,
155
- // Avoid crawling every module page at startup, but optimize dependencies as pages load.
156
- entries: [],
189
+ entries: optimizerEntries,
157
190
  },
158
191
  ssr: { noExternal: ["@nuxt/icon", "@nuxt/ui"] },
159
192
  server: { strictPort: true, allowedHosts: [".onamp.dev"] },