@antelopejs/dms-frontend 0.2.6 → 0.2.8

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
@@ -133,6 +133,17 @@ export default frontendModule;
133
133
 
134
134
  The SDK also exposes `use` for Vue plugins. Entries execute by descending manifest priority, then stable module id. Modules without `dms.frontend.ts` are skipped by the generated loader. Registered page keys match a route's `fullSlug`, request path, or `default`; unregistered pages and components use the generic card renderer.
135
135
 
136
+ ### Components rendered inside `<svg>`
137
+
138
+ Modules usually register their components lazily, with `defineAsyncComponent`, and the page is rendered under a `<Suspense>`. Vue mounts an async component that resolves under a `<Suspense>` with the `<Suspense>`'s element namespace rather than the namespace of the place it is rendered: a component resolved by name (`resolveComponent` or a template tag) whose root is an SVG element (`<path>`, `<g>`, …) is created as an HTML element inside the `<svg>`, and the browser draws nothing ([vuejs/core#15639](https://github.com/vuejs/core/issues/15639)). Import such components directly instead, for example the node and edge types passed to Vue Flow:
139
+
140
+ ```ts
141
+ import { markRaw } from "vue";
142
+ import RelationEdge from "./RelationEdge.vue";
143
+
144
+ const edgeTypes = markRaw({ relation: RelationEdge });
145
+ ```
146
+
136
147
  ### Layer aliases outside the workspace
137
148
 
138
149
  Every layer a module ships under `layers/<name>/` answers to `#<name>` inside
package/dist/config.js CHANGED
@@ -52,6 +52,7 @@ exports.TEMPLATE_FILES = [
52
52
  ["npmrc", ".npmrc"],
53
53
  ["gitignore", ".gitignore"],
54
54
  ["pnpm-workspace.yaml", "pnpm-workspace.yaml"],
55
+ ["patches", "patches"],
55
56
  ];
56
57
  exports.LAYERS_SUBDIR = "frontend-modules";
57
58
  exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
@@ -1,6 +1,6 @@
1
1
  import { type SpawnOptions } from "node:child_process";
2
2
  import { ResolvedLayer } from "./workspace";
3
- export declare function computeDepsHash(layerPaths: string[], workspacePackage?: string): string;
3
+ export declare function computeDepsHash(layerPaths: string[], workspacePackage?: string, installInputs?: readonly string[]): string;
4
4
  export declare function needsInstall(workspaceDir: string, layerPaths: string[], force: boolean): boolean;
5
5
  export declare function saveDepsHash(workspaceDir: string, layerPaths: string[]): void;
6
6
  export declare function installDeps(workspaceDir: string): void;
@@ -16,10 +16,25 @@ const manifest_1 = require("./manifest");
16
16
  const materialize_1 = require("./materialize");
17
17
  const workspace_1 = require("./workspace");
18
18
  const config_1 = require("./config");
19
- function computeDepsHash(layerPaths, workspacePackage) {
19
+ const WORKSPACE_PNPM_CONFIG = "pnpm-workspace.yaml";
20
+ const WORKSPACE_PATCHES_DIR = "patches";
21
+ function workspaceInstallInputs(workspaceDir) {
22
+ const patchesDir = (0, node_path_1.join)(workspaceDir, WORKSPACE_PATCHES_DIR);
23
+ const patches = (0, node_fs_1.existsSync)(patchesDir)
24
+ ? (0, node_fs_1.readdirSync)(patchesDir)
25
+ .sort()
26
+ .map((name) => (0, node_path_1.join)(patchesDir, name))
27
+ : [];
28
+ return [(0, node_path_1.join)(workspaceDir, WORKSPACE_PNPM_CONFIG), ...patches];
29
+ }
30
+ function computeDepsHash(layerPaths, workspacePackage, installInputs = []) {
20
31
  const hash = (0, node_crypto_1.createHash)("sha256");
21
32
  if (workspacePackage && (0, node_fs_1.existsSync)(workspacePackage))
22
33
  hash.update((0, node_fs_1.readFileSync)(workspacePackage));
34
+ for (const input of installInputs) {
35
+ if ((0, node_fs_1.existsSync)(input))
36
+ hash.update((0, node_fs_1.readFileSync)(input));
37
+ }
23
38
  for (const layerPath of [...layerPaths].sort()) {
24
39
  const pkgPath = (0, node_path_1.join)(layerPath, "package.json");
25
40
  if ((0, node_fs_1.existsSync)(pkgPath)) {
@@ -36,12 +51,12 @@ function needsInstall(workspaceDir, layerPaths, force) {
36
51
  return true;
37
52
  if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(workspaceDir, "node_modules")))
38
53
  return true;
39
- const currentHash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"));
54
+ const currentHash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"), workspaceInstallInputs(workspaceDir));
40
55
  const savedHash = (0, node_fs_1.readFileSync)(hashFile, "utf-8").trim();
41
56
  return currentHash !== savedHash;
42
57
  }
43
58
  function saveDepsHash(workspaceDir, layerPaths) {
44
- const hash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"));
59
+ const hash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"), workspaceInstallInputs(workspaceDir));
45
60
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, config_1.DEPS_HASH_FILE), hash);
46
61
  }
47
62
  function installDeps(workspaceDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  "dist/",
28
+ "!dist/.tsbuildinfo",
28
29
  "templates/"
29
30
  ],
30
31
  "main": "./dist/common.js",
@@ -52,14 +53,14 @@
52
53
  "registry": "https://registry.npmjs.org/"
53
54
  },
54
55
  "scripts": {
55
- "build": "rimraf dist && tsc -p tsconfig.build.json",
56
+ "build": "rimraf dist && tsc",
56
57
  "prepack": "pnpm run build",
57
58
  "release": "pnpm run lint && pnpm run test && pnpm run prepack && release-it",
58
59
  "test": "node --import tsx --test tests/*.test.ts tests/*.test.mts",
59
60
  "test:real-source": "pnpm run build && node dist/verify-source-runner.js",
60
61
  "format": "oxfmt .",
61
62
  "lint": "oxlint && oxfmt --check .",
62
- "typecheck": "tsc -p tsconfig.json",
63
+ "typecheck": "tsc --noEmit",
63
64
  "lint:fix": "oxlint --fix --max-warnings=1000000 && oxfmt .",
64
65
  "knip": "knip --dependencies",
65
66
  "knip:all": "knip"
@@ -94,7 +95,7 @@
94
95
  "release-it-changelogen": "^0.1.0",
95
96
  "rimraf": "^6.1.2",
96
97
  "tsx": "^4.20.6",
97
- "typescript": "^5.9.3",
98
+ "typescript": "^6.0.3",
98
99
  "unplugin-auto-import": "^20.2.0",
99
100
  "vite": "^7.2.4",
100
101
  "vue": "^3.5.25",
@@ -31,8 +31,10 @@ import {
31
31
  installDmsPlugins,
32
32
  preloadDmsPage,
33
33
  provideDmsFrontendRuntime,
34
+ resolveDmsAsyncComponents,
34
35
  resolveDmsComponent,
35
36
  serializeDmsAsyncData,
37
+ trackDmsAsyncComponents,
36
38
  useDmsAppConfig,
37
39
  useDmsState,
38
40
  useError,
@@ -72,6 +74,7 @@ export type DmsServerFetch = (
72
74
  ) => Promise<unknown>;
73
75
 
74
76
  const SSR_ASYNC_DATA_ID = "dms-ssr-async-data";
77
+ export const SSR_ASYNC_COMPONENTS_ID = "dms-ssr-async-components";
75
78
  const DEFAULT_LOCALE = "en";
76
79
 
77
80
  function pageLocale(props: DmsPageProps): string {
@@ -90,6 +93,15 @@ function readDmsAsyncData(): Record<string, unknown> {
90
93
  return data;
91
94
  }
92
95
 
96
+ function readDmsAsyncComponents(): string[] {
97
+ if (typeof document === "undefined") return [];
98
+ const element = document.getElementById(SSR_ASYNC_COMPONENTS_ID);
99
+ if (!element?.textContent) return [];
100
+ const names = JSON.parse(element.textContent) as string[];
101
+ element.remove();
102
+ return names;
103
+ }
104
+
93
105
  const DmsInertiaPage = defineComponent({
94
106
  name: "DmsInertiaPage",
95
107
  inheritAttrs: false,
@@ -227,6 +239,13 @@ export async function configureDmsApp(
227
239
  options.app.runWithContext(() =>
228
240
  hydrateDmsPageProps(options.initialPageProps, options.initialPageUrl),
229
241
  );
242
+ // Resolve, before the app mounts, every async component the server
243
+ // rendered: the page, its layout, and the components the render reached.
244
+ // Hydration then adopts the server-rendered markup instead of discarding it.
245
+ await Promise.all([
246
+ preloadDmsPage(options.initialPageProps),
247
+ resolveDmsAsyncComponents(readDmsAsyncComponents()),
248
+ ]);
230
249
  const mounted = await installDmsPlugins(
231
250
  options.app,
232
251
  i18n.global,
@@ -237,4 +256,4 @@ export async function configureDmsApp(
237
256
  return { mounted, runtime };
238
257
  }
239
258
 
240
- export { serializeDmsAsyncData };
259
+ export { serializeDmsAsyncData, trackDmsAsyncComponents };
@@ -13,6 +13,7 @@ import { type FetchOptions, ofetch } from "ofetch";
13
13
  import {
14
14
  type App,
15
15
  type Component,
16
+ type ComponentPublicInstance,
16
17
  type ComputedRef,
17
18
  computed,
18
19
  defineComponent,
@@ -313,6 +314,8 @@ export interface DmsFrontendRuntime {
313
314
  pendingNavigation?: Promise<void>;
314
315
  hasNavigationListener: boolean;
315
316
  pageVersion: number;
317
+ /** The `Cookie` header of the request a server runtime renders. */
318
+ requestCookies?: string;
316
319
  }
317
320
 
318
321
  const components = new Map<string, DmsComponentRegistration>();
@@ -349,8 +352,10 @@ export function createDmsFrontendRuntime(
349
352
  serverFetch?: typeof ofetch,
350
353
  hydratedAsyncData: Record<string, unknown> = {},
351
354
  isServer = false,
355
+ requestCookies?: string,
352
356
  ): DmsFrontendRuntime {
353
357
  return {
358
+ requestCookies,
354
359
  sharedState: new Map(),
355
360
  asyncData: new Map(),
356
361
  asyncDataPromises: new Map(),
@@ -519,16 +524,24 @@ async function visit(
519
524
  options: DmsNavigationOptions = {},
520
525
  ): Promise<void> {
521
526
  const url = locationToUrl(runtime, to);
522
- const result = await runMiddleware(runtime, parseRoute(runtime, url));
527
+ const target = parseRoute(runtime, url);
528
+ const result = await runMiddleware(runtime, target);
523
529
  if (result === false) return;
524
530
  if (result !== undefined) return visit(runtime, result, options);
525
531
  if (runtime.isServer) {
526
532
  runtime.serverRedirect = url;
527
533
  return;
528
534
  }
535
+ // Like Vue Router, a navigation that only changes the query or hash keeps
536
+ // the page mounted: without preserveState, Inertia remounts the page
537
+ // component on every visit and its local state (open tabs, selection,
538
+ // inputs) is lost.
539
+ const samePath = target.path === runtime.route.path;
529
540
  await new Promise<void>((resolve) =>
530
541
  inertiaRouter.visit(url, {
531
542
  ...options,
543
+ preserveState: options.preserveState ?? samePath,
544
+ preserveScroll: options.preserveScroll ?? samePath,
532
545
  onFinish: (completedVisit) => {
533
546
  options.onFinish?.(completedVisit);
534
547
  resolve();
@@ -1004,44 +1017,94 @@ export function useDmsApp(): DmsAppContext {
1004
1017
  return contextual ?? (useDmsRuntime().appContext as DmsAppContext);
1005
1018
  }
1006
1019
 
1007
- export function useDmsCookie<T = string | null>(
1020
+ const COOKIE_STATE_PREFIX = "dms-cookie:";
1021
+ // A browser tab runs one application, so its cookie refs are shared by every
1022
+ // caller; a server shares them within the request it renders instead.
1023
+ const browserCookieRefs = new Map<string, Ref<unknown>>();
1024
+
1025
+ function readCookie(
1026
+ source: string | undefined,
1008
1027
  name: string,
1009
- options: DmsCookieOptions<T> = {},
1010
- ): Ref<T> {
1011
- if (typeof document === "undefined")
1012
- return ref(options.default?.() ?? null) as Ref<T>;
1013
- const match = document.cookie
1014
- .split("; ")
1028
+ ): string | undefined {
1029
+ const match = source
1030
+ ?.split(/;\s*/)
1015
1031
  .find((entry) => entry.startsWith(`${name}=`));
1016
- const stored = match
1017
- ? decodeURIComponent(match.slice(name.length + 1))
1018
- : undefined;
1019
- let parsed: T;
1032
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
1033
+ }
1034
+
1035
+ function parseCookieValue<T>(
1036
+ stored: string | undefined,
1037
+ options: DmsCookieOptions<T>,
1038
+ ): T {
1039
+ if (stored === undefined) return (options.default?.() ?? null) as T;
1020
1040
  try {
1021
- parsed =
1022
- stored === undefined
1023
- ? ((options.default?.() ?? null) as T)
1024
- : JSON.parse(stored);
1041
+ return JSON.parse(stored) as T;
1025
1042
  } catch {
1026
- parsed = stored as T;
1043
+ return stored as T;
1027
1044
  }
1028
- const value = ref(parsed) as Ref<T>;
1045
+ }
1046
+
1047
+ function writeCookie<T>(name: string, next: T, options: DmsCookieOptions<T>) {
1048
+ const attributes = [
1049
+ `path=${options.path ?? "/"}`,
1050
+ options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
1051
+ options.sameSite ? `samesite=${options.sameSite}` : "",
1052
+ options.secure ? "secure" : "",
1053
+ ].filter(Boolean);
1054
+ // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
1055
+ document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
1056
+ }
1057
+
1058
+ function createCookieRef<T>(
1059
+ name: string,
1060
+ source: string | undefined,
1061
+ options: DmsCookieOptions<T>,
1062
+ isServer: boolean,
1063
+ ): Ref<T> {
1064
+ const value = ref(
1065
+ parseCookieValue(readCookie(source, name), options),
1066
+ ) as Ref<T>;
1029
1067
  return computed({
1030
1068
  get: () => value.value,
1031
1069
  set: (next) => {
1032
1070
  value.value = next;
1033
- const attributes = [
1034
- `path=${options.path ?? "/"}`,
1035
- options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
1036
- options.sameSite ? `samesite=${options.sameSite}` : "",
1037
- options.secure ? "secure" : "",
1038
- ].filter(Boolean);
1039
- // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
1040
- document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
1071
+ if (!isServer) writeCookie(name, next, options);
1041
1072
  },
1042
1073
  });
1043
1074
  }
1044
1075
 
1076
+ /**
1077
+ * A cookie as a ref, shared by every caller of the same name — so a plugin and
1078
+ * a page reading one preference see each other's writes — and read on the
1079
+ * server from the request being rendered, so the server HTML matches the client.
1080
+ */
1081
+ export function useDmsCookie<T = string | null>(
1082
+ name: string,
1083
+ options: DmsCookieOptions<T> = {},
1084
+ ): Ref<T> {
1085
+ if (typeof document !== "undefined") {
1086
+ if (!browserCookieRefs.has(name))
1087
+ browserCookieRefs.set(
1088
+ name,
1089
+ createCookieRef(name, document.cookie, options, false) as Ref<unknown>,
1090
+ );
1091
+ return browserCookieRefs.get(name) as Ref<T>;
1092
+ }
1093
+ const runtime = useDmsRuntime();
1094
+ const key = `${COOKIE_STATE_PREFIX}${name}`;
1095
+ if (!runtime.sharedState.has(key))
1096
+ runtime.sharedState.set(
1097
+ key,
1098
+ createCookieRef(
1099
+ name,
1100
+ runtime.requestCookies,
1101
+ options,
1102
+ true,
1103
+ ) as Ref<unknown>,
1104
+ );
1105
+ return runtime.sharedState.get(key) as Ref<T>;
1106
+ }
1107
+
1045
1108
  function preferredColorMode(): "light" | "dark" {
1046
1109
  return window.matchMedia?.("(prefers-color-scheme: dark)").matches
1047
1110
  ? "dark"
@@ -1141,6 +1204,41 @@ export function resolveDmsComponent(name: string): Component | undefined {
1141
1204
  return components.get(normalizeDmsName(name))?.component;
1142
1205
  }
1143
1206
  export const getDmsComponent = resolveDmsComponent;
1207
+
1208
+ /**
1209
+ * Records, by registered name, every registered async component a server
1210
+ * render reaches, so the client can resolve the same ones before hydrating.
1211
+ * Inertia re-renders the tree once on boot; an async component still loading
1212
+ * at that moment makes Vue drop its server-rendered markup ("Skipping lazy
1213
+ * hydration") and leave it empty until its chunk arrives.
1214
+ */
1215
+ export function trackDmsAsyncComponents(app: App): Set<string> {
1216
+ const names = new Map<Component, string>();
1217
+ components.forEach(({ component, name }) => {
1218
+ if ((component as DmsAsyncComponent).__asyncLoader)
1219
+ names.set(component, name);
1220
+ });
1221
+ const rendered = new Set<string>();
1222
+ app.mixin({
1223
+ beforeCreate(this: ComponentPublicInstance) {
1224
+ const name = names.get(this.$.type as Component);
1225
+ if (name) rendered.add(name);
1226
+ },
1227
+ });
1228
+ return rendered;
1229
+ }
1230
+
1231
+ export async function resolveDmsAsyncComponents(
1232
+ names: readonly string[],
1233
+ ): Promise<void> {
1234
+ await Promise.all(
1235
+ names.map((name) =>
1236
+ (
1237
+ resolveDmsComponent(name) as DmsAsyncComponent | undefined
1238
+ )?.__asyncLoader?.(),
1239
+ ),
1240
+ );
1241
+ }
1144
1242
  export async function preloadComponents(names: string[]): Promise<void> {
1145
1243
  const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
1146
1244
  await Promise.all(
@@ -1281,8 +1379,15 @@ export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
1281
1379
  ? layouts.get(normalizeDmsName(layoutName))
1282
1380
  : undefined;
1283
1381
  const entries = props.error ? [errorPage] : [page, layout];
1382
+ // `preload` only warms the module; the async wrapper stays unresolved until
1383
+ // its own loader runs. Hydrating an unresolved wrapper defers it, and the
1384
+ // first parent render (Inertia's initial swap) then makes Vue drop the
1385
+ // server-rendered markup and render the subtree from scratch.
1284
1386
  await Promise.all(
1285
- entries.flatMap((entry) => (entry?.preload ? [entry.preload()] : [])),
1387
+ entries.flatMap((entry) => [
1388
+ entry?.preload?.(),
1389
+ (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
1390
+ ]),
1286
1391
  );
1287
1392
  if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
1288
1393
  }
@@ -15,7 +15,7 @@
15
15
  "@unhead/vue": "^2.0.14",
16
16
  "@vitejs/plugin-vue": "^6.0.0",
17
17
  "@vue/server-renderer": "^3.5.0",
18
- "@vueuse/core": "^13.0.0",
18
+ "@vueuse/core": "^14.3.0",
19
19
  "defu": "^6.1.4",
20
20
  "ofetch": "^1.4.1",
21
21
  "reka-ui": "^2.6.0",
@@ -0,0 +1,44 @@
1
+ diff --git a/dist/runtime/vue/overrides/inertia/Link.vue b/dist/runtime/vue/overrides/inertia/Link.vue
2
+ index 5af1e3e..6cf09e1 100644
3
+ --- a/dist/runtime/vue/overrides/inertia/Link.vue
4
+ +++ b/dist/runtime/vue/overrides/inertia/Link.vue
5
+ @@ -106,20 +106,32 @@ const rel = computed(() => {
6
+ }
7
+ return null;
8
+ });
9
+ +// Patched by @antelopejs/dms-frontend until nuxt/ui#6809 is released: match on
10
+ +// path segments, so `/test` is not active on `/test-a` (nuxt/ui#6803). A `to`
11
+ +// that is not a string (a route object) has no path to match, as before.
12
+ +function normalizePath(url) {
13
+ + if (typeof url !== "string") {
14
+ + return void 0;
15
+ + }
16
+ + const path = url.split("#")[0].split("?")[0];
17
+ + if (!path) {
18
+ + return void 0;
19
+ + }
20
+ + return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
21
+ +}
22
+ +const currentPath = computed(() => normalizePath(page.url) ?? "/");
23
+ +const targetPath = computed(() => href.value ? normalizePath(href.value) : void 0);
24
+ const isLinkActive = computed(() => {
25
+ if (props.active !== void 0) {
26
+ return props.active;
27
+ }
28
+ - if (!href.value) {
29
+ + if (!targetPath.value) {
30
+ return false;
31
+ }
32
+ - if (props.exact && page.url === href.value) {
33
+ - return true;
34
+ - }
35
+ - if (!props.exact && page.url.startsWith(href.value)) {
36
+ - return true;
37
+ + if (props.exact) {
38
+ + return page.url === href.value;
39
+ }
40
+ - return false;
41
+ + return currentPath.value === targetPath.value || currentPath.value.startsWith(targetPath.value === "/" ? "/" : `${targetPath.value}/`);
42
+ });
43
+ const linkClass = computed(() => {
44
+ const active = isLinkActive.value;
@@ -2,3 +2,10 @@ packages:
2
2
  - .
3
3
  - frontend-modules/*
4
4
  ignore-workspace-root-check: true
5
+ # Fixes carried until they are released upstream. Each patch names the upstream
6
+ # issue and pull request it stands in for; drop it with the upgrade that ships
7
+ # them, since pnpm refuses a patch whose version is no longer installed.
8
+ patchedDependencies:
9
+ # Menu and breadcrumb links active on a sibling route (`/test` on `/test-a`):
10
+ # nuxt/ui#6803, fixed by nuxt/ui#6809.
11
+ "@nuxt/ui@4.10.0": patches/@nuxt__ui@4.10.0.patch
@@ -0,0 +1,15 @@
1
+ const HTML_ATTRIBUTE = /([^\s=]+)(?:="[^"]*")?/g;
2
+
3
+ /**
4
+ * The document's `<html>` tag: the template's attributes, overridden by those
5
+ * the render produced (`lang`, `data-scale`…), so none appears twice.
6
+ */
7
+ export function htmlTag(templateAttributes, renderedAttributes) {
8
+ const rendered = new Set(
9
+ [...renderedAttributes.matchAll(HTML_ATTRIBUTE)].map((match) => match[1]),
10
+ );
11
+ const kept = [...templateAttributes.matchAll(HTML_ATTRIBUTE)]
12
+ .filter((match) => !rendered.has(match[1]))
13
+ .map((match) => match[0]);
14
+ return `<html ${[...kept, renderedAttributes.trim()].filter(Boolean).join(" ")}>`;
15
+ }
@@ -22,6 +22,7 @@ import { productionHtmlTemplate } from "./server/client-manifest.mjs";
22
22
  import { documentStyleTags } from "./server/dev-styles.mjs";
23
23
  import { handleEmailRender } from "./server/email.mjs";
24
24
  import { HOMEPAGE } from "./server/homepage.mjs";
25
+ import { htmlTag } from "./server/html-tag.mjs";
25
26
  import { handleTester } from "./server/tester.mjs";
26
27
  import {
27
28
  createInertiaPage,
@@ -62,6 +63,11 @@ const MINIMUM_COMPRESSION_BYTES = 1_024;
62
63
  const DYNAMIC_BROTLI_QUALITY = 4;
63
64
  const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
64
65
  const BUILT_SSR_RENDERER_PATH = join(PROJECT_ROOT, "dist/ssr/ssr-renderer.js");
66
+ // The SSR bundle keeps vue-i18n external, so Node loads its esm-bundler build,
67
+ // which reads compile-time flags only a bundler replaces. Vite inlines them in
68
+ // the client bundle; without the same value here, installing vue-i18n under
69
+ // NODE_ENV=production throws a ReferenceError on the first render.
70
+ globalThis.__VUE_PROD_DEVTOOLS__ ??= false;
65
71
  let productionSsrRenderer;
66
72
  let vite;
67
73
  let vitePromise;
@@ -314,7 +320,12 @@ async function ssrRenderer(devServer) {
314
320
  return productionSsrRenderer;
315
321
  }
316
322
 
317
- export async function renderHtml(page, requestUrl, serverFetch) {
323
+ export async function renderHtml(
324
+ page,
325
+ requestUrl,
326
+ serverFetch,
327
+ requestCookies,
328
+ ) {
318
329
  const devServer = await developmentServer();
319
330
  const rawTemplate = devServer
320
331
  ? readFileSync(SOURCE_TEMPLATE_PATH, "utf8")
@@ -325,7 +336,7 @@ export async function renderHtml(page, requestUrl, serverFetch) {
325
336
  let rendered;
326
337
  try {
327
338
  const renderer = await ssrRenderer(devServer);
328
- rendered = await renderer.renderDmsPage(page, serverFetch);
339
+ rendered = await renderer.renderDmsPage(page, serverFetch, requestCookies);
329
340
  } catch (error) {
330
341
  console.error(
331
342
  "DMS SSR render failed; falling back to client rendering",
@@ -343,7 +354,9 @@ export async function renderHtml(page, requestUrl, serverFetch) {
343
354
  const preloads = await documentStyleTags(devServer, page, template);
344
355
  const html = template
345
356
  .replace("<title>Antelope DMS</title>", rendered.head.headTags)
346
- .replace("<html", `<html ${rendered.head.htmlAttrs}`)
357
+ .replace(/<html([^>]*)>/, (_, attributes) =>
358
+ htmlTag(attributes, rendered.head.htmlAttrs),
359
+ )
347
360
  .replace("<body", `<body ${rendered.head.bodyAttrs}`)
348
361
  .replace("</head>", `${preloads}</head>`)
349
362
  .replace(
@@ -354,6 +367,11 @@ export async function renderHtml(page, requestUrl, serverFetch) {
354
367
  return { html, status: rendered.error?.statusCode ?? 200 };
355
368
  }
356
369
 
370
+ function renderRequestHtml(page, request) {
371
+ const fetch = serverComponentFetch(request);
372
+ return renderHtml(page, request.url, fetch, request.headers.cookie);
373
+ }
374
+
357
375
  async function writeBackendError(error, request, response) {
358
376
  const status = Number.isInteger(error?.status) ? error.status : 502;
359
377
  const pathname = new URL(request.url, "http://frontend.local").pathname;
@@ -387,11 +405,7 @@ async function writeBackendError(error, request, response) {
387
405
  JSON.stringify(page),
388
406
  );
389
407
  }
390
- const { html } = await renderHtml(
391
- page,
392
- request.url,
393
- serverComponentFetch(request),
394
- );
408
+ const { html } = await renderRequestHtml(page, request);
395
409
  return writeContent(request, response, status, htmlHeaders(), html);
396
410
  }
397
411
  const payload =
@@ -444,11 +458,7 @@ export async function handleRequest(request, response) {
444
458
  JSON.stringify(page),
445
459
  );
446
460
  }
447
- const rendered = await renderHtml(
448
- page,
449
- request.url,
450
- serverComponentFetch(request),
451
- );
461
+ const rendered = await renderRequestHtml(page, request);
452
462
  if (rendered.redirect) {
453
463
  redirectFrontendVisit(request, response, rendered.redirect);
454
464
  return;
@@ -498,5 +508,27 @@ if (
498
508
  frontendHttpServer.listen(
499
509
  Number(process.env.PORT ?? 3001),
500
510
  process.env.HOST ?? "0.0.0.0",
511
+ announceReady,
501
512
  );
502
513
  }
514
+
515
+ /**
516
+ * Print the line the CLI's "Starting … server" announcement waits for. In
517
+ * development Vite would otherwise start with the first request, so it is
518
+ * started here: "ready" then means the first page is served without that wait.
519
+ */
520
+ async function announceReady() {
521
+ try {
522
+ await developmentServer();
523
+ } catch (error) {
524
+ console.error("DMS development server failed to start", error);
525
+ return;
526
+ }
527
+ const { address, port } = frontendHttpServer.address();
528
+ const host = ["0.0.0.0", "::"].includes(address)
529
+ ? "localhost"
530
+ : address.includes(":")
531
+ ? `[${address}]`
532
+ : address;
533
+ console.log(`✓ Server ready on http://${host}:${port}`);
534
+ }
@@ -5,9 +5,11 @@ import { renderToString } from "@vue/server-renderer";
5
5
  import { createSSRApp, h } from "vue";
6
6
  import {
7
7
  type DmsServerFetch,
8
+ SSR_ASYNC_COMPONENTS_ID,
8
9
  configureDmsApp,
9
10
  resolveDmsInertiaPage,
10
11
  serializeDmsAsyncData,
12
+ trackDmsAsyncComponents,
11
13
  } from "./app-runtime";
12
14
  import {
13
15
  type DmsErrorData,
@@ -60,6 +62,11 @@ function asyncDataScript(runtime: DmsFrontendRuntime): string {
60
62
  return `<script id="${SSR_ASYNC_DATA_ID}" type="application/json">${value}</script>`;
61
63
  }
62
64
 
65
+ function asyncComponentsScript(names: Set<string>): string {
66
+ const value = JSON.stringify([...names]).replaceAll("<", "\\u003c");
67
+ return `<script id="${SSR_ASYNC_COMPONENTS_ID}" type="application/json">${value}</script>`;
68
+ }
69
+
63
70
  await setupFrontendModules(frontendModules);
64
71
 
65
72
  export function isDmsFrontendPage(path: string): boolean {
@@ -69,11 +76,13 @@ export function isDmsFrontendPage(path: string): boolean {
69
76
  export async function renderDmsPage(
70
77
  page: DmsSsrPage,
71
78
  serverFetch?: DmsServerFetch,
79
+ requestCookies?: string,
72
80
  ): Promise<DmsSsrResult> {
73
81
  const runtime = createDmsFrontendRuntime(
74
82
  serverFetch as typeof import("ofetch").ofetch,
75
83
  {},
76
84
  true,
85
+ requestCookies,
77
86
  );
78
87
  return runtimeStorage.run(runtime, () =>
79
88
  renderDmsPageWithRuntime(page, runtime, serverFetch),
@@ -88,12 +97,14 @@ async function renderDmsPageWithRuntime(
88
97
  await preloadDmsPage(page.props);
89
98
  const head = createHead();
90
99
  const ssrContext: DmsSsrContext = {};
100
+ let asyncComponents = new Set<string>();
91
101
  const inertiaResult = await createInertiaApp({
92
102
  page,
93
103
  render: (app) => renderToString(app, ssrContext),
94
104
  resolve: resolveDmsInertiaPage,
95
105
  async setup({ App, props, plugin }) {
96
106
  const app = createSSRApp({ render: () => h(App, props) });
107
+ asyncComponents = trackDmsAsyncComponents(app);
97
108
  const configured = await configureDmsApp({
98
109
  app,
99
110
  head,
@@ -130,12 +141,13 @@ async function renderDmsPageWithRuntime(
130
141
  return renderDmsPage(
131
142
  { ...page, props: { ...page.props, error } },
132
143
  serverFetch,
144
+ runtime.requestCookies,
133
145
  );
134
146
  }
135
147
  const renderedHead = await renderSSRHead(head);
136
148
  return {
137
149
  error: page.props.error,
138
- body: `${inertiaResult.body}${asyncDataScript(runtime)}`,
150
+ body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
139
151
  head: {
140
152
  bodyAttrs: renderedHead.bodyAttrs,
141
153
  headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
@@ -184,7 +184,7 @@ export default defineConfig({
184
184
  paintBeforeHydrate(),
185
185
  ],
186
186
  resolve: {
187
- dedupe: ["vue", "reka-ui", "@nuxt/ui"],
187
+ dedupe: ["vue", "reka-ui", "@nuxt/ui", "@vueuse/core"],
188
188
  alias: {
189
189
  "#dms/frontend-module": resolve(__dirname, "frontend-module.ts"),
190
190
  "#build/nuxt-icon-client-bundle": "virtual:nuxt-ui-icons",