@antelopejs/dms-frontend 0.3.12 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -188,6 +188,72 @@ The SDK also exposes `use` for Vue plugins. Entries execute by descending manife
188
188
 
189
189
  Within one setup, the first module to register a name keeps it. `ajs-dms dev` sets every module up again each time a change to a layer reaches the server renderer, into empty registries, so the next server render uses the edited component without a restart. A `setup` may therefore run more than once in a process: it should only register, and keep no state of its own between runs.
190
190
 
191
+ ### Component prefix
192
+
193
+ Every registered component joins the application's global components and resolves by name anywhere, in templates and in backend page trees. To keep the components of two modules from colliding, a module declares a `componentPrefix`, put in front of the name of every component it registers:
194
+
195
+ ```ts
196
+ const frontendModule: DmsFrontendModule = {
197
+ componentPrefix: "Acme",
198
+ setup(sdk) {
199
+ sdk.registerComponent("Banner", Banner); // registered as AcmeBanner
200
+ },
201
+ };
202
+ ```
203
+
204
+ The full name, prefix included, is the one a template uses (`<AcmeBanner>`) and a backend sends (`CustomComponent("AcmeBanner")`, or `acme-banner`). Names are compared ignoring case, a leading `lazy` and non-alphanumeric characters; a leading `Dms` is no longer dropped, since it is the DMS module's own prefix.
205
+
206
+ When two modules register the same name, the module of higher priority keeps it and a warning names both. A module that means to replace another's component registers its full name without its own prefix, which is not reported:
207
+
208
+ ```ts
209
+ sdk.registerComponent("AcmeBanner", MyBanner, { prefix: false });
210
+ ```
211
+
212
+ A module's `app/build/` directory is never auto-imported (see below): its components are registered so backend pages can name them, but they are the module's internals, not a surface other modules should build on.
213
+
214
+ ### Auto-imports (`dms.frontend.build.ts`)
215
+
216
+ Nothing a module ships is auto-imported unless the module declares it, in an optional `dms.frontend.build.ts` next to its `dms.frontend.ts`:
217
+
218
+ ```ts
219
+ import { defineDmsFrontendBuild } from "#dms/frontend-build";
220
+
221
+ export default defineDmsFrontendBuild((build) => {
222
+ build.registerAutoImports([
223
+ "layers/*/app/composables",
224
+ "layers/*/app/utils",
225
+ "layers/*/app/types",
226
+ ]);
227
+ });
228
+ ```
229
+
230
+ Each entry is a directory relative to the module root, glob patterns allowed; every export of the files under it becomes available to the whole application without an import, in scripts and templates. A module without the file has nothing auto-imported. `app/build/` holds a module's private code and is never auto-imported: it is left out of what a broader entry covers, and an entry naming it, or a directory outside the module, is ignored with a warning when Vite starts.
231
+
232
+ The file runs in Node.js when Vite starts, under `ajs dms dev` and `ajs dms build`, not in the application: it may import `#dms/frontend-build` and Node.js built-ins, never the application's code. Vite restarts when one changes.
233
+
234
+ Up to 0.3, every module's `app/composables`, `app/types`, `app/utils`, `app/build/composables` and `app/build/types` were auto-imported. A module that relies on auto-imports declares its public directories in `dms.frontend.build.ts`, and imports its `app/build/` code by path.
235
+
236
+ ### Page loading state
237
+
238
+ After a client navigation, the page renders under a `<Suspense>` until its chunk and any async `setup` resolve. `useDmsPageLoading()` (auto-imported) is `true` meanwhile, so a layout can show its own placeholder instead of an empty page body:
239
+
240
+ ```vue
241
+ <script setup lang="ts">
242
+ const pageLoading = useDmsPageLoading();
243
+ </script>
244
+
245
+ <template>
246
+ <slot />
247
+ <PageSkeleton v-if="pageLoading" />
248
+ </template>
249
+ ```
250
+
251
+ It stays `false` on the server and during hydration, which render the page resolved, for a page that does not suspend, and once a pending page is left.
252
+
253
+ ### Interface language
254
+
255
+ The interface language follows the session (`useUserSession().user.language`), not the props of the page on screen: the first render takes it from the session the page arrived with, and it changes when the session's language does, after `useUserSession().fetch()` or a visit with a newer session. A page Inertia restores from history or serves from its prefetch cache carries the session it was fetched with: the frontend server stamps every session it writes (`session.updatedAt`), and a page carrying an older stamp of the same account updates neither the session nor the language. A session without a language, such as a signed-out one, keeps the language on screen; `$i18n.setLocale()` still switches it at any time.
256
+
191
257
  ### Declaring the loader releases a module supports
192
258
 
193
259
  A frontend module names the `@antelopejs/dms-frontend` releases it runs on in its own `package.json`, the way a package names the Node.js versions it supports:
@@ -412,7 +478,8 @@ The other files the workspace derives from every module follow their sources
412
478
  the same way. A file added to, changed in or removed from a module's `public/`
413
479
  is served, or no longer served, at once. Adding or removing an
414
480
  `app/config/shortcuts-registry.ts` or a `dms.frontend.ts` regenerates the
415
- aggregated shortcuts or the module loader, and the page reloads. A layer
481
+ aggregated shortcuts or the module loader, and the page reloads. Adding,
482
+ changing or removing a `dms.frontend.build.ts` restarts Vite. A layer
416
483
  directory added to or removed from `layers/` is the exception: Vite reads the
417
484
  `#<layer>` aliases and the auto-imported directories at startup only, so the
418
485
  dev server says it needs a restart.
@@ -436,6 +503,14 @@ needs no exception for the Iconify API. Icons reach the page two ways:
436
503
  answers `404`, and a name outside Iconify's naming rule `400`. `ajs dms dev`
437
504
  and `ajs dms start` run the same server, so both serve the route.
438
505
 
506
+ The server render draws every icon, bundled or not: the frontend server loads
507
+ each installed `@iconify-json/*` collection once and hands it to the renderer,
508
+ and an icon is drawn in its first render as soon as its data is loaded, instead
509
+ of after the component mounts. The data of each icon a render drew travels in
510
+ the page, so the browser draws the same icons as it hydrates; an icon no
511
+ installed collection holds stays empty on the server and is fetched in the
512
+ browser as before.
513
+
439
514
  ## Options
440
515
 
441
516
  | Option | Environment | Purpose |
@@ -449,6 +524,7 @@ needs no exception for the Iconify API. Icons reach the page two ways:
449
524
  | | `HOST` | Address the frontend server binds to (default `0.0.0.0`) |
450
525
  | | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
451
526
  | | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
527
+ | | `DMS_COUNTRY_HEADER` | Client country header the server relays to the backend, set by a trusted reverse proxy (default `CF-IPCountry`; empty relays none). Name the same header in the backend's `auth.signInCountry.header`. Relayed only when `DMS_TRUSTED_PROXY_HOPS` is above `0`, like the client address |
452
528
  | | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
453
529
  | | `DMS_HTML_RENDER_SECRET` | Secret that verifies the backend's signed HTML/email render requests; without it, e-mail renders are refused. Defaults to the `htmlRender.serviceSecret` the backend publishes in the frontend manifest |
454
530
  | | `DMS_OAUTH_RELAY_SECRET` | Secret the server presents to the backend's OAuth endpoints; without it, the backend refuses OAuth sign-in. Defaults to the `oauth.relaySecret` the backend publishes in the frontend manifest |
package/dist/config.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare function writeSecretBearingFile(file: string, content: string): v
9
9
  export declare const TEMPLATE_FILES: ReadonlyArray<readonly [string, string]>;
10
10
  export declare const LAYERS_SUBDIR = "frontend-modules";
11
11
  export declare const FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
12
+ export declare const FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
12
13
  export declare const AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
13
14
  export declare const LAYER_COPY_BLOCKLIST: readonly string[];
14
15
  export declare const layerCopyIgnore: ignore.Ignore;
package/dist/config.js CHANGED
@@ -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.Options = exports.UsageError = 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;
6
+ exports.Options = exports.UsageError = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_BUILD_ENTRY = 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.parseBackendUrl = parseBackendUrl;
9
9
  exports.requireBackendUrl = requireBackendUrl;
@@ -45,6 +45,9 @@ exports.TEMPLATE_FILES = [
45
45
  ["ui-app-config.ts", "ui-app-config.ts"],
46
46
  ["ssr-renderer.ts", "ssr-renderer.ts"],
47
47
  ["frontend-module.ts", "frontend-module.ts"],
48
+ ["frontend-build.ts", "frontend-build.ts"],
49
+ ["frontend-build-loader.ts", "frontend-build-loader.ts"],
50
+ ["icon-hydration.ts", "icon-hydration.ts"],
48
51
  ["globals.d.ts", "globals.d.ts"],
49
52
  ["compress-assets.mjs", "compress-assets.mjs"],
50
53
  ["head-order.mjs", "head-order.mjs"],
@@ -66,6 +69,7 @@ exports.TEMPLATE_FILES = [
66
69
  ];
67
70
  exports.LAYERS_SUBDIR = "frontend-modules";
68
71
  exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
72
+ exports.FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
69
73
  exports.AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
70
74
  exports.LAYER_COPY_BLOCKLIST = [
71
75
  "node_modules",
@@ -1,10 +1,12 @@
1
1
  import { FrontendModuleOptions, ResolvedLayer } from "./workspace";
2
2
  export interface FrontendModuleRegistryEntry {
3
3
  id: string;
4
+ name?: string;
4
5
  packageName?: string;
5
6
  root: string;
6
7
  priority: number;
7
8
  entry?: string;
9
+ build?: string;
8
10
  options: FrontendModuleOptions;
9
11
  }
10
12
  export interface FrontendModuleRegistry {
@@ -21,6 +21,8 @@ function validateFrontendModuleRegistry(registry, workspaceDir) {
21
21
  throw new Error(`Invalid frontend module priority: ${module.id}`);
22
22
  if (module.entry !== undefined && module.entry !== config_1.FRONTEND_MODULE_ENTRY)
23
23
  throw new Error(`Invalid frontend module entry: ${module.id}`);
24
+ if (module.build !== undefined && module.build !== config_1.FRONTEND_BUILD_ENTRY)
25
+ throw new Error(`Invalid frontend module build entry: ${module.id}`);
24
26
  if (!(0, node_path_1.resolve)(module.root).startsWith(moduleRoot))
25
27
  throw new Error(`Frontend module root escapes the workspace: ${module.id}`);
26
28
  ids.add(module.id);
@@ -29,13 +31,16 @@ function validateFrontendModuleRegistry(registry, workspaceDir) {
29
31
  function createFrontendModuleRegistry(workspaceDir, layers) {
30
32
  const modules = layers.map((layer) => {
31
33
  const sourceEntry = (0, node_path_1.join)(layer.path, config_1.FRONTEND_MODULE_ENTRY);
34
+ const buildEntry = (0, node_path_1.join)(layer.path, config_1.FRONTEND_BUILD_ENTRY);
32
35
  const options = layer.options ?? {};
33
36
  return {
34
37
  id: (0, layers_1.getLayerSafeName)(layer),
38
+ name: layer.name ?? layer.packageName,
35
39
  packageName: layer.packageName,
36
40
  root: (0, layers_1.getLayerWorkspacePath)(workspaceDir, layer),
37
41
  priority: layer.priority ?? 0,
38
42
  entry: (0, node_fs_1.existsSync)(sourceEntry) ? config_1.FRONTEND_MODULE_ENTRY : undefined,
43
+ build: (0, node_fs_1.existsSync)(buildEntry) ? config_1.FRONTEND_BUILD_ENTRY : undefined,
39
44
  options: layer.configKey ? { [layer.configKey]: options } : options,
40
45
  };
41
46
  });
@@ -47,6 +52,7 @@ function createFrontendModuleRegistry(workspaceDir, layers) {
47
52
  function writeFrontendTypePaths(workspaceDir, registry) {
48
53
  const paths = {
49
54
  "#dms/frontend-module": ["./frontend-module.ts"],
55
+ "#dms/frontend-build": ["./frontend-build.ts"],
50
56
  "@frontend/*": ["./frontend-modules/*"],
51
57
  ...(0, layer_aliases_1.frontendLayerTypePaths)([...registry.modules].reverse().map((module) => module.root), { relativeTo: workspaceDir }),
52
58
  };
@@ -59,14 +65,27 @@ function writeFrontendModuleLoader(workspaceDir, registry) {
59
65
  .map((module, index) => `import module${index} from "@frontend/${module.id}/${module.entry}";`)
60
66
  .join("\n");
61
67
  const registrations = modules
62
- .map((module, index) => `{ module: module${index}, options: { public: ${JSON.stringify(module.options)} } }`)
68
+ .map((module, index) => `{ name: ${JSON.stringify(module.name)}, module: module${index}, options: { public: ${JSON.stringify(module.options)} } }`)
63
69
  .join(", ");
64
70
  const content = `${imports}\n\nimport type { DmsFrontendModuleRegistration } from "./frontend-module";\n\nexport const frontendModules: DmsFrontendModuleRegistration[] = [${registrations}];\n`;
65
71
  return (0, fs_sync_1.applyContent)(content, (0, node_path_1.join)(workspaceDir, "frontend-modules.generated.ts"));
66
72
  }
73
+ function writeFrontendBuildLoader(workspaceDir, registry) {
74
+ const modules = registry.modules.filter((module) => module.build);
75
+ const imports = modules
76
+ .map((module, index) => `import build${index} from "./${config_1.LAYERS_SUBDIR}/${module.id}/${module.build}";`)
77
+ .join("\n");
78
+ const declarations = modules
79
+ .map((module, index) => `{ id: ${JSON.stringify(module.id)}, setup: build${index} }`)
80
+ .join(", ");
81
+ const content = `${imports}\n\nimport type { FrontendBuildDeclaration } from "./frontend-build-loader";\n\nexport const frontendBuilds: FrontendBuildDeclaration[] = [${declarations}];\n`;
82
+ return (0, fs_sync_1.applyContent)(content, (0, node_path_1.join)(workspaceDir, "frontend-builds.generated.ts"));
83
+ }
67
84
  function writeModuleRegistry(workspaceDir, registry) {
68
85
  const wroteRegistry = (0, fs_sync_1.applyContent)(`${JSON.stringify(registry, null, 2)}\n`, (0, node_path_1.join)(workspaceDir, "generated-frontend-modules.json"));
69
- return writeFrontendModuleLoader(workspaceDir, registry) || wroteRegistry;
86
+ const wroteModules = writeFrontendModuleLoader(workspaceDir, registry);
87
+ const wroteBuilds = writeFrontendBuildLoader(workspaceDir, registry);
88
+ return wroteRegistry || wroteModules || wroteBuilds;
70
89
  }
71
90
  function moduleAssetRoots(moduleRoot) {
72
91
  const layersRoot = (0, node_path_1.join)(moduleRoot, "layers");
@@ -278,7 +297,7 @@ function writeShortcutExports(workspaceDir, registry) {
278
297
  exports.DERIVED_OUTPUTS = [
279
298
  {
280
299
  name: "module registry",
281
- affects: (path) => (0, fs_sync_1.toPosixPath)(path) === config_1.FRONTEND_MODULE_ENTRY,
300
+ affects: (path) => [config_1.FRONTEND_MODULE_ENTRY, config_1.FRONTEND_BUILD_ENTRY].includes((0, fs_sync_1.toPosixPath)(path)),
282
301
  write: writeModuleRegistry,
283
302
  },
284
303
  {
package/dist/help.js CHANGED
@@ -25,6 +25,10 @@ exports.ENVIRONMENT_VARIABLES = [
25
25
  "DMS_TRUSTED_PROXY_HOPS",
26
26
  "Trusted reverse-proxy hops, counted from the right (default: 0)",
27
27
  ],
28
+ [
29
+ "DMS_COUNTRY_HEADER",
30
+ "Proxy country header relayed to the backend when a proxy is trusted (default: CF-IPCountry; empty: none)",
31
+ ],
28
32
  [
29
33
  "DMS_HTML_RENDER_SECRET",
30
34
  "Overrides the e-mail render secret the backend publishes",
package/dist/layers.js CHANGED
@@ -119,6 +119,7 @@ function readLayerPackageName(layerPath) {
119
119
  function resolveLayer(layerPath, mod) {
120
120
  return {
121
121
  path: layerPath,
122
+ name: mod.name,
122
123
  sourcePath: mod.path,
123
124
  packageName: readLayerPackageName(layerPath),
124
125
  priority: mod.priority,
@@ -27,6 +27,7 @@ export interface FrontendManifest {
27
27
  }
28
28
  export interface ResolvedLayer {
29
29
  path: string;
30
+ name?: string;
30
31
  sourcePath?: string;
31
32
  packageName?: string;
32
33
  priority?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.3.12",
3
+ "version": "0.5.0",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -6,6 +6,7 @@ import { en as uiEn, fr as uiFr } from "@nuxt/ui/locale";
6
6
  import ui from "@nuxt/ui/vue-plugin";
7
7
  import { useHead } from "@unhead/vue";
8
8
  import type { VueHeadClient } from "@unhead/vue/types";
9
+ import type { IconifyIcon } from "@iconify/vue";
9
10
  import type { FetchOptions } from "ofetch";
10
11
  import {
11
12
  type App,
@@ -23,7 +24,9 @@ import DmsDynamicPage from "./DmsDynamicPage.vue";
23
24
  import {
24
25
  type DmsLocaleLoader,
25
26
  type DmsPageProps,
27
+ type DmsUser,
26
28
  createDmsFrontendRuntime,
29
+ dmsPageSuspenseProps,
27
30
  getDmsErrorPage,
28
31
  getDmsLayout,
29
32
  getDmsLayoutProps,
@@ -39,7 +42,13 @@ import {
39
42
  useDmsAppConfig,
40
43
  useDmsState,
41
44
  useError,
45
+ useUserSession,
42
46
  } from "./frontend-module";
47
+ import {
48
+ SSR_ICONS_ID,
49
+ addDmsIcons,
50
+ drawLoadedIconsAtOnce,
51
+ } from "./icon-hydration";
43
52
  import {
44
53
  loadLocaleMessages,
45
54
  localeMessages,
@@ -94,30 +103,24 @@ function uiLocale(locale: string): UiLocale {
94
103
  return UI_LOCALES[locale as keyof typeof UI_LOCALES] ?? UI_LOCALES.en;
95
104
  }
96
105
 
97
- function pageLocale(props: DmsPageProps): string {
98
- const language = props.user?.language;
99
- if (typeof language !== "string") return DEFAULT_LOCALE;
106
+ /** The supported locale of a user's language, if it has one. */
107
+ function userLocale(user: DmsUser | null | undefined): string | undefined {
108
+ const language = user?.language;
109
+ if (typeof language !== "string") return undefined;
100
110
  const locale = language.slice(0, 2);
101
- return supportedLocales.includes(locale) ? locale : DEFAULT_LOCALE;
111
+ return supportedLocales.includes(locale) ? locale : undefined;
102
112
  }
103
113
 
104
- function readDmsAsyncData(): Record<string, unknown> {
105
- if (typeof document === "undefined") return {};
106
- const element = document.getElementById(SSR_ASYNC_DATA_ID);
107
- if (!element?.textContent) return {};
108
- const data = JSON.parse(element.textContent) as Record<string, unknown>;
114
+ function readServerRendered<T>(id: string, empty: T): T {
115
+ if (typeof document === "undefined") return empty;
116
+ const element = document.getElementById(id);
117
+ if (!element?.textContent) return empty;
118
+ const value = JSON.parse(element.textContent) as T;
109
119
  element.remove();
110
- return data;
120
+ return value;
111
121
  }
112
122
 
113
- function readDmsAsyncComponents(): string[] {
114
- if (typeof document === "undefined") return [];
115
- const element = document.getElementById(SSR_ASYNC_COMPONENTS_ID);
116
- if (!element?.textContent) return [];
117
- const names = JSON.parse(element.textContent) as string[];
118
- element.remove();
119
- return names;
120
- }
123
+ drawLoadedIconsAtOnce();
121
124
 
122
125
  const DmsInertiaPage = defineComponent({
123
126
  name: "DmsInertiaPage",
@@ -148,17 +151,25 @@ const DmsPersistentLayout = defineComponent({
148
151
  setup(_, { attrs, slots }) {
149
152
  const props = computed(() => attrs as unknown as DmsPageProps);
150
153
  const i18n = useI18n();
154
+ const { user } = useUserSession();
151
155
  let localeVersion = 0;
152
156
  useHead({
153
157
  htmlAttrs: { lang: computed(() => i18n.locale.value) },
154
158
  });
155
159
  watch(
156
160
  () => [attrs.path, attrs.page, attrs.user, attrs.session, attrs.error],
157
- async () => {
161
+ () => hydrateDmsPageProps(props.value),
162
+ { immediate: true, flush: "sync" },
163
+ );
164
+ // The session, not the page props: a page restored from history or from
165
+ // the prefetch cache carries the user as it was when it was fetched, and
166
+ // would switch the interface back to a language the user has left. A
167
+ // session without a language keeps the one on screen.
168
+ watch(
169
+ () => userLocale(user.value),
170
+ async (locale) => {
158
171
  const version = ++localeVersion;
159
- hydrateDmsPageProps(props.value);
160
- const locale = pageLocale(props.value);
161
- if (i18n.locale.value === locale) return;
172
+ if (!locale || i18n.locale.value === locale) return;
162
173
  const messages = await loadLocaleMessages(locale);
163
174
  if (version !== localeVersion) return;
164
175
  i18n.setLocaleMessage(locale, messages);
@@ -205,7 +216,7 @@ function renderDmsPage(
205
216
  error,
206
217
  key: props.path,
207
218
  });
208
- return h(Suspense, null, { default: () => pageContent });
219
+ return h(Suspense, dmsPageSuspenseProps(), { default: () => pageContent });
209
220
  }
210
221
 
211
222
  function renderDmsPersistentLayout(
@@ -247,9 +258,9 @@ export async function configureDmsApp(
247
258
  options.runtime ??
248
259
  createDmsFrontendRuntime(
249
260
  options.serverFetch as typeof import("ofetch").ofetch,
250
- readDmsAsyncData(),
261
+ readServerRendered<Record<string, unknown>>(SSR_ASYNC_DATA_ID, {}),
251
262
  );
252
- const locale = pageLocale(options.initialPageProps);
263
+ const locale = userLocale(options.initialPageProps.user) ?? DEFAULT_LOCALE;
253
264
  const messages = await loadLocaleMessages(locale);
254
265
  const i18n = createI18n({
255
266
  legacy: false,
@@ -266,6 +277,9 @@ export async function configureDmsApp(
266
277
  options.app.runWithContext(() =>
267
278
  hydrateDmsPageProps(options.initialPageProps, options.initialPageUrl),
268
279
  );
280
+ addDmsIcons(
281
+ readServerRendered<Record<string, IconifyIcon>>(SSR_ICONS_ID, {}),
282
+ );
269
283
  if (typeof window !== "undefined") {
270
284
  const toast = options.app.runWithContext(() => useToast());
271
285
  showNetworkErrors(toast.add, i18n.global);
@@ -275,7 +289,9 @@ export async function configureDmsApp(
275
289
  // Hydration then adopts the server-rendered markup instead of discarding it.
276
290
  await Promise.all([
277
291
  preloadDmsPage(options.initialPageProps),
278
- resolveDmsAsyncComponents(readDmsAsyncComponents()),
292
+ resolveDmsAsyncComponents(
293
+ readServerRendered<string[]>(SSR_ASYNC_COMPONENTS_ID, []),
294
+ ),
279
295
  ]);
280
296
  const mounted = await installDmsPlugins(
281
297
  options.app,
@@ -0,0 +1,78 @@
1
+ import { isAbsolute, posix, resolve } from "node:path";
2
+ import { normalizePath } from "vite";
3
+ import type { DmsFrontendBuildSetup } from "./frontend-build";
4
+
5
+ /** A module's `dms.frontend.build.ts`, as the generated loader imports it. */
6
+ export interface FrontendBuildDeclaration {
7
+ /** The module's id in the generated module registry. */
8
+ id: string;
9
+ setup: DmsFrontendBuildSetup;
10
+ }
11
+
12
+ interface FrontendModuleRoot {
13
+ id: string;
14
+ root: string;
15
+ }
16
+
17
+ export interface AutoImportDirectory {
18
+ glob: string;
19
+ types: boolean;
20
+ }
21
+
22
+ /** A module's private code: never auto-imported, whatever it declares. */
23
+ const PRIVATE_DIRECTORY = /(?:^|\/)app\/build(?:\/|$)/;
24
+
25
+ function declaredDirectories(setup: DmsFrontendBuildSetup): string[] {
26
+ const directories: string[] = [];
27
+ setup({
28
+ registerAutoImports: (entries) => directories.push(...entries),
29
+ });
30
+ return directories;
31
+ }
32
+
33
+ function rejection(directory: string): string | undefined {
34
+ const path = posix.normalize(normalizePath(directory));
35
+ if (isAbsolute(directory) || path === ".." || path.startsWith("../"))
36
+ return "it is not inside the module";
37
+ if (PRIVATE_DIRECTORY.test(path))
38
+ return "app/build/ holds the module's private code, which is never auto-imported";
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * The directories the modules declared in their `dms.frontend.build.ts`, as
44
+ * the auto-importer's scan globs. Each module's `app/build/` directories are
45
+ * excluded even from a broader declaration; a declaration naming one, or
46
+ * leaving the module, is ignored with a warning.
47
+ *
48
+ * Globs are POSIX on every platform: the scanner reads a backslash as an
49
+ * escape, not as a separator.
50
+ */
51
+ export function autoImportDirectories(
52
+ modules: readonly FrontendModuleRoot[],
53
+ declarations: readonly FrontendBuildDeclaration[],
54
+ warn: (message: string) => void,
55
+ ): AutoImportDirectory[] {
56
+ const roots = new Map(modules.map((module) => [module.id, module.root]));
57
+ return declarations.flatMap(({ id, setup }) => {
58
+ const root = roots.get(id);
59
+ if (root === undefined) return [];
60
+ const scan = (pattern: string) => normalizePath(resolve(root, pattern));
61
+ const accepted = declaredDirectories(setup).filter((directory) => {
62
+ const reason = rejection(directory);
63
+ if (reason)
64
+ warn(
65
+ `Frontend module ${id} declares "${directory}" for auto-import, which is ignored: ${reason}.`,
66
+ );
67
+ return !reason;
68
+ });
69
+ if (accepted.length === 0) return [];
70
+ return [
71
+ ...accepted.map((directory) => ({
72
+ glob: scan(`${directory}/**/*`),
73
+ types: true,
74
+ })),
75
+ { glob: `!${scan("**/app/build/**")}`, types: true },
76
+ ];
77
+ });
78
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What a frontend module's `dms.frontend.build.ts` declares about how the
3
+ * application is built from it. Read when Vite starts, by `ajs dms dev` and
4
+ * `ajs dms build` alike.
5
+ */
6
+ export interface DmsFrontendBuild {
7
+ /**
8
+ * Makes every export of the files under these directories available to
9
+ * the whole application without an import, in scripts and templates.
10
+ *
11
+ * Each entry is a directory relative to the module root, and may hold glob
12
+ * patterns: `layers/*\/app/composables` covers that directory of every
13
+ * layer. Nothing under an `app/build/` directory is ever auto-imported: it
14
+ * holds the module's private code, which its own files import by path.
15
+ */
16
+ registerAutoImports(directories: readonly string[]): void;
17
+ }
18
+
19
+ export type DmsFrontendBuildSetup = (build: DmsFrontendBuild) => void;
20
+
21
+ /**
22
+ * Declares how the application is built from a frontend module, as the
23
+ * default export of its `dms.frontend.build.ts`:
24
+ *
25
+ * ```ts
26
+ * import { defineDmsFrontendBuild } from "#dms/frontend-build";
27
+ *
28
+ * export default defineDmsFrontendBuild((build) => {
29
+ * build.registerAutoImports(["layers/*\/app/composables"]);
30
+ * });
31
+ * ```
32
+ *
33
+ * A module without this file has nothing auto-imported. The file runs in
34
+ * Node.js when Vite starts, not in the application: it may import Node.js
35
+ * built-ins, never the application's own code.
36
+ */
37
+ export function defineDmsFrontendBuild(
38
+ setup: DmsFrontendBuildSetup,
39
+ ): DmsFrontendBuildSetup {
40
+ return setup;
41
+ }
@@ -15,6 +15,8 @@ import {
15
15
  type Component,
16
16
  type ComponentPublicInstance,
17
17
  type ComputedRef,
18
+ camelize,
19
+ capitalize,
18
20
  computed,
19
21
  defineComponent,
20
22
  type EffectScope,
@@ -31,6 +33,7 @@ import {
31
33
  type PropType,
32
34
  type Ref,
33
35
  reactive,
36
+ readonly,
34
37
  ref,
35
38
  watch,
36
39
  } from "vue";
@@ -82,6 +85,13 @@ export interface DmsUser extends Record<string, unknown> {}
82
85
  export interface DmsSession {
83
86
  accountId: string;
84
87
  activeTenantId?: string;
88
+ /**
89
+ * When the frontend server last wrote this session, in milliseconds since
90
+ * the epoch. A page restored from history or from the prefetch cache
91
+ * carries an older session than the one the app already holds, and does not
92
+ * replace it.
93
+ */
94
+ updatedAt?: number;
85
95
  }
86
96
  export interface DmsRuntimeConfig extends RuntimeConfig {
87
97
  public: PublicRuntimeConfig;
@@ -220,9 +230,21 @@ export interface DmsPluginRegistrationOptions {
220
230
  clientOnly?: boolean;
221
231
  }
222
232
  export type DmsComponentPreloader = () => Promise<unknown>;
233
+ export interface DmsComponentRegistrationOptions {
234
+ /**
235
+ * Registers the component under `name` as given, without the module's
236
+ * `componentPrefix`: the way to replace a component another module
237
+ * registers, from a module of higher priority. Defaults to `true`.
238
+ */
239
+ prefix?: boolean;
240
+ }
223
241
  export interface DmsFrontendSdk {
224
242
  options: DmsModuleOptions;
225
- registerComponent(name: string, component: Component): void;
243
+ registerComponent(
244
+ name: string,
245
+ component: Component,
246
+ options?: DmsComponentRegistrationOptions,
247
+ ): void;
226
248
  registerPage(
227
249
  name: string,
228
250
  component: Component,
@@ -261,9 +283,18 @@ export interface DmsFrontendSdk {
261
283
  use(plugin: Plugin): void;
262
284
  }
263
285
  export interface DmsFrontendModule {
286
+ /**
287
+ * Put in front of the name of every component the module registers, so
288
+ * the components of two modules never collide: with `"Acme"`,
289
+ * `registerComponent("Banner", ...)` registers `AcmeBanner`, the name the
290
+ * backend sends and templates use.
291
+ */
292
+ componentPrefix?: string;
264
293
  setup(sdk: DmsFrontendSdk): void | Promise<void>;
265
294
  }
266
295
  export interface DmsFrontendModuleRegistration {
296
+ /** The module's manifest name, which collision warnings name it by. */
297
+ name?: string;
267
298
  module: DmsFrontendModule;
268
299
  options: DmsModuleOptions;
269
300
  }
@@ -283,6 +314,10 @@ export interface DmsRuntimeHooks {
283
314
  interface DmsComponentRegistration {
284
315
  name: string;
285
316
  component: Component;
317
+ /** The registering module, for a collision warning. */
318
+ module?: string;
319
+ /** Registered without the module's prefix, to replace another module's. */
320
+ unprefixed?: boolean;
286
321
  }
287
322
 
288
323
  interface DmsFrontendEntry {
@@ -333,6 +368,8 @@ export interface DmsFrontendRuntime {
333
368
  pendingNavigation?: Promise<void>;
334
369
  hasNavigationListener: boolean;
335
370
  pageVersion: number;
371
+ /** Whether the page `<Suspense>` waits on the page being navigated to. */
372
+ pageLoading: Ref<boolean>;
336
373
  /**
337
374
  * Identifies the page on screen (its path, or the error page shown at that
338
375
  * path), whose meta `route.meta` holds.
@@ -403,6 +440,7 @@ export function createDmsFrontendRuntime(
403
440
  }),
404
441
  hasNavigationListener: false,
405
442
  pageVersion: 0,
443
+ pageLoading: ref(false),
406
444
  scope: effectScope(true),
407
445
  };
408
446
  }
@@ -1187,20 +1225,86 @@ export function useUserSession<
1187
1225
  };
1188
1226
  }
1189
1227
 
1228
+ /**
1229
+ * Whether the page loaded by the current navigation is still pending: its
1230
+ * chunk, or an async `setup` it awaits, has not resolved yet. A layout reads
1231
+ * it to show its own placeholder in place of the page. Always `false` on the
1232
+ * server and during hydration, which render the page resolved.
1233
+ */
1234
+ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
1235
+ return readonly(useDmsRuntime().pageLoading);
1236
+ }
1237
+
1238
+ /**
1239
+ * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
1240
+ * in step with it. Cleared before the boundary unmounts, so a page left while
1241
+ * pending never leaves the state set.
1242
+ */
1243
+ export function dmsPageSuspenseProps(): Record<string, () => void> {
1244
+ const loading = useDmsRuntime().pageLoading;
1245
+ const settle = () => {
1246
+ loading.value = false;
1247
+ };
1248
+ return {
1249
+ onPending: () => {
1250
+ loading.value = true;
1251
+ },
1252
+ onResolve: settle,
1253
+ onVnodeBeforeUnmount: settle,
1254
+ };
1255
+ }
1256
+
1257
+ /**
1258
+ * The key a component name is registered and resolved under: case, a leading
1259
+ * `lazy` and every non-alphanumeric character ignored, so PascalCase,
1260
+ * kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
1261
+ * leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
1262
+ * make `DmsBanner` and another module's `Banner` the same component.
1263
+ */
1264
+ function normalizeDmsComponentName(name: string): string {
1265
+ return name
1266
+ .replace(/^lazy/i, "")
1267
+ .replace(/[^a-z0-9]/gi, "")
1268
+ .toLowerCase();
1269
+ }
1270
+
1271
+ function moduleLabel(module: string | undefined): string {
1272
+ return module ? `module "${module}"` : "an unnamed module";
1273
+ }
1274
+
1275
+ /**
1276
+ * A component registered under a name already taken is ignored: the first
1277
+ * registration, from the module of higher priority, keeps the name. That is
1278
+ * how a module replaces another's component, by registering its full name
1279
+ * without a prefix, and only a collision neither side asked for is reported.
1280
+ */
1190
1281
  function registerDmsComponentEntry(
1191
1282
  entries: Map<string, DmsComponentRegistration>,
1192
- name: string,
1193
- component: Component,
1283
+ registration: DmsComponentRegistration,
1194
1284
  ): void {
1195
- const key = normalizeDmsName(name);
1196
- if (!entries.has(key)) entries.set(key, { name, component });
1285
+ const key = normalizeDmsComponentName(registration.name);
1286
+ const existing = entries.get(key);
1287
+ if (!existing) {
1288
+ entries.set(key, registration);
1289
+ return;
1290
+ }
1291
+ if (existing.unprefixed || registration.unprefixed) return;
1292
+ console.warn(
1293
+ `[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
1294
+ `${moduleLabel(existing.module)} already registers "${existing.name}". ` +
1295
+ "Give the modules different component prefixes.",
1296
+ );
1197
1297
  }
1198
1298
  /** Registers a component unless a higher-priority module already owns its name. */
1199
1299
  export function registerDmsComponent(name: string, component: Component): void {
1200
- registerDmsComponentEntry(registry.components, name, component);
1300
+ registerDmsComponentEntry(registry.components, { name, component });
1201
1301
  }
1302
+ /**
1303
+ * Resolves a registered component by its full name, prefix included. Case,
1304
+ * a leading `lazy` and non-alphanumeric characters are ignored.
1305
+ */
1202
1306
  export function resolveDmsComponent(name: string): Component | undefined {
1203
- return registry.components.get(normalizeDmsName(name))?.component;
1307
+ return registry.components.get(normalizeDmsComponentName(name))?.component;
1204
1308
  }
1205
1309
  export const getDmsComponent = resolveDmsComponent;
1206
1310
 
@@ -1371,6 +1475,24 @@ export function getDmsLayoutProps(
1371
1475
  };
1372
1476
  }
1373
1477
 
1478
+ /**
1479
+ * Whether a page carries an older session of the same account than the one
1480
+ * the app holds: Inertia restores a page from history, or serves it from the
1481
+ * prefetch cache, with the props it was fetched with, and those must not undo
1482
+ * a later `useUserSession().fetch()`.
1483
+ */
1484
+ function isStaleSession(
1485
+ incoming: DmsSession | undefined,
1486
+ current: DmsSession | null,
1487
+ ): boolean {
1488
+ return (
1489
+ !!incoming &&
1490
+ !!current &&
1491
+ incoming.accountId === current.accountId &&
1492
+ (incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
1493
+ );
1494
+ }
1495
+
1374
1496
  export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1375
1497
  const runtime = useDmsRuntime();
1376
1498
  if (url) updateRoute(runtime, url);
@@ -1384,10 +1506,12 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1384
1506
  }
1385
1507
  runtime.pageVersion++;
1386
1508
  runtime.currentError.value = null;
1387
- useDmsState<DmsUser | null>("dms-user", () => null).value =
1388
- props.user ?? null;
1389
- useDmsState<DmsSession | null>("dms-session", () => null).value =
1390
- props.session ?? null;
1509
+ const session = useDmsState<DmsSession | null>("dms-session", () => null);
1510
+ if (!isStaleSession(props.session, session.value)) {
1511
+ useDmsState<DmsUser | null>("dms-user", () => null).value =
1512
+ props.user ?? null;
1513
+ session.value = props.session ?? null;
1514
+ }
1391
1515
  const shared = props.page.shared;
1392
1516
  if (shared) {
1393
1517
  useDmsState<unknown>("dms-siteLayout", () => undefined).value =
@@ -1451,14 +1575,28 @@ function registerDmsFrontendEntry(
1451
1575
  if (!entries.has(name)) entries.set(name, { component, preload });
1452
1576
  }
1453
1577
 
1578
+ /** `name` behind `prefix`, as a PascalCase component name. */
1579
+ function prefixedComponentName(prefix: string, name: string): string {
1580
+ return prefix ? `${prefix}${capitalize(camelize(name))}` : name;
1581
+ }
1582
+
1454
1583
  function createSdk(
1455
1584
  target: DmsFrontendRegistry,
1456
- options: DmsModuleOptions,
1585
+ {
1586
+ name: module,
1587
+ module: { componentPrefix = "" },
1588
+ options,
1589
+ }: DmsFrontendModuleRegistration,
1457
1590
  ): DmsFrontendSdk {
1458
1591
  return {
1459
1592
  options,
1460
- registerComponent: (name, component) =>
1461
- registerDmsComponentEntry(target.components, name, component),
1593
+ registerComponent: (name, component, { prefix = true } = {}) =>
1594
+ registerDmsComponentEntry(target.components, {
1595
+ name: prefix ? prefixedComponentName(componentPrefix, name) : name,
1596
+ component,
1597
+ module,
1598
+ unprefixed: !prefix,
1599
+ }),
1462
1600
  registerPage: (name, component, preload) => {
1463
1601
  const key = normalizeDmsPageKey(name);
1464
1602
  registerDmsFrontendEntry(target.pages, key, component, preload);
@@ -1519,7 +1657,7 @@ export async function setupFrontendModules(
1519
1657
  runtimeConfig.value.public,
1520
1658
  registration.options.public,
1521
1659
  );
1522
- await registration.module.setup(createSdk(next, registration.options));
1660
+ await registration.module.setup(createSdk(next, registration));
1523
1661
  }
1524
1662
  registry = next;
1525
1663
  }
@@ -0,0 +1,78 @@
1
+ import {
2
+ addCollection,
3
+ addIcon,
4
+ getIcon,
5
+ Icon,
6
+ type IconifyIcon,
7
+ type IconifyJSON,
8
+ iconLoaded,
9
+ } from "@iconify/vue";
10
+ import type { App, Component, ComponentPublicInstance } from "vue";
11
+
12
+ /** The element a server render writes the data of the icons it drew to. */
13
+ export const SSR_ICONS_ID = "dms-ssr-icons";
14
+
15
+ interface IconProps {
16
+ icon?: unknown;
17
+ }
18
+
19
+ /**
20
+ * Draws an Iconify icon in its first render whenever its data is loaded, on
21
+ * the server as in the browser.
22
+ *
23
+ * `@iconify/vue` otherwise renders every icon empty until it mounts, which a
24
+ * server render never does: pages arrived without their icons, which popped in
25
+ * once hydrated. Its `ssr` prop opts out of the wait, but Nuxt UI's `UIcon`
26
+ * does not pass it, so its default is set here, and only for an icon whose
27
+ * data is loaded: an icon still to be fetched waits for the mount as before,
28
+ * so a server render never queries an Iconify API.
29
+ */
30
+ export function drawLoadedIconsAtOnce(): void {
31
+ (Icon as unknown as { props: Record<string, unknown> }).props.ssr = {
32
+ type: Boolean,
33
+ default: ({ icon }: IconProps) =>
34
+ typeof icon !== "string" || iconLoaded(icon),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Loads whole icon collections, so a server render draws any icon of them,
40
+ * including those only named by backend data, which the client bundle's scan
41
+ * cannot see.
42
+ */
43
+ export function registerDmsIconCollections(collections: IconifyJSON[]): void {
44
+ for (const collection of collections) addCollection(collection);
45
+ }
46
+
47
+ /** Records the name of every Iconify icon a server render reaches. */
48
+ export function trackDmsIcons(app: App): Set<string> {
49
+ const names = new Set<string>();
50
+ app.mixin({
51
+ beforeCreate(this: ComponentPublicInstance) {
52
+ if (this.$.type !== (Icon as Component)) return;
53
+ const { icon } = this.$props as IconProps;
54
+ if (typeof icon === "string") names.add(icon);
55
+ },
56
+ });
57
+ return names;
58
+ }
59
+
60
+ /**
61
+ * The data of the icons a server render drew, which the browser loads before
62
+ * it hydrates: an icon outside the client bundle would otherwise hydrate
63
+ * empty over the server's markup.
64
+ */
65
+ export function serializeDmsIcons(
66
+ names: Iterable<string>,
67
+ ): Record<string, IconifyIcon> {
68
+ return Object.fromEntries(
69
+ [...names].flatMap((name) => {
70
+ const data = getIcon(name);
71
+ return data ? [[name, data]] : [];
72
+ }),
73
+ );
74
+ }
75
+
76
+ export function addDmsIcons(icons: Record<string, IconifyIcon>): void {
77
+ for (const [name, data] of Object.entries(icons)) addIcon(name, data);
78
+ }
@@ -1,4 +1,4 @@
1
- import { clientIp } from "./client-ip.mjs";
1
+ import { clientCountryHeader, clientIp } from "./client-ip.mjs";
2
2
 
3
3
  const FORWARDED_HEADERS = [
4
4
  "user-agent",
@@ -26,12 +26,26 @@ export function publicBackendMessage(data) {
26
26
  : undefined;
27
27
  }
28
28
 
29
- export async function backend(path, request, options = {}) {
30
- const headers = Object.fromEntries(
31
- FORWARDED_HEADERS.flatMap((name) =>
32
- request.headers[name] ? [[name, request.headers[name]]] : [],
29
+ /**
30
+ * The headers of a client request the server relays to the backend.
31
+ *
32
+ * @param request The client request
33
+ * @param names Lowercase names of the request headers to copy when present
34
+ * @returns The client country header, then the copied headers
35
+ */
36
+ export function relayedHeaders(request, names) {
37
+ return {
38
+ ...clientCountryHeader(request),
39
+ ...Object.fromEntries(
40
+ names.flatMap((name) =>
41
+ request.headers[name] ? [[name, request.headers[name]]] : [],
42
+ ),
33
43
  ),
34
- );
44
+ };
45
+ }
46
+
47
+ export async function backend(path, request, options = {}) {
48
+ const headers = relayedHeaders(request, FORWARDED_HEADERS);
35
49
  headers["x-forwarded-for"] = clientIp(request);
36
50
  if (options.token) headers.authorization = `Bearer ${options.token}`;
37
51
  if (options.relay)
@@ -51,6 +51,34 @@ export function clientIp(request) {
51
51
  return chain[Math.max(0, chain.length - hops - 1)] ?? socketIp;
52
52
  }
53
53
 
54
+ const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
55
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
56
+
57
+ function countryHeader() {
58
+ const name = (process.env.DMS_COUNTRY_HEADER ?? DEFAULT_COUNTRY_HEADER)
59
+ .trim()
60
+ .toLowerCase();
61
+ return HEADER_NAME.test(name) ? name : undefined;
62
+ }
63
+
64
+ /**
65
+ * The client's country header, as the backend request headers to add.
66
+ *
67
+ * The header is the one `DMS_COUNTRY_HEADER` names (`CF-IPCountry` by
68
+ * default, empty to relay none), the name the DMS reads from
69
+ * `auth.signInCountry.header`. Like `X-Forwarded-For`, it is only believed
70
+ * from a trusted reverse proxy: with `DMS_TRUSTED_PROXY_HOPS` at `0`, any
71
+ * caller could have set it, so nothing is relayed.
72
+ */
73
+ export function clientCountryHeader(request) {
74
+ const name = countryHeader();
75
+ if (!name || !trustedHops()) return {};
76
+ const value = request.headers?.[name];
77
+ return typeof value === "string" && value.trim()
78
+ ? { [name]: value.trim() }
79
+ : {};
80
+ }
81
+
54
82
  /**
55
83
  * Body answered with the 403 of the CSRF origin check.
56
84
  *
@@ -66,6 +66,7 @@ export function publicSession(session) {
66
66
  session: {
67
67
  accountId: session.accountId,
68
68
  activeTenantId: session.activeTenantId,
69
+ updatedAt: session.updatedAt,
69
70
  },
70
71
  };
71
72
  }
@@ -146,9 +146,15 @@ export function assertAccountInvariant(session, accountId) {
146
146
  throw new Error("Account session invariant violated");
147
147
  }
148
148
 
149
+ /**
150
+ * Write `session` as the current one, and as one of the stored accounts.
151
+ * Stamps it with `updatedAt`, which lets the browser tell a page fetched
152
+ * before this write, and restored from its cache, from a current one.
153
+ */
149
154
  export function persistAccountSession(request, response, session, accountId) {
150
155
  const id = accountId ?? session.accountId ?? randomUUID();
151
156
  session.accountId = id;
157
+ session.updatedAt = Date.now();
152
158
  const account = storeAccount(request, response, session, id);
153
159
  assertAccountInvariant(session, account.accountId);
154
160
  writeSession(response, session);
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readdir, readFile } from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -14,12 +14,16 @@ const READ_METHODS = new Set(["GET", "HEAD"]);
14
14
  const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
15
  const COLLECTION_FILE = /^([^/]+)\.json$/;
16
16
  const ICON_NAME_SEPARATOR = ",";
17
+ const COLLECTION_PACKAGES = join("node_modules", "@iconify-json");
17
18
  // An icon's data only changes with the installed collection, which a
18
19
  // deployment replaces; a week bounds how long an upgraded icon can lag.
19
20
  const ICON_CACHE_CONTROL =
20
21
  "public, max-age=604800, stale-while-revalidate=86400";
21
22
  const ERROR_CACHE_CONTROL = "no-store";
22
23
  const collections = new Map();
24
+ // By renderer module: a development server evaluates a new one after a layer
25
+ // changes, and each must find the collections loaded before it renders.
26
+ const rendererCollections = new WeakMap();
23
27
 
24
28
  function collectionSpecifiers(prefix) {
25
29
  return [
@@ -60,6 +64,44 @@ function collection(root, prefix) {
60
64
  return collections.get(key);
61
65
  }
62
66
 
67
+ /**
68
+ * Every `@iconify-json/<prefix>` collection installed in the workspace under
69
+ * `root`, the ones the server render draws from. The workspace hoists every
70
+ * dependency, a module's collections included.
71
+ */
72
+ export async function installedCollections(root) {
73
+ const prefixes = await readdir(join(root, COLLECTION_PACKAGES)).catch(
74
+ () => [],
75
+ );
76
+ const loaded = await Promise.all(
77
+ prefixes
78
+ .filter((prefix) => ICON_NAME.test(prefix))
79
+ .map((prefix) => collection(root, prefix)),
80
+ );
81
+ return loaded.filter(Boolean);
82
+ }
83
+
84
+ /**
85
+ * Hand a server renderer the installed collections, once per renderer module,
86
+ * before its first render. A collection that fails to load leaves the render
87
+ * with the bundled icons only.
88
+ */
89
+ export function provideIconCollections(renderer, root = WORKSPACE_ROOT) {
90
+ if (!rendererCollections.has(renderer))
91
+ rendererCollections.set(
92
+ renderer,
93
+ installedCollections(root)
94
+ .then((loaded) => renderer.registerDmsIconCollections(loaded))
95
+ .catch((error) =>
96
+ console.error(
97
+ "DMS icon collections not loaded; server renders draw bundled icons only",
98
+ error,
99
+ ),
100
+ ),
101
+ );
102
+ return rendererCollections.get(renderer);
103
+ }
104
+
63
105
  function jsonResponse(status, cacheControl, payload) {
64
106
  return {
65
107
  status,
@@ -5,7 +5,11 @@ import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { ofetch } from "ofetch";
8
- import { RequestBodyError, UpstreamError } from "./server/auth/backend.mjs";
8
+ import {
9
+ relayedHeaders,
10
+ RequestBodyError,
11
+ UpstreamError,
12
+ } from "./server/auth/backend.mjs";
9
13
  import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
10
14
  import {
11
15
  handleAuth,
@@ -36,7 +40,7 @@ import { documentStyleTags } from "./server/dev-styles.mjs";
36
40
  import { handleEmailRender, watchEmailBundle } from "./server/email.mjs";
37
41
  import { HOMEPAGE } from "./server/homepage.mjs";
38
42
  import { htmlTag } from "./server/html-tag.mjs";
39
- import { handleIcons, isIconRequest } from "./server/icons.mjs";
43
+ import * as icons from "./server/icons.mjs";
40
44
  import * as requestFailure from "./server/request-failure.mjs";
41
45
  import { handleTester } from "./server/tester.mjs";
42
46
  import {
@@ -75,7 +79,7 @@ const HTML_RENDER_ROUTE = "/api/html/render";
75
79
  const TESTER_ROUTE = /^\/api\/_dms\/tester\/?$/;
76
80
  const LOCAL_ROUTES = [
77
81
  [(pathname) => TESTER_ROUTE.test(pathname), handleTester],
78
- [isIconRequest, handleIcons],
82
+ [icons.isIconRequest, icons.handleIcons],
79
83
  ];
80
84
  const AUTH_PAGE = "/auth";
81
85
  const ONBOARDING_PAGE = "/onboarding";
@@ -117,18 +121,13 @@ async function developmentServer() {
117
121
  }
118
122
 
119
123
  function backendHeaders(request) {
120
- const names = [
124
+ const headers = relayedHeaders(request, [
121
125
  "accept",
122
126
  "content-type",
123
127
  "user-agent",
124
128
  "x-content-language",
125
129
  "x-realtime-session",
126
- ];
127
- const headers = Object.fromEntries(
128
- names.flatMap((name) =>
129
- request.headers[name] ? [[name, request.headers[name]]] : [],
130
- ),
131
- );
130
+ ]);
132
131
  const session = readSession(request);
133
132
  if (session?.accessToken)
134
133
  headers.authorization = `Bearer ${session.accessToken}`;
@@ -297,9 +296,11 @@ function serveAsset(pathname, request, response) {
297
296
  }
298
297
 
299
298
  async function ssrRenderer(devServer) {
300
- if (devServer) return devServer.ssrLoadModule("/ssr-renderer.ts");
301
- productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH);
302
- return productionSsrRenderer;
299
+ const renderer = devServer
300
+ ? await devServer.ssrLoadModule("/ssr-renderer.ts")
301
+ : await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH));
302
+ await icons.provideIconCollections(renderer);
303
+ return renderer;
303
304
  }
304
305
 
305
306
  export async function renderHtml(
@@ -24,6 +24,13 @@ import {
24
24
  trackDmsServerScopes,
25
25
  } from "./frontend-module";
26
26
  import { frontendModules } from "./frontend-modules.generated";
27
+ import {
28
+ SSR_ICONS_ID,
29
+ serializeDmsIcons,
30
+ trackDmsIcons,
31
+ } from "./icon-hydration";
32
+
33
+ export { registerDmsIconCollections } from "./icon-hydration";
27
34
 
28
35
  export interface DmsSsrPage {
29
36
  component: string;
@@ -75,6 +82,14 @@ function asyncComponentsScript(names: Set<string>): string {
75
82
  return `<script id="${SSR_ASYNC_COMPONENTS_ID}" type="application/json">${value}</script>`;
76
83
  }
77
84
 
85
+ function iconsScript(names: Set<string>): string {
86
+ const value = JSON.stringify(serializeDmsIcons(names)).replaceAll(
87
+ "<",
88
+ "\\u003c",
89
+ );
90
+ return `<script id="${SSR_ICONS_ID}" type="application/json">${value}</script>`;
91
+ }
92
+
78
93
  await setupFrontendModules(frontendModules);
79
94
 
80
95
  export function isDmsFrontendPage(path: string): boolean {
@@ -117,6 +132,7 @@ async function renderDmsPageWithRuntime(
117
132
  const head = createHead();
118
133
  const ssrContext: DmsSsrContext = {};
119
134
  let asyncComponents = new Set<string>();
135
+ let icons = new Set<string>();
120
136
  const inertiaResult = await createInertiaApp({
121
137
  page,
122
138
  render: (app) => renderToString(app, ssrContext),
@@ -124,6 +140,7 @@ async function renderDmsPageWithRuntime(
124
140
  async setup({ App, props, plugin }) {
125
141
  const app = createSSRApp({ render: () => h(App, props) });
126
142
  asyncComponents = trackDmsAsyncComponents(app);
143
+ icons = trackDmsIcons(app);
127
144
  stopScopes = trackDmsServerScopes(app);
128
145
  const configured = await configureDmsApp({
129
146
  app,
@@ -173,7 +190,7 @@ async function renderDmsPageWithRuntime(
173
190
  const renderedHead = await renderSSRHead(head);
174
191
  return {
175
192
  error: page.props.error,
176
- body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
193
+ body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}`,
177
194
  head: {
178
195
  bodyAttrs: renderedHead.bodyAttrs,
179
196
  headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
@@ -2,7 +2,15 @@ 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, type LogLevel, normalizePath, type Plugin } from "vite";
5
+ import {
6
+ createLogger,
7
+ defineConfig,
8
+ type LogLevel,
9
+ normalizePath,
10
+ type Plugin,
11
+ } from "vite";
12
+ import { autoImportDirectories } from "./frontend-build-loader";
13
+ import { frontendBuilds } from "./frontend-builds.generated";
6
14
  import { orderHeadForFirstPaint } from "./head-order.mjs";
7
15
  import { iconScanGlobs } from "./icon-scan.mjs";
8
16
 
@@ -40,21 +48,19 @@ const stableModuleAliases = Object.fromEntries(
40
48
  }),
41
49
  );
42
50
  /**
43
- * Globs are POSIX, always.
44
- *
45
- * `resolve` returns a native path, so on Windows every pattern below would
46
- * carry backslashes — which a glob matcher reads as escape characters, not as
47
- * separators, and which therefore match nothing at all. `normalizePath` is
48
- * Vite's own answer to this and is the identity function on POSIX.
51
+ * `ajs dms build` runs Vite at "warn" and reports the steps itself; a verbose
52
+ * run, `dev` and a hand-run build keep Vite's own output.
53
+ */
54
+ const logLevel = (process.env.DMS_VITE_LOG_LEVEL ?? "info") as LogLevel;
55
+ const logger = createLogger(logLevel);
56
+ /**
57
+ * Only what each module declares in its `dms.frontend.build.ts`: a module
58
+ * without one has nothing auto-imported.
49
59
  */
50
- const importDirectories = frontendSourceRoots.flatMap((root) =>
51
- [
52
- "app/composables/**/*",
53
- "app/types/**/*",
54
- "app/utils/**/*",
55
- "app/build/composables/**/*",
56
- "app/build/types/**/*",
57
- ].map((glob) => ({ glob: normalizePath(resolve(root, glob)), types: true })),
60
+ const importDirectories = autoImportDirectories(
61
+ registry.modules,
62
+ frontendBuilds,
63
+ (message) => logger.warn(message),
58
64
  );
59
65
  const optimizedDependencies = [
60
66
  "vue",
@@ -90,8 +96,9 @@ const optimizedDependencies = [
90
96
  * startup, pays that cost a single time and in a place where it reads as
91
97
  * startup rather than as a crash.
92
98
  *
93
- * Normalized for the same reason as `importDirectories`: Vite hands these
94
- * patterns straight to its glob matcher without touching the separators.
99
+ * Globs are POSIX, always: `resolve` returns a native path, whose Windows
100
+ * backslashes a glob matcher reads as escape characters, not as separators.
101
+ * `normalizePath` is Vite's own answer to this, the identity on POSIX.
95
102
  */
96
103
  const optimizerEntries = [
97
104
  resolve(__dirname, "main.ts"),
@@ -117,12 +124,6 @@ const uiInertiaLinkImport = resolve(
117
124
  "node_modules/@nuxt/ui/dist/runtime/vue/overrides/inertia/Link.vue",
118
125
  );
119
126
 
120
- /**
121
- * `ajs dms build` runs Vite at "warn" and reports the steps itself; a verbose
122
- * run, `dev` and a hand-run build keep Vite's own output.
123
- */
124
- const logLevel = (process.env.DMS_VITE_LOG_LEVEL ?? "info") as LogLevel;
125
-
126
127
  export default defineConfig({
127
128
  logLevel,
128
129
  plugins: [
@@ -175,6 +176,7 @@ export default defineConfig({
175
176
  "useI18n",
176
177
  "useError",
177
178
  "useDmsLazyAsyncData",
179
+ "useDmsPageLoading",
178
180
  "useDmsApp",
179
181
  "useDmsRoute",
180
182
  "useDmsRouter",