@antelopejs/dms-frontend 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,8 @@ An email entry exports `serverEmailTemplates` and may export a plain `appConfig`
112
112
 
113
113
  Email builds ship complete merged translation catalogs as JSON under `dist/server/locales/`, separate from executable JavaScript. Deploy the entire `dist/server` directory. Each render loads only its requested language and the English fallback; arbitrary translation keys and module overrides remain available. Unknown languages fall back to English, while missing or corrupt files for a supported language fail rendering rather than silently dropping translations. The source harness keeps the 256 KiB JavaScript ceiling and reports locale-data bytes separately.
114
114
 
115
+ `ajs-dms dev` builds the email bundle too, once the dev server is ready, and rebuilds it whenever an email template changes. The build runs in a separate low-priority process, so page reloads and HMR never wait on it; a render requested while a build is running waits for that build to finish.
116
+
115
117
  ## Frontend modules
116
118
 
117
119
  A materialized module opts into the Vue adapter with a root `dms.frontend.ts`:
@@ -133,6 +135,19 @@ export default frontendModule;
133
135
 
134
136
  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
137
 
138
+ ### Redirecting typed access refusals
139
+
140
+ A backend can refuse a whole surface with a typed 403 whose body is a machine-readable code, such as a tenant access gate blocking a suspended workspace. A module that owns such a code registers where a refused page visit goes instead of the error page:
141
+
142
+ ```ts
143
+ sdk.registerAccessRedirect(
144
+ "saas.errors.workspace.access_blocked",
145
+ "/workspace-suspended",
146
+ );
147
+ ```
148
+
149
+ The server answers the refused visit, document or Inertia, with a redirect to that path before anything renders, so a reload lands there directly. The body may be the bare code or JSON (`"code"` or `{ "message": "code" }`). A destination refused in turn keeps the error page instead of looping. The first registration of a code wins, in manifest-priority order.
150
+
136
151
  ### Components rendered inside `<svg>`
137
152
 
138
153
  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:
@@ -304,6 +319,11 @@ the matching Inertia page object to the browser for hydration. Subsequent
304
319
  plugins and `DmsClientOnly` defer browser-only work while the same generated
305
320
  frontend-module registry drives server and client entries.
306
321
 
322
+ Under `ajs dms dev`, an edit to a module's `i18n/locales/*.json` regenerates the
323
+ merged catalogs the application imports: the next server render uses them, and
324
+ the open page applies them in place, without a reload. Adding or removing a
325
+ locale reloads the page instead.
326
+
307
327
  ## Options
308
328
 
309
329
  | Option | Environment | Purpose |
@@ -316,11 +336,15 @@ frontend-module registry drives server and client entries.
316
336
  | | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
317
337
  | | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
318
338
  | | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
339
+ | | `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 |
340
+ | | `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 |
319
341
  | | `DMS_AUTH_ESTABLISH_ENDPOINTS` | Extra backend endpoints `/auth/establish` may open a session from, on top of those the backend's modules declare (comma-separated, empty by default) |
320
342
  | | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
321
343
 
322
344
  All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
323
345
 
346
+ `ajs dms dev` takes the two manifest secrets from the manifest it fetches, and `ajs dms start` from the manifest cached by `ajs dms build`. An explicitly set variable always wins over the manifest value. Before the server starts, both commands log where each secret comes from (`env`, `manifest`, `build-time manifest` or `not set`), never its value. When several modules publish the same secret, the first one in manifest-priority order wins, and a module publishing a different value is named in a warning.
347
+
324
348
  Use pnpm for all repository and workspace operations.
325
349
 
326
350
  ## Verify local frontend modules
@@ -66,7 +66,7 @@ function cmdDev() {
66
66
  const spinner = new cli_ui_1.Spinner("Setting up workspace...");
67
67
  await spinner.start();
68
68
  try {
69
- const { workspaceDir, layers, manifestFromCache, manifestFetchedAt } = await (0, common_1.setupWorkspace)({
69
+ const { workspaceDir, layers, manifestFromCache, manifestFetchedAt, manifestSecrets, } = await (0, common_1.setupWorkspace)({
70
70
  backendUrl,
71
71
  force: !!options.force,
72
72
  mode: "dev",
@@ -90,6 +90,8 @@ function cmdDev() {
90
90
  console.log(chalk_1.default.dim(` Workspace: ${workspaceDir}`));
91
91
  console.log(chalk_1.default.dim(` Backend: ${backendUrl}`));
92
92
  console.log(chalk_1.default.dim(` Watching: ${layers.length} layer source tree(s)`));
93
+ const secrets = (0, common_1.resolveManifestSecrets)(manifestSecrets);
94
+ (0, common_1.reportManifestSecrets)(secrets);
93
95
  console.log("");
94
96
  const nodeModulesDir = (0, node_path_1.join)(workspaceDir, "node_modules");
95
97
  await reserved.release();
@@ -103,6 +105,7 @@ function cmdDev() {
103
105
  DMS_API_BASE_URL: backendUrl,
104
106
  DMS_BOOTSTRAP_SECRET: bootstrapSecret,
105
107
  DMS_SESSION_SECRET: sessionSecret,
108
+ ...secrets.env,
106
109
  NODE_OPTIONS: "--max-old-space-size=4096",
107
110
  NODE_PATH: nodeModulesDir,
108
111
  },
@@ -35,6 +35,8 @@ function cmdStart() {
35
35
  console.log("");
36
36
  (0, cli_ui_1.info)(`Starting production server on port ${chalk_1.default.cyan(options.port)}...`);
37
37
  console.log(chalk_1.default.dim(` Workspace: ${workspaceDir}`));
38
+ const secrets = (0, common_1.resolveManifestSecrets)((0, common_1.collectManifestSecrets)((0, common_1.readCachedManifest)(workspaceDir)?.manifest.modules ?? []));
39
+ (0, common_1.reportManifestSecrets)(secrets, "build-time manifest");
38
40
  console.log("");
39
41
  const code = await (0, common_1.runCommand)("node", [serverPath], {
40
42
  cwd: workspaceDir,
@@ -43,6 +45,7 @@ function cmdStart() {
43
45
  PORT: options.port,
44
46
  DMS_API_BASE_URL: options.backendUrl,
45
47
  DMS_SESSION_SECRET: sessionSecret,
48
+ ...secrets.env,
46
49
  DMS_COOKIE_SECURE: process.env.DMS_COOKIE_SECURE ?? "true",
47
50
  },
48
51
  });
package/dist/common.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./layers";
3
3
  export * from "./layer-aliases";
4
4
  export * from "./manifest";
5
5
  export * from "./fs-sync";
6
+ export * from "./manifest-secrets";
6
7
  export * from "./layer-watch";
7
8
  export * from "./materialize";
8
9
  export * from "./workspace";
package/dist/common.js CHANGED
@@ -19,6 +19,7 @@ __exportStar(require("./layers"), exports);
19
19
  __exportStar(require("./layer-aliases"), exports);
20
20
  __exportStar(require("./manifest"), exports);
21
21
  __exportStar(require("./fs-sync"), exports);
22
+ __exportStar(require("./manifest-secrets"), exports);
22
23
  __exportStar(require("./layer-watch"), exports);
23
24
  __exportStar(require("./materialize"), exports);
24
25
  __exportStar(require("./workspace"), exports);
package/dist/config.js CHANGED
@@ -35,6 +35,7 @@ exports.TEMPLATE_FILES = [
35
35
  ["index.html", "index.html"],
36
36
  ["main.ts", "main.ts"],
37
37
  ["app-runtime.ts", "app-runtime.ts"],
38
+ ["network-error.ts", "network-error.ts"],
38
39
  ["ssr-renderer.ts", "ssr-renderer.ts"],
39
40
  ["frontend-module.ts", "frontend-module.ts"],
40
41
  ["globals.d.ts", "globals.d.ts"],
@@ -8,9 +8,11 @@ const node_fs_1 = require("node:fs");
8
8
  const node_path_1 = require("node:path");
9
9
  const chokidar_1 = __importDefault(require("chokidar"));
10
10
  const layers_1 = require("./layers");
11
+ const materialize_1 = require("./materialize");
11
12
  const fs_sync_1 = require("./fs-sync");
12
13
  const WATCH_DEBOUNCE_MS = 80;
13
- function createLayerSync(src, dest) {
14
+ const LOCALE_REFRESH_DEBOUNCE_MS = 100;
15
+ function createLayerSync(src, dest, onFlushed) {
14
16
  const fileUpserts = new Set();
15
17
  const fileDeletes = new Set();
16
18
  const dirCreates = new Set();
@@ -25,6 +27,12 @@ function createLayerSync(src, dest) {
25
27
  };
26
28
  const flush = () => {
27
29
  timer = null;
30
+ const touched = [
31
+ ...dirCreates,
32
+ ...fileUpserts,
33
+ ...fileDeletes,
34
+ ...dirDeletes,
35
+ ];
28
36
  for (const rel of [...dirCreates].sort((a, b) => a.length - b.length)) {
29
37
  try {
30
38
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(dest, rel), { recursive: true });
@@ -78,6 +86,7 @@ function createLayerSync(src, dest) {
78
86
  }
79
87
  }
80
88
  dirDeletes.clear();
89
+ onFlushed(touched);
81
90
  };
82
91
  const arm = () => {
83
92
  if (timer)
@@ -124,15 +133,45 @@ function createLayerSync(src, dest) {
124
133
  },
125
134
  };
126
135
  }
136
+ function createLocaleRefresher(workspaceDir, layers) {
137
+ const registry = (0, materialize_1.createFrontendModuleRegistry)(workspaceDir, layers);
138
+ let timer = null;
139
+ const refresh = () => {
140
+ timer = null;
141
+ try {
142
+ (0, materialize_1.writeLocaleMessages)(workspaceDir, registry);
143
+ }
144
+ catch (err) {
145
+ console.warn("[ajs-dms] locale catalogs not regenerated:", err);
146
+ }
147
+ };
148
+ return {
149
+ schedule() {
150
+ if (timer)
151
+ clearTimeout(timer);
152
+ timer = setTimeout(refresh, LOCALE_REFRESH_DEBOUNCE_MS);
153
+ },
154
+ flushNow() {
155
+ if (!timer)
156
+ return;
157
+ clearTimeout(timer);
158
+ refresh();
159
+ },
160
+ };
161
+ }
127
162
  function startLayerWatchers(workspaceDir, layers) {
128
163
  const watchers = [];
129
164
  const syncs = [];
165
+ const localeRefresher = createLocaleRefresher(workspaceDir, layers);
130
166
  for (const layer of layers) {
131
167
  if (!layer.packageName)
132
168
  continue;
133
169
  const dest = (0, layers_1.getLayerWorkspacePath)(workspaceDir, layer);
134
170
  const src = layer.path;
135
- const sync = createLayerSync(src, dest);
171
+ const sync = createLayerSync(src, dest, (paths) => {
172
+ if (paths.some(materialize_1.affectsLocaleMessages))
173
+ localeRefresher.schedule();
174
+ });
136
175
  syncs.push(sync);
137
176
  const watcher = chokidar_1.default.watch(src, {
138
177
  ignoreInitial: true,
@@ -150,5 +189,6 @@ function startLayerWatchers(workspaceDir, layers) {
150
189
  await Promise.all(watchers.map((w) => w.close().catch(() => { })));
151
190
  for (const sync of syncs)
152
191
  sync.flushNow();
192
+ localeRefresher.flushNow();
153
193
  };
154
194
  }
@@ -0,0 +1,31 @@
1
+ import type { ManifestModule } from "./workspace";
2
+ export declare const MANIFEST_SECRET_ENV: {
3
+ readonly DMS_HTML_RENDER_SECRET: readonly ["htmlRender", "serviceSecret"];
4
+ readonly DMS_OAUTH_RELAY_SECRET: readonly ["oauth", "relaySecret"];
5
+ };
6
+ export type ManifestSecretName = keyof typeof MANIFEST_SECRET_ENV;
7
+ interface PublishedSecret {
8
+ value: string;
9
+ module: string;
10
+ }
11
+ export interface ManifestSecretConflict {
12
+ name: ManifestSecretName;
13
+ used: string;
14
+ ignored: string[];
15
+ }
16
+ export interface ManifestSecrets {
17
+ published: Partial<Record<ManifestSecretName, PublishedSecret>>;
18
+ conflicts: ManifestSecretConflict[];
19
+ }
20
+ export type ManifestSecretSource = "env" | "manifest" | "not set";
21
+ export interface ResolvedManifestSecrets {
22
+ env: Partial<Record<ManifestSecretName, string>>;
23
+ sources: Record<ManifestSecretName, ManifestSecretSource>;
24
+ conflicts: ManifestSecretConflict[];
25
+ }
26
+ export declare function collectManifestSecrets(modules: readonly ManifestModule[]): ManifestSecrets;
27
+ export declare function resolveManifestSecrets(secrets: ManifestSecrets, env?: NodeJS.ProcessEnv): ResolvedManifestSecrets;
28
+ export declare function formatSecretConflicts(conflicts: readonly ManifestSecretConflict[]): string[];
29
+ export declare function formatSecretSources(sources: ResolvedManifestSecrets["sources"], manifestLabel?: string): string[];
30
+ export declare function reportManifestSecrets(resolved: ResolvedManifestSecrets, manifestLabel?: string, log?: (line: string) => void): void;
31
+ export {};
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MANIFEST_SECRET_ENV = void 0;
7
+ exports.collectManifestSecrets = collectManifestSecrets;
8
+ exports.resolveManifestSecrets = resolveManifestSecrets;
9
+ exports.formatSecretConflicts = formatSecretConflicts;
10
+ exports.formatSecretSources = formatSecretSources;
11
+ exports.reportManifestSecrets = reportManifestSecrets;
12
+ const chalk_1 = __importDefault(require("chalk"));
13
+ exports.MANIFEST_SECRET_ENV = {
14
+ DMS_HTML_RENDER_SECRET: ["htmlRender", "serviceSecret"],
15
+ DMS_OAUTH_RELAY_SECRET: ["oauth", "relaySecret"],
16
+ };
17
+ const SECRET_NAMES = Object.keys(exports.MANIFEST_SECRET_ENV);
18
+ const UNSET_CONSEQUENCE = {
19
+ DMS_HTML_RENDER_SECRET: "e-mail renders will be refused",
20
+ DMS_OAUTH_RELAY_SECRET: "OAuth sign-in will be refused by the backend",
21
+ };
22
+ function readPath(options, path) {
23
+ let value = options;
24
+ for (const key of path) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return undefined;
27
+ value = value[key];
28
+ }
29
+ return value;
30
+ }
31
+ function byPriority(modules) {
32
+ return [...modules].sort((a, b) => b.priority - a.priority);
33
+ }
34
+ function collectManifestSecrets(modules) {
35
+ const published = {};
36
+ const conflicts = [];
37
+ const ordered = byPriority(modules);
38
+ for (const name of SECRET_NAMES) {
39
+ const ignored = [];
40
+ for (const module of ordered) {
41
+ const value = readPath(module.privateOptions, exports.MANIFEST_SECRET_ENV[name]);
42
+ if (typeof value !== "string" || value === "")
43
+ continue;
44
+ const first = published[name];
45
+ if (!first)
46
+ published[name] = { value, module: module.name };
47
+ else if (first.value !== value)
48
+ ignored.push(module.name);
49
+ }
50
+ const used = published[name];
51
+ if (used && ignored.length)
52
+ conflicts.push({ name, used: used.module, ignored });
53
+ }
54
+ return { published, conflicts };
55
+ }
56
+ function resolveManifestSecrets(secrets, env = process.env) {
57
+ const resolved = {
58
+ env: {},
59
+ sources: {},
60
+ conflicts: [],
61
+ };
62
+ for (const name of SECRET_NAMES) {
63
+ const explicit = env[name];
64
+ const published = secrets.published[name];
65
+ if (explicit) {
66
+ resolved.env[name] = explicit;
67
+ resolved.sources[name] = "env";
68
+ }
69
+ else if (published) {
70
+ resolved.env[name] = published.value;
71
+ resolved.sources[name] = "manifest";
72
+ }
73
+ else {
74
+ resolved.sources[name] = "not set";
75
+ }
76
+ }
77
+ resolved.conflicts = secrets.conflicts.filter((conflict) => resolved.sources[conflict.name] === "manifest");
78
+ return resolved;
79
+ }
80
+ function formatSecretConflicts(conflicts) {
81
+ return conflicts.map(({ name, used, ignored }) => `${name}: ${ignored.join(", ")} publish${ignored.length === 1 ? "es" : ""} a different value than ${used}; using ${used}'s`);
82
+ }
83
+ function formatSecretSources(sources, manifestLabel = "manifest") {
84
+ const width = Math.max(...SECRET_NAMES.map((name) => name.length));
85
+ return SECRET_NAMES.map((name) => {
86
+ const source = sources[name];
87
+ const label = source === "manifest"
88
+ ? manifestLabel
89
+ : source === "not set"
90
+ ? `not set (${UNSET_CONSEQUENCE[name]})`
91
+ : source;
92
+ return `${name.padEnd(width)} ${label}`;
93
+ });
94
+ }
95
+ function reportManifestSecrets(resolved, manifestLabel = "manifest", log = console.log) {
96
+ for (const line of formatSecretConflicts(resolved.conflicts))
97
+ log(`${chalk_1.default.yellow.bold("⚠")} ${chalk_1.default.yellow(line)}`);
98
+ log(chalk_1.default.dim(" Server secrets:"));
99
+ for (const line of formatSecretSources(resolved.sources, manifestLabel))
100
+ log(chalk_1.default.dim(` ${line}`));
101
+ }
@@ -17,4 +17,6 @@ export interface FrontendModuleRegistry {
17
17
  export declare function createFrontendModuleRegistry(workspaceDir: string, layers: ResolvedLayer[]): FrontendModuleRegistry;
18
18
  export declare function collectAuthEstablishEndpoints(layers: ResolvedLayer[]): string[];
19
19
  export declare function writeFrontendModuleRegistry(workspaceDir: string, layers: ResolvedLayer[]): void;
20
+ export declare function affectsLocaleMessages(relativePath: string): boolean;
21
+ export declare function writeLocaleMessages(workspaceDir: string, registry: FrontendModuleRegistry): void;
20
22
  export declare function writeDmsMainCss(workspaceDir: string, layers: ResolvedLayer[]): void;
@@ -7,6 +7,8 @@ exports.materializeLayers = materializeLayers;
7
7
  exports.createFrontendModuleRegistry = createFrontendModuleRegistry;
8
8
  exports.collectAuthEstablishEndpoints = collectAuthEstablishEndpoints;
9
9
  exports.writeFrontendModuleRegistry = writeFrontendModuleRegistry;
10
+ exports.affectsLocaleMessages = affectsLocaleMessages;
11
+ exports.writeLocaleMessages = writeLocaleMessages;
10
12
  exports.writeDmsMainCss = writeDmsMainCss;
11
13
  const node_fs_1 = require("node:fs");
12
14
  const node_path_1 = require("node:path");
@@ -209,6 +211,16 @@ function mergeLocaleMessages(source, existing) {
209
211
  }
210
212
  return merged;
211
213
  }
214
+ const LOCALE_DIRECTORY = "i18n/locales";
215
+ function readLocaleFile(path) {
216
+ try {
217
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
218
+ }
219
+ catch (error) {
220
+ const reason = error instanceof Error ? error.message : String(error);
221
+ throw new Error(`Invalid locale file ${path}: ${reason}`);
222
+ }
223
+ }
212
224
  function readLocaleMessages(assets, registry) {
213
225
  const roots = new Map(registry.modules.map((module) => [module.id, module.root]));
214
226
  const messages = {};
@@ -217,27 +229,107 @@ function readLocaleMessages(assets, registry) {
217
229
  const root = roots.get(asset.moduleId);
218
230
  if (!locale || !root)
219
231
  continue;
220
- const value = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, asset.relativePath), "utf8"));
232
+ const value = readLocaleFile((0, node_path_1.join)(root, asset.relativePath));
221
233
  messages[locale] = mergeLocaleMessages(value, messages[locale] ?? {});
222
234
  }
223
235
  return messages;
224
236
  }
237
+ function affectsLocaleMessages(relativePath) {
238
+ const segments = (0, fs_sync_1.toPosixPath)(relativePath).split("/");
239
+ if (segments[0] === "layers" && segments.length <= 2)
240
+ return true;
241
+ const withinRoot = segments[0] === "layers" ? segments.slice(2) : segments;
242
+ return LOCALE_DIRECTORY.split("/").every((segment, index) => index >= withinRoot.length || withinRoot[index] === segment);
243
+ }
244
+ function localeModuleSource(supportedLocales) {
245
+ const loaders = supportedLocales
246
+ .filter((locale) => locale !== "en")
247
+ .map((locale) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(`./locales.generated/${locale}`)}),`);
248
+ return `import defaultMessages from "./locales.generated/en";
249
+
250
+ type LocaleMessages = Record<string, unknown>;
251
+ type LocaleLoader = () => Promise<{ default: LocaleMessages }>;
252
+ interface LocaleMessagesTarget {
253
+ setLocaleMessage(locale: string, messages: LocaleMessages): void;
254
+ }
255
+ interface LocaleState {
256
+ messages: Record<string, LocaleMessages>;
257
+ targets: Set<LocaleMessagesTarget>;
258
+ version: number;
259
+ }
260
+
261
+ export const supportedLocales = ${JSON.stringify(supportedLocales)};
262
+ const localeLoaders: Record<string, LocaleLoader> = {
263
+ "en": async () => ({ default: defaultMessages }),
264
+ ${loaders.join("\n")}
265
+ };
266
+ const shared: LocaleState | undefined = import.meta.hot?.data.state;
267
+ const state: LocaleState = shared ?? {
268
+ messages: { en: defaultMessages },
269
+ targets: new Set(),
270
+ version: 0,
271
+ };
272
+ export const localeMessages = state.messages;
273
+
274
+ async function readLocaleMessages(locale: string): Promise<LocaleMessages> {
275
+ return (await localeLoaders[locale]()).default;
276
+ }
277
+
278
+ export async function loadLocaleMessages(locale: string): Promise<LocaleMessages> {
279
+ const normalized = locale.slice(0, 2);
280
+ if (!localeLoaders[normalized]) return localeMessages.en;
281
+ localeMessages[normalized] ??= await readLocaleMessages(normalized);
282
+ return localeMessages[normalized];
283
+ }
284
+
285
+ /** Development only: applies every regenerated catalog to \`target\`. */
286
+ export function syncLocaleMessages(target: LocaleMessagesTarget): void {
287
+ if (import.meta.hot) state.targets.add(target);
288
+ }
289
+
290
+ async function refreshLocaleMessages(): Promise<void> {
291
+ const version = ++state.version;
292
+ const locales = Object.keys(localeMessages).filter(
293
+ (locale) => locale in localeLoaders,
294
+ );
295
+ const catalogs = await Promise.all(locales.map(readLocaleMessages));
296
+ if (version !== state.version) return;
297
+ locales.forEach((locale, index) => {
298
+ localeMessages[locale] = catalogs[index];
299
+ for (const target of state.targets)
300
+ target.setLocaleMessage(locale, catalogs[index]);
301
+ });
302
+ }
303
+
304
+ if (import.meta.hot) {
305
+ import.meta.hot.data.state = state;
306
+ if (shared) void refreshLocaleMessages();
307
+ import.meta.hot.accept((next) => {
308
+ if (next?.supportedLocales.join() !== supportedLocales.join())
309
+ import.meta.hot?.invalidate("the list of locales changed");
310
+ });
311
+ }
312
+ `;
313
+ }
225
314
  function writeLocaleMessages(workspaceDir, registry) {
226
- const locales = discoverAssets(registry, "i18n/locales", [".json"]);
315
+ const locales = discoverAssets(registry, LOCALE_DIRECTORY, [".json"]);
227
316
  const messages = readLocaleMessages(locales, registry);
228
317
  const outputDir = (0, node_path_1.join)(workspaceDir, "locales.generated");
229
- (0, node_fs_1.rmSync)(outputDir, { recursive: true, force: true });
318
+ const outputs = new Set();
230
319
  (0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
231
320
  for (const [locale, value] of Object.entries(messages)) {
232
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, `${locale}.json`), JSON.stringify(value));
233
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, `${locale}.ts`), `const messages = ${JSON.stringify(value)};\nexport default messages;\n`);
321
+ const catalog = JSON.stringify(value);
322
+ (0, fs_sync_1.applyContent)(catalog, (0, node_path_1.join)(outputDir, `${locale}.json`));
323
+ (0, fs_sync_1.applyContent)(`const messages = ${catalog};\nexport default messages;\n`, (0, node_path_1.join)(outputDir, `${locale}.ts`));
324
+ outputs.add(`${locale}.json`).add(`${locale}.ts`);
234
325
  }
235
326
  const supportedLocales = Object.keys(messages);
236
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, "email-locales.generated.json"), JSON.stringify(supportedLocales));
237
- const loaders = supportedLocales
238
- .filter((locale) => locale !== "en")
239
- .map((locale) => `${JSON.stringify(locale)}: () => import(${JSON.stringify(`./locales.generated/${locale}`)})`);
240
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, "locales.generated.ts"), `import defaultMessages from "./locales.generated/en";\ninterface LocaleModule { default: Record<string, unknown>; }\ntype LocaleLoader = () => Promise<LocaleModule>;\nexport const supportedLocales = ${JSON.stringify(supportedLocales)};\nexport const localeMessages: Record<string, Record<string, unknown>> = { en: defaultMessages };\nconst localeLoaders: Record<string, LocaleLoader> = { ${loaders.join(", ")} };\nexport async function loadLocaleMessages(locale: string): Promise<Record<string, unknown>> {\n const normalized = locale.slice(0, 2);\n const existing = localeMessages[normalized];\n if (existing) return existing;\n const loader = localeLoaders[normalized];\n if (!loader) return defaultMessages;\n const messages = (await loader()).default;\n localeMessages[normalized] = messages;\n return messages;\n}\n`);
327
+ (0, fs_sync_1.applyContent)(JSON.stringify(supportedLocales), (0, node_path_1.join)(workspaceDir, "email-locales.generated.json"));
328
+ (0, fs_sync_1.applyContent)(localeModuleSource(supportedLocales), (0, node_path_1.join)(workspaceDir, "locales.generated.ts"));
329
+ for (const name of (0, node_fs_1.readdirSync)(outputDir)) {
330
+ if (!outputs.has(name))
331
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(outputDir, name), { recursive: true, force: true });
332
+ }
241
333
  }
242
334
  function writeShortcutExports(workspaceDir, registry) {
243
335
  const registries = discoverAssets(registry, "app/config", [
package/dist/ports.js CHANGED
@@ -7,7 +7,7 @@ exports.PORT_FALLBACK_RANGE = 20;
7
7
  const MAX_TCP_PORT = 65535;
8
8
  function tryListen(port) {
9
9
  return new Promise((resolve) => {
10
- const server = (0, node_net_1.createServer)();
10
+ const server = (0, node_net_1.createServer)((socket) => socket.destroy());
11
11
  server.unref();
12
12
  server.once("error", () => resolve(undefined));
13
13
  server.listen({ port }, () => resolve(server));
@@ -1,4 +1,5 @@
1
1
  import { type SpawnOptions } from "node:child_process";
2
+ import { type ManifestSecrets } from "./manifest-secrets";
2
3
  import { ResolvedLayer } from "./workspace";
3
4
  export declare function computeDepsHash(layerPaths: string[], workspacePackage?: string, installInputs?: readonly string[]): string;
4
5
  export declare function needsInstall(workspaceDir: string, layerPaths: string[], force: boolean): boolean;
@@ -19,5 +20,6 @@ export interface SetupWorkspaceResult {
19
20
  layers: ResolvedLayer[];
20
21
  manifestFromCache: boolean;
21
22
  manifestFetchedAt?: string;
23
+ manifestSecrets: ManifestSecrets;
22
24
  }
23
25
  export declare function setupWorkspace(opts: SetupWorkspaceOptions): Promise<SetupWorkspaceResult>;
@@ -13,6 +13,7 @@ const node_os_1 = require("node:os");
13
13
  const node_path_1 = require("node:path");
14
14
  const layers_1 = require("./layers");
15
15
  const manifest_1 = require("./manifest");
16
+ const manifest_secrets_1 = require("./manifest-secrets");
16
17
  const materialize_1 = require("./materialize");
17
18
  const workspace_1 = require("./workspace");
18
19
  const config_1 = require("./config");
@@ -183,5 +184,6 @@ async function setupWorkspace(opts) {
183
184
  layers,
184
185
  manifestFromCache: fromCache,
185
186
  manifestFetchedAt: fetchedAt,
187
+ manifestSecrets: (0, manifest_secrets_1.collectManifestSecrets)(manifest.modules),
186
188
  };
187
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -1,5 +1,6 @@
1
1
  import UApp from "@nuxt/ui/components/App.vue";
2
2
  import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
3
+ import { useToast } from "@nuxt/ui/composables/useToast";
3
4
  import { useAppConfig } from "@nuxt/ui/runtime/vue/composables/useAppConfig";
4
5
  import ui from "@nuxt/ui/vue-plugin";
5
6
  import { useHead } from "@unhead/vue";
@@ -43,7 +44,9 @@ import {
43
44
  loadLocaleMessages,
44
45
  localeMessages,
45
46
  supportedLocales,
47
+ syncLocaleMessages,
46
48
  } from "./locales.generated";
49
+ import { showNetworkErrors } from "./network-error";
47
50
 
48
51
  export interface DmsInertiaSetupProps {
49
52
  initialPage: unknown;
@@ -214,11 +217,26 @@ function renderDmsPersistentLayout(
214
217
  );
215
218
  }
216
219
 
220
+ /**
221
+ * Nuxt UI keeps one app config for the whole process, and defu concatenates
222
+ * arrays: merged into it on every server render, the compound variants the DMS
223
+ * declares piled up, and every class list built from them grew with each
224
+ * request. Each config is merged once; a new one, after a reload, is merged
225
+ * again.
226
+ */
227
+ const mergedUiAppConfigs = new WeakSet<object>();
228
+
229
+ function mergeDmsAppConfig(): void {
230
+ const uiAppConfig = useAppConfig();
231
+ if (mergedUiAppConfigs.has(uiAppConfig)) return;
232
+ Object.assign(uiAppConfig, defu(useDmsAppConfig(), uiAppConfig));
233
+ mergedUiAppConfigs.add(uiAppConfig);
234
+ }
235
+
217
236
  export async function configureDmsApp(
218
237
  options: DmsAppOptions,
219
238
  ): Promise<DmsConfiguredApp> {
220
- const uiAppConfig = useAppConfig();
221
- Object.assign(uiAppConfig, defu(useDmsAppConfig(), uiAppConfig));
239
+ mergeDmsAppConfig();
222
240
  const runtime =
223
241
  options.runtime ??
224
242
  createDmsFrontendRuntime(
@@ -233,12 +251,19 @@ export async function configureDmsApp(
233
251
  fallbackLocale: DEFAULT_LOCALE,
234
252
  messages: { ...localeMessages, [locale]: messages },
235
253
  });
254
+ // A translation changed in a layer during development reaches the page
255
+ // without a reload. A server render imports the new catalogs instead.
256
+ if (typeof window !== "undefined") syncLocaleMessages(i18n.global);
236
257
  provideDmsFrontendRuntime(options.app, runtime);
237
258
  options.app.component("Icon", UIcon);
238
259
  options.app.use(options.inertiaPlugin).use(options.head).use(ui).use(i18n);
239
260
  options.app.runWithContext(() =>
240
261
  hydrateDmsPageProps(options.initialPageProps, options.initialPageUrl),
241
262
  );
263
+ if (typeof window !== "undefined") {
264
+ const toast = options.app.runWithContext(() => useToast());
265
+ showNetworkErrors(toast.add, i18n.global);
266
+ }
242
267
  // Resolve, before the app mounts, every async component the server
243
268
  // rendered: the page, its layout, and the components the render reached.
244
269
  // Hydration then adopts the server-rendered markup instead of discarding it.