@antelopejs/dms-frontend 0.2.1 → 0.2.3

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
@@ -94,9 +94,11 @@ URL, either through `-b` or through the enclosing antelope project's
94
94
  `.antelope/dev.json`, and say so on exit 1 when they have neither;
95
95
  `verify-source` needs `--layer` instead.
96
96
 
97
- The CLI checks npm for a newer release at most once a day a failed lookup
98
- counts as the day's attempt and prints a one-line notice on stderr. The
99
- throttle stamp lives at `~/.antelopejs/dms-frontend/update-check.json`. Set
97
+ The CLI checks npm for a newer release at most once a day and prints a one-line
98
+ notice on stderr. A lookup that comes back empty offline, throttled, or simply
99
+ raced by a command that blocked the event loop past the deadline — is retried
100
+ after an hour instead of counting as the day's attempt. The throttle stamp lives
101
+ at `~/.antelopejs/dms-frontend/update-check.json`. Set
100
102
  `NO_UPDATE_NOTIFIER=1`, pass `--no-update-check`, or run under `CI` to turn the
101
103
  check off.
102
104
 
package/dist/config.js CHANGED
@@ -39,6 +39,7 @@ exports.TEMPLATE_FILES = [
39
39
  ["frontend-module.ts", "frontend-module.ts"],
40
40
  ["globals.d.ts", "globals.d.ts"],
41
41
  ["compress-assets.mjs", "compress-assets.mjs"],
42
+ ["head-order.mjs", "head-order.mjs"],
42
43
  ["email-renderer.ts", "email-renderer.ts"],
43
44
  ["email-runtime.ts", "email-runtime.ts"],
44
45
  ["email-locales.ts", "email-locales.ts"],
package/dist/fs-sync.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.stripExtendedLengthPrefix = stripExtendedLengthPrefix;
7
+ exports.toPosixPath = toPosixPath;
7
8
  exports.isBlocklistedCopyPath = isBlocklistedCopyPath;
8
9
  exports.sanitizedPackageContent = sanitizedPackageContent;
9
10
  exports.filesIdentical = filesIdentical;
@@ -18,11 +19,14 @@ const config_1 = require("./config");
18
19
  function stripExtendedLengthPrefix(p) {
19
20
  return p.replace(/^\\\\\?\\/, "");
20
21
  }
22
+ function toPosixPath(path, separator = node_path_1.sep) {
23
+ return separator === "/" ? path : path.split(separator).join("/");
24
+ }
21
25
  function isBlocklistedCopyPath(src, srcPath) {
22
26
  const rel = (0, node_path_1.relative)((0, node_path_1.resolve)(stripExtendedLengthPrefix(src)), (0, node_path_1.resolve)(stripExtendedLengthPrefix(srcPath)));
23
27
  if (!rel || rel.startsWith(".."))
24
28
  return false;
25
- return config_1.layerCopyIgnore.ignores(rel.split(node_path_1.sep).join("/"));
29
+ return config_1.layerCopyIgnore.ignores(toPosixPath(rel));
26
30
  }
27
31
  function sanitizedPackageContent(raw) {
28
32
  const pkg = JSON.parse(raw);
@@ -198,7 +198,7 @@ function discoverAssets(registry, directory, extensions) {
198
198
  .sort()
199
199
  .map((relativePath) => ({
200
200
  moduleId: module.id,
201
- relativePath: `${assetRoot.relativePrefix}${(0, node_path_1.join)(directory, relativePath).split(node_path_1.sep).join("/")}`,
201
+ relativePath: `${assetRoot.relativePrefix}${(0, fs_sync_1.toPosixPath)((0, node_path_1.join)(directory, relativePath))}`,
202
202
  }));
203
203
  });
204
204
  });
@@ -259,7 +259,7 @@ function writeShortcutExports(workspaceDir, registry) {
259
259
  }
260
260
  function writeDmsMainCss(workspaceDir, layers) {
261
261
  const sources = layers
262
- .map((layer) => `@source "${(0, layers_1.getLayerWorkspacePath)(workspaceDir, layer)}/${config_1.TAILWIND_SOURCE_GLOB}";`)
262
+ .map((layer) => `@source "${(0, fs_sync_1.toPosixPath)((0, layers_1.getLayerWorkspacePath)(workspaceDir, layer))}/${config_1.TAILWIND_SOURCE_GLOB}";`)
263
263
  .join("\n");
264
264
  const content = `@import "tailwindcss";
265
265
  @import "@nuxt/ui";
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.NO_UPDATE_CHECK_FLAG = exports.UPDATE_CHECK_TIMEOUT_MS = exports.UPDATE_CHECK_INTERVAL_MS = exports.UPDATE_CHECK_PACKAGE = void 0;
6
+ exports.NO_UPDATE_CHECK_FLAG = exports.UPDATE_CHECK_TIMEOUT_MS = exports.UPDATE_CHECK_RETRY_INTERVAL_MS = exports.UPDATE_CHECK_INTERVAL_MS = exports.UPDATE_CHECK_PACKAGE = void 0;
7
7
  exports.stripUpdateCheckFlag = stripUpdateCheckFlag;
8
8
  exports.isUpdateCheckEnabled = isUpdateCheckEnabled;
9
9
  exports.updateCheckCacheFile = updateCheckCacheFile;
@@ -18,6 +18,7 @@ const config_1 = require("./config");
18
18
  exports.UPDATE_CHECK_PACKAGE = "@antelopejs/dms-frontend";
19
19
  const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org/";
20
20
  exports.UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
21
+ exports.UPDATE_CHECK_RETRY_INTERVAL_MS = 60 * 60 * 1000;
21
22
  exports.UPDATE_CHECK_TIMEOUT_MS = 3000;
22
23
  exports.NO_UPDATE_CHECK_FLAG = "--no-update-check";
23
24
  const HELP_OR_VERSION_FLAGS = ["--help", "-h", "--version", "-v"];
@@ -73,7 +74,11 @@ function readCache(file) {
73
74
  if (latestVersion !== undefined && typeof latestVersion !== "string") {
74
75
  return undefined;
75
76
  }
76
- return { checkedAt: parsed.checkedAt, latestVersion };
77
+ const succeeded = parsed.succeeded;
78
+ if (succeeded !== undefined && typeof succeeded !== "boolean") {
79
+ return undefined;
80
+ }
81
+ return { checkedAt: parsed.checkedAt, latestVersion, succeeded };
77
82
  }
78
83
  catch {
79
84
  return undefined;
@@ -156,13 +161,20 @@ async function checkForUpdate(options) {
156
161
  return;
157
162
  const cached = readCache(cacheFile);
158
163
  const checkedAt = now();
159
- const withinInterval = cached !== undefined &&
160
- checkedAt - cached.checkedAt < exports.UPDATE_CHECK_INTERVAL_MS;
164
+ const lastAttemptAnswered = cached?.succeeded ?? cached?.latestVersion !== undefined;
165
+ const interval = lastAttemptAnswered
166
+ ? exports.UPDATE_CHECK_INTERVAL_MS
167
+ : exports.UPDATE_CHECK_RETRY_INTERVAL_MS;
168
+ const withinInterval = cached !== undefined && checkedAt - cached.checkedAt < interval;
161
169
  let latestVersion = cached?.latestVersion;
162
170
  if (!withinInterval) {
163
- latestVersion =
164
- (await fetchLatestVersion(exports.UPDATE_CHECK_PACKAGE)) ?? latestVersion;
165
- writeCache(cacheFile, { checkedAt, latestVersion });
171
+ const published = await fetchLatestVersion(exports.UPDATE_CHECK_PACKAGE);
172
+ latestVersion = published ?? latestVersion;
173
+ writeCache(cacheFile, {
174
+ checkedAt,
175
+ latestVersion,
176
+ succeeded: published !== undefined,
177
+ });
166
178
  }
167
179
  if (latestVersion && isNewer(currentVersion, latestVersion)) {
168
180
  write(formatNotice(currentVersion, latestVersion));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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,106 @@
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, sep } 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
+ * `ENTRY_STYLESHEET_ID` is matched against the `data-vite-dev-id` Vite puts on
37
+ * its own copy, and Vite normalizes every module id onto POSIX separators — so
38
+ * the native path `join` returns has to be normalized too, or the handover
39
+ * never happens on Windows and both copies stay in the document.
40
+ */
41
+ const ENTRY_STYLESHEET = "/dms-main.css";
42
+ const ENTRY_STYLESHEET_ID = join(PROJECT_ROOT, "dms-main.css")
43
+ .split(sep)
44
+ .join("/");
45
+
46
+ function ssrModuleGraph(devServer) {
47
+ return devServer.environments?.ssr?.moduleGraph ?? devServer.moduleGraph;
48
+ }
49
+
50
+ function directUrl(url) {
51
+ return `${url}${url.includes("?") ? "&" : "?"}direct`;
52
+ }
53
+
54
+ function styleModules(devServer) {
55
+ const graph = ssrModuleGraph(devServer);
56
+ if (!graph) return [];
57
+ return [...graph.urlToModuleMap.entries()]
58
+ .filter(([url]) => SFC_STYLE.test(url) || STYLE_EXTENSION.test(url))
59
+ .map(([url, module]) => ({ url, id: module?.id ?? url }));
60
+ }
61
+
62
+ function linkTag({ url, id }) {
63
+ return `<link rel="stylesheet" ${STYLE_ATTRIBUTE}="${id}" href="${directUrl(url)}">`;
64
+ }
65
+
66
+ /**
67
+ * A single-file component's `<style>` block is served with a JavaScript content
68
+ * type even when requested directly, so a browser would refuse it as a
69
+ * stylesheet. They are small; inline them instead. A block that could close its
70
+ * own tag is dropped rather than escaped — CSS has no escape that is valid in
71
+ * every position, and a scoped component rule is not worth the risk.
72
+ */
73
+ async function inlineTag(devServer, { url, id }) {
74
+ const result = await devServer
75
+ .transformRequest(directUrl(url))
76
+ .catch(() => undefined);
77
+ const css = result?.code;
78
+ if (!css || /<\/style/i.test(css)) return "";
79
+ return `<style ${STYLE_ATTRIBUTE}="${id}">${css}</style>`;
80
+ }
81
+
82
+ /** Style tags for everything the development render touched, entry sheet first. */
83
+ export async function developmentStyleTags(devServer) {
84
+ const modules = styleModules(devServer);
85
+ const links = [
86
+ { url: ENTRY_STYLESHEET, id: ENTRY_STYLESHEET_ID },
87
+ ...modules.filter(({ url }) => !SFC_STYLE.test(url)),
88
+ ].map(linkTag);
89
+ const inlined = await Promise.all(
90
+ modules
91
+ .filter(({ url }) => SFC_STYLE.test(url))
92
+ .map((module) => inlineTag(devServer, module)),
93
+ );
94
+ return [...links, ...inlined].join("");
95
+ }
96
+
97
+ /**
98
+ * 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.
101
+ */
102
+ export async function documentStyleTags(devServer, page, template) {
103
+ return devServer
104
+ ? developmentStyleTags(devServer)
105
+ : pageModulePreloads(page, template);
106
+ }
@@ -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"] ||
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer } from "node:http";
3
- import { extname, join, resolve } from "node:path";
3
+ import { extname, join, resolve, sep } from "node:path";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { fileURLToPath } from "node:url";
@@ -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}`);
@@ -274,7 +254,9 @@ function acceptedAsset(pathname, request) {
274
254
 
275
255
  function serveAsset(pathname, request, response) {
276
256
  const selected = acceptedAsset(pathname, request);
277
- const assetRoot = `${resolve(PROJECT_ROOT, "dist/client")}/`;
257
+ // Native separator: `selected.source` comes from `resolve`, so a hardcoded
258
+ // `/` here would make the containment check fail for every asset on Windows.
259
+ const assetRoot = `${resolve(PROJECT_ROOT, "dist/client")}${sep}`;
278
260
  if (
279
261
  !selected.source.startsWith(assetRoot) ||
280
262
  !existsSync(selected.file) ||
@@ -358,7 +340,7 @@ export async function renderHtml(page, requestUrl, serverFetch) {
358
340
  }
359
341
  if (rendered.redirect)
360
342
  return { html: "", status: 200, redirect: rendered.redirect };
361
- const preloads = devServer ? "" : pageModulePreloads(page, template);
343
+ const preloads = await documentStyleTags(devServer, page, template);
362
344
  const html = template
363
345
  .replace("<title>Antelope DMS</title>", rendered.head.headTags)
364
346
  .replace("<html", `<html ${rendered.head.htmlAttrs}`)
@@ -410,13 +392,7 @@ async function writeBackendError(error, request, response) {
410
392
  request.url,
411
393
  serverComponentFetch(request),
412
394
  );
413
- return writeContent(
414
- request,
415
- response,
416
- status,
417
- { "content-type": "text/html", vary: "X-Inertia" },
418
- html,
419
- );
395
+ return writeContent(request, response, status, htmlHeaders(), html);
420
396
  }
421
397
  const payload =
422
398
  error instanceof UpstreamError || error instanceof RequestBodyError
@@ -481,7 +457,7 @@ export async function handleRequest(request, response) {
481
457
  request,
482
458
  response,
483
459
  rendered.status,
484
- { "content-type": "text/html", vary: "X-Inertia" },
460
+ htmlHeaders(),
485
461
  rendered.html,
486
462
  );
487
463
  }
@@ -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, normalizePath, type Plugin } from "vite";
6
+ import { orderHeadForFirstPaint } from "./head-order.mjs";
6
7
 
7
8
  interface FrontendModuleRegistryEntry {
8
9
  id: string;
@@ -37,6 +38,14 @@ const stableModuleAliases = Object.fromEntries(
37
38
  return [[`#${moduleName}`, module.root]];
38
39
  }),
39
40
  );
41
+ /**
42
+ * Globs are POSIX, always.
43
+ *
44
+ * `resolve` returns a native path, so on Windows every pattern below would
45
+ * carry backslashes — which a glob matcher reads as escape characters, not as
46
+ * separators, and which therefore match nothing at all. `normalizePath` is
47
+ * Vite's own answer to this and is the identity function on POSIX.
48
+ */
40
49
  const importDirectories = frontendSourceRoots.flatMap((root) =>
41
50
  [
42
51
  "app/composables/**/*",
@@ -44,7 +53,7 @@ const importDirectories = frontendSourceRoots.flatMap((root) =>
44
53
  "app/utils/**/*",
45
54
  "app/build/composables/**/*",
46
55
  "app/build/types/**/*",
47
- ].map((glob) => ({ glob: resolve(root, glob), types: true })),
56
+ ].map((glob) => ({ glob: normalizePath(resolve(root, glob)), types: true })),
48
57
  );
49
58
  const optimizedDependencies = [
50
59
  "vue",
@@ -66,6 +75,41 @@ const optimizedDependencies = [
66
75
  "json-schema-to-zod",
67
76
  "striptags",
68
77
  ];
78
+
79
+ /**
80
+ * Entry points the dependency optimizer crawls at startup.
81
+ *
82
+ * Every materialized module brings its own dependency set — subpath exports and
83
+ * transitive CommonJS included — and none of it can be listed in
84
+ * `optimizedDependencies` by hand, because the modules are only known at
85
+ * materialization time. Leaving the crawl off and letting Vite discover them as
86
+ * pages load meant the first visit to a cold page re-ran the optimizer
87
+ * mid-request: the chunks already in flight answered 504 and the client was
88
+ * told to reload the whole document. Crawling the module sources once, at
89
+ * startup, pays that cost a single time and in a place where it reads as
90
+ * startup rather than as a crash.
91
+ *
92
+ * Normalized for the same reason as `importDirectories`: Vite hands these
93
+ * patterns straight to its glob matcher without touching the separators.
94
+ */
95
+ const optimizerEntries = [
96
+ resolve(__dirname, "main.ts"),
97
+ ...moduleRoots.map((root) => resolve(root, "dms.frontend.ts")),
98
+ ...frontendSourceRoots.flatMap((root) => [
99
+ resolve(root, "app/**/*.vue"),
100
+ resolve(root, "app/**/*.ts"),
101
+ ]),
102
+ ].map(normalizePath);
103
+
104
+ function paintBeforeHydrate(): Plugin {
105
+ return {
106
+ name: "dms-paint-before-hydrate",
107
+ apply: "build",
108
+ enforce: "post",
109
+ transformIndexHtml: { order: "post", handler: orderHeadForFirstPaint },
110
+ };
111
+ }
112
+
69
113
  const uiLinkImport = "@nuxt/ui/components/Link.vue";
70
114
  const uiInertiaLinkImport = resolve(
71
115
  __dirname,
@@ -92,7 +136,13 @@ export default defineConfig({
92
136
  imports: [
93
137
  "vue",
94
138
  {
95
- [resolve(__dirname, "frontend-module.ts")]: [
139
+ // This key is inlined verbatim as the module specifier of the
140
+ // import the auto-importer prepends to each file. A native Windows
141
+ // path would land inside a single-quoted JavaScript string with its
142
+ // backslashes intact, where `\U`, `\M` and `\f` are read as escape
143
+ // sequences: the specifier the bundler then resolves is a mangled
144
+ // path that cannot exist. Keep it POSIX.
145
+ [normalizePath(resolve(__dirname, "frontend-module.ts"))]: [
96
146
  "$fetch",
97
147
  "abortNavigation",
98
148
  "clearError",
@@ -131,6 +181,7 @@ export default defineConfig({
131
181
  vueTemplate: true,
132
182
  },
133
183
  }),
184
+ paintBeforeHydrate(),
134
185
  ],
135
186
  resolve: {
136
187
  dedupe: ["vue", "reka-ui", "@nuxt/ui"],
@@ -152,8 +203,7 @@ export default defineConfig({
152
203
  },
153
204
  optimizeDeps: {
154
205
  include: optimizedDependencies,
155
- // Avoid crawling every module page at startup, but optimize dependencies as pages load.
156
- entries: [],
206
+ entries: optimizerEntries,
157
207
  },
158
208
  ssr: { noExternal: ["@nuxt/icon", "@nuxt/ui"] },
159
209
  server: { strictPort: true, allowedHosts: [".onamp.dev"] },
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import vue from "@vitejs/plugin-vue";
4
4
  import AutoImport from "unplugin-auto-import/vite";
5
- import { defineConfig } from "vite";
5
+ import { defineConfig, normalizePath } from "vite";
6
6
 
7
7
  interface FrontendModuleRegistryEntry {
8
8
  id: string;
@@ -55,7 +55,10 @@ export default defineConfig({
55
55
  dts: false,
56
56
  imports: [
57
57
  {
58
- [resolve(__dirname, "email-runtime.ts")]: [
58
+ // Inlined verbatim as a module specifier in the generated import, so
59
+ // it has to be POSIX: a native Windows path would reach the parser
60
+ // with its backslashes read as string escapes. See vite.config.ts.
61
+ [normalizePath(resolve(__dirname, "email-runtime.ts"))]: [
59
62
  "useDmsAppConfig",
60
63
  "useDmsRuntimeConfig",
61
64
  "defineAppConfig",