@antelopejs/dms-frontend 0.7.1 → 0.9.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.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/dist/commands/build-action.d.ts +8 -5
  3. package/dist/commands/build-action.js +16 -41
  4. package/dist/commands/build.js +9 -15
  5. package/dist/commands/dev-action.d.ts +2 -0
  6. package/dist/commands/dev-action.js +14 -3
  7. package/dist/commands/dev.js +3 -1
  8. package/dist/commands/start-action.d.ts +2 -0
  9. package/dist/commands/start-action.js +65 -47
  10. package/dist/commands/start.js +8 -6
  11. package/dist/common.d.ts +1 -0
  12. package/dist/common.js +1 -0
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +9 -0
  15. package/dist/derived-outputs.d.ts +1 -5
  16. package/dist/derived-outputs.js +48 -35
  17. package/dist/dev-modules.d.ts +5 -0
  18. package/dist/dev-modules.js +88 -0
  19. package/dist/entry-weight.d.ts +24 -0
  20. package/dist/entry-weight.js +86 -0
  21. package/dist/fs-sync.d.ts +1 -0
  22. package/dist/fs-sync.js +6 -0
  23. package/dist/help.js +12 -4
  24. package/dist/index.js +4 -6
  25. package/dist/layer-watch.js +7 -2
  26. package/dist/layers.d.ts +2 -3
  27. package/dist/layers.js +1 -95
  28. package/dist/locale-files.d.ts +6 -0
  29. package/dist/locale-files.js +75 -0
  30. package/dist/manifest.d.ts +8 -8
  31. package/dist/manifest.js +17 -39
  32. package/dist/materialize.js +6 -0
  33. package/dist/module-set.d.ts +14 -12
  34. package/dist/module-set.js +39 -31
  35. package/dist/private-values.d.ts +3 -2
  36. package/dist/private-values.js +16 -5
  37. package/dist/project-modules-child.d.ts +13 -0
  38. package/dist/project-modules-child.js +26 -0
  39. package/dist/project-modules.d.ts +47 -0
  40. package/dist/project-modules.js +268 -0
  41. package/dist/workspace-setup.d.ts +3 -7
  42. package/dist/workspace-setup.js +79 -62
  43. package/dist/workspace.d.ts +8 -4
  44. package/dist/workspace.js +1 -1
  45. package/package.json +13 -8
  46. package/skills/dms-frontend/SKILL.md +2 -2
  47. package/templates/vue/app-runtime.ts +8 -1
  48. package/templates/vue/client-chunking.mjs +61 -0
  49. package/templates/vue/email-renderer.ts +6 -1
  50. package/templates/vue/entry-weight.mjs +132 -0
  51. package/templates/vue/frontend-build-loader.ts +1 -1
  52. package/templates/vue/frontend-module.ts +168 -1
  53. package/templates/vue/gitignore +1 -0
  54. package/templates/vue/main.ts +32 -2
  55. package/templates/vue/network-error.ts +3 -1
  56. package/templates/vue/package.json +3 -3
  57. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  58. package/templates/vue/pnpm-workspace.yaml +5 -0
  59. package/templates/vue/prepare-types.mjs +2 -7
  60. package/templates/vue/public-options.ts +19 -0
  61. package/templates/vue/server/auth/backend.mjs +20 -3
  62. package/templates/vue/server/auth/client-ip.mjs +64 -22
  63. package/templates/vue/server/auth/session.mjs +4 -11
  64. package/templates/vue/server/backend-proxies.mjs +13 -2
  65. package/templates/vue/server/backend-response.mjs +2 -13
  66. package/templates/vue/server/cli-report.mjs +42 -18
  67. package/templates/vue/server/client-manifest.mjs +34 -58
  68. package/templates/vue/server/dev-private-files.mjs +1 -1
  69. package/templates/vue/server/dev-styles.mjs +16 -5
  70. package/templates/vue/server/homepage.mjs +4 -1
  71. package/templates/vue/server/in-place-render.mjs +110 -0
  72. package/templates/vue/server/module-event.mjs +23 -8
  73. package/templates/vue/server/module-server.mjs +4 -4
  74. package/templates/vue/server/module-testing.mjs +5 -3
  75. package/templates/vue/server/page-props.mjs +10 -0
  76. package/templates/vue/server/proxy-compression.mjs +15 -8
  77. package/templates/vue/server/proxy-headers.mjs +12 -1
  78. package/templates/vue/server/proxy-redirects.mjs +66 -0
  79. package/templates/vue/server/proxy-transfers.mjs +110 -0
  80. package/templates/vue/server/public-endpoints.mjs +2 -2
  81. package/templates/vue/server/public-options.mjs +49 -0
  82. package/templates/vue/server/request-routing.mjs +20 -0
  83. package/templates/vue/server/web-body.mjs +24 -0
  84. package/templates/vue/server-module.ts +19 -7
  85. package/templates/vue/server.mjs +98 -78
  86. package/templates/vue/sfc-global-types.mjs +119 -0
  87. package/templates/vue/ssr-renderer.ts +27 -1
  88. package/templates/vue/ui-kbd.mjs +45 -0
  89. package/templates/vue/vite.config.ts +104 -47
  90. package/templates/vue/vite.email.config.ts +6 -3
  91. package/templates/vue/vite.server.config.ts +5 -2
  92. package/dist/commands/snapshot-action.d.ts +0 -6
  93. package/dist/commands/snapshot-action.js +0 -53
  94. package/dist/commands/snapshot.d.ts +0 -2
  95. package/dist/commands/snapshot.js +0 -55
  96. package/dist/snapshot.d.ts +0 -25
  97. package/dist/snapshot.js +0 -90
  98. package/templates/vue/server/backend-wake.mjs +0 -129
@@ -29,8 +29,8 @@ export interface DmsSession {
29
29
  refreshToken?: string;
30
30
  accountId?: string;
31
31
  activeTenantId?: string;
32
- /** False when the session ends with the browser. */
33
- persistent?: boolean;
32
+ /** True when the session outlives the browser (30 days), false when it ends with it. */
33
+ persistent: boolean;
34
34
  /** Stamped by the frontend server on every write. */
35
35
  updatedAt?: number;
36
36
  [key: string]: unknown;
@@ -84,8 +84,8 @@ export interface DmsServerEvent {
84
84
  */
85
85
  view(): DmsPublicSession;
86
86
  /**
87
- * Writes the session cookie, stamped with `updatedAt`; it ends with the
88
- * browser unless `persistent` is true or unset.
87
+ * Writes the session cookie, stamped with `updatedAt`. It lasts 30 days
88
+ * when `persistent` is true and ends with the browser when it is false.
89
89
  */
90
90
  write(session: DmsSession): void;
91
91
  clear(): void;
@@ -112,16 +112,21 @@ export interface DmsServerEvent {
112
112
  /**
113
113
  * Calls the backend, JSON in and out. A refusal throws an error carrying
114
114
  * the status and the backend's public `error.*` message, which the server
115
- * answers as is when the handler lets it through.
115
+ * answers as is when the handler lets it through. A redirect is not
116
+ * followed: it throws a 502.
116
117
  */
117
118
  backend<T = unknown>(path: string, init?: DmsBackendRequest): Promise<T>;
118
119
  /**
119
120
  * The backend URL of an absolute path, for a call `backend` cannot make (a
120
121
  * raw response, no relayed header): always on the backend's origin,
121
- * whatever the path holds.
122
+ * whatever the path holds. Fetch it with `redirect: "manual"` to relay a
123
+ * backend redirect: the browser then follows it.
122
124
  */
123
125
  backendUrl(path: string): URL;
124
- /** The client's address, behind `DMS_TRUSTED_PROXY_HOPS` proxies. */
126
+ /**
127
+ * The client's address, from the `DMS_CLIENT_IP_HEADER` header or behind
128
+ * `DMS_TRUSTED_PROXY_HOPS` proxies.
129
+ */
125
130
  clientIp(): string;
126
131
  /** This module, and only this one. */
127
132
  module: {
@@ -273,6 +278,13 @@ export interface DmsBackendProxyOptions {
273
278
  * needed.
274
279
  */
275
280
  auth?: "session" | "none";
281
+ /**
282
+ * The largest request body, in bytes, the server streams to the backend.
283
+ * A larger declared `Content-Length` gets a `413` before any byte is read;
284
+ * a chunked body is cut off with a `413` as soon as it goes over. No limit
285
+ * by default: the backend applies its own.
286
+ */
287
+ maxBodyBytes?: number;
276
288
  }
277
289
 
278
290
  export type DmsServerSetup = (server: DmsServer) => void;
@@ -2,8 +2,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer, maxHeaderSize } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { ofetch } from "ofetch";
6
5
  import {
6
+ CLIENT_HEADERS,
7
7
  relayedHeaders,
8
8
  RequestBodyError,
9
9
  UpstreamError,
@@ -17,7 +17,6 @@ import {
17
17
  redirectAccessRefusal,
18
18
  UNEXPECTED_ERROR_BODY,
19
19
  } from "./server/backend-response.mjs";
20
- import { fetchWaking } from "./server/backend-wake.mjs";
21
20
  import { handleClientError } from "./server/client-error.mjs";
22
21
  import {
23
22
  reportListenError,
@@ -26,7 +25,10 @@ import {
26
25
  } from "./server/cli-report.mjs";
27
26
  import { publicBuildFile } from "./server/built-files.mjs";
28
27
  import { serveStaticFile } from "./server/static-files.mjs";
29
- import { productionHtmlTemplate } from "./server/client-manifest.mjs";
28
+ import {
29
+ localeCatalogPreloadTag,
30
+ productionHtmlTemplate,
31
+ } from "./server/client-manifest.mjs";
30
32
  import { writeContent } from "./server/content.mjs";
31
33
  import {
32
34
  captureViteErrors,
@@ -37,7 +39,11 @@ import { documentStyleTags } from "./server/dev-styles.mjs";
37
39
  import { watchEmailBundle } from "./server/email.mjs";
38
40
  import { withFaviconPlaceholder } from "./server/favicon.mjs";
39
41
  import { DEV_FS_DENY, denyPrivateFiles } from "./server/dev-private-files.mjs";
40
- import { ownerLabel, requestOwner } from "./server/request-routing.mjs";
42
+ import {
43
+ ownerLabel,
44
+ reachesBackend,
45
+ requestOwner,
46
+ } from "./server/request-routing.mjs";
41
47
  import { routingReport } from "./server/backend-proxies.mjs";
42
48
  import { routeRuleFor, ruleHeaders } from "./server/route-rules.mjs";
43
49
  import {
@@ -45,7 +51,9 @@ import {
45
51
  loadServerModules,
46
52
  } from "./server/module-server.mjs";
47
53
  import { HOMEPAGE } from "./server/homepage.mjs";
48
- import { loadPageProps } from "./server/page-props.mjs";
54
+ import { publicOptionsScript } from "./server/public-options.mjs";
55
+ import { renderInPlace, serverReads } from "./server/in-place-render.mjs";
56
+ import { loadPageProps, propsForQuery } from "./server/page-props.mjs";
49
57
  import { htmlTag } from "./server/html-tag.mjs";
50
58
  import { listenHost } from "./server/listen-host.mjs";
51
59
  import * as icons from "./server/icons.mjs";
@@ -55,6 +63,7 @@ import {
55
63
  relayAnswer,
56
64
  } from "./server/proxy-compression.mjs";
57
65
  import { backendUrl, refuseForeignTarget } from "./server/request-target.mjs";
66
+ import { sendWithinLimit, SERVER_OPTIONS } from "./server/proxy-transfers.mjs";
58
67
  import {
59
68
  createInertiaPage,
60
69
  htmlHeaders,
@@ -73,7 +82,12 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
73
82
  const LOCAL_ROUTES = [[icons.isIconRequest, icons.handleIcons]];
74
83
  const CLIENT_ROOT = join(PROJECT_ROOT, "dist/client");
75
84
  const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
76
- const BUILT_SSR_RENDERER_PATH = join(PROJECT_ROOT, "dist/ssr/ssr-renderer.js");
85
+ // A URL, not a path: on Windows, `import()` reads `C:\...` as a URL whose
86
+ // scheme is `c:` and refuses it.
87
+ const BUILT_SSR_RENDERER_URL = new URL(
88
+ "dist/ssr/ssr-renderer.js",
89
+ import.meta.url,
90
+ );
77
91
  // The SSR bundle keeps vue-i18n external, so Node loads its esm-bundler build,
78
92
  // which reads compile-time flags only a bundler replaces. Vite inlines them in
79
93
  // the client bundle; without the same value here, installing vue-i18n under
@@ -94,8 +108,7 @@ async function developmentServer() {
94
108
  // The generated secrets and the modules' server code, never served.
95
109
  fs: { deny: DEV_FS_DENY },
96
110
  };
97
- if (frontendHttpServer)
98
- serverOptions.hmr = { server: frontendHttpServer };
111
+ if (frontendHttpServer) serverOptions.ws = { server: frontendHttpServer };
99
112
  return createViteServer({
100
113
  server: serverOptions,
101
114
  appType: "custom",
@@ -127,9 +140,7 @@ function backendHeaders(request, access = { anonymous: false, headers: [] }) {
127
140
  "if-match",
128
141
  "origin",
129
142
  "referer",
130
- "user-agent",
131
- "x-content-language",
132
- "x-realtime-session",
143
+ ...CLIENT_HEADERS,
133
144
  ...access.headers,
134
145
  ]),
135
146
  ...forwardedHeaders(request),
@@ -141,24 +152,13 @@ function backendHeaders(request, access = { anonymous: false, headers: [] }) {
141
152
  }
142
153
 
143
154
  function serverComponentFetch(request) {
144
- return (path, options = {}) => {
145
- const headers = new Headers(backendHeaders(request));
146
- new Headers(options.headers).forEach((value, name) => {
147
- headers.set(name, value);
148
- });
149
- return ofetch(path, {
150
- ...options,
151
- baseURL: process.env.DMS_API_BASE_URL,
152
- headers,
153
- });
154
- };
155
+ return serverReads(() => backendHeaders(request));
155
156
  }
156
157
 
157
158
  async function backendJson(path, request) {
158
- const response = await fetchWaking(
159
- new URL(path, process.env.DMS_API_BASE_URL),
160
- { headers: backendHeaders(request), signal: request.dmsAbandoned },
161
- );
159
+ const response = await fetch(new URL(path, process.env.DMS_API_BASE_URL), {
160
+ headers: backendHeaders(request),
161
+ });
162
162
  if (!response.ok) {
163
163
  throw await backendResponseError(response);
164
164
  }
@@ -170,18 +170,18 @@ async function currentAuth() {
170
170
  return (await loadServerModules(await developmentServer())).serverAuth;
171
171
  }
172
172
 
173
+ function pageBackend(request) {
174
+ return {
175
+ fetchJson: (path) => backendJson(path, request),
176
+ isFrontendPage: async (pathname) =>
177
+ (await ssrRenderer(await developmentServer())).isDmsFrontendPage(
178
+ pathname,
179
+ ),
180
+ };
181
+ }
182
+
173
183
  function pageProps(request, auth) {
174
- return loadPageProps(
175
- request,
176
- {
177
- fetchJson: (path) => backendJson(path, request),
178
- isFrontendPage: async (pathname) =>
179
- (await ssrRenderer(await developmentServer())).isDmsFrontendPage(
180
- pathname,
181
- ),
182
- },
183
- auth,
184
- );
184
+ return loadPageProps(request, pageBackend(request), auth);
185
185
  }
186
186
 
187
187
  function redirectToHomepage(request, response, pathname) {
@@ -202,25 +202,45 @@ function redirectSignedOutPrivatePage(request, response, props, auth) {
202
202
  return true;
203
203
  }
204
204
 
205
- /** Sends a request to the backend through the proxy it reached. */
206
- async function proxy(request, response, backendProxy) {
205
+ /**
206
+ * Sends a request to the backend through the proxy it reached.
207
+ * `isProxiedPath` says which backend paths a relayed `Location` may name on
208
+ * the frontend's origin.
209
+ */
210
+ async function proxy(request, response, backendProxy, isProxiedPath) {
207
211
  const access = proxyAccess(request, SAFE_METHODS, backendProxy);
208
212
  if (access.isRefused) {
209
213
  refuseCrossOrigin(response);
210
214
  return true;
211
215
  }
216
+ // A client that left while the request was routed closed the response
217
+ // already, and would never abort the request below.
218
+ if (response.closed) return;
212
219
  // Cancelling the body alone misses a client that leaves before the backend
213
220
  // answers: its stream, an SSE feed above all, then stayed open for good.
214
221
  const abandoned = new AbortController();
215
222
  response.once("close", () => abandoned.abort());
216
- const upstream = await fetchWaking(backendUrl(request.url), {
223
+ const init = {
217
224
  signal: abandoned.signal,
218
225
  method: request.method,
219
226
  headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
220
- body: ["GET", "HEAD"].includes(request.method) ? undefined : request,
221
227
  duplex: "half",
228
+ // A backend redirect is the browser's to follow: Media sends a file's
229
+ // link to the storage's signed URL, on the public origin the server
230
+ // itself may not reach, which turned every download into a 502.
231
+ redirect: "manual",
232
+ };
233
+ const upstream = await sendWithinLimit(
234
+ request,
235
+ response,
236
+ backendProxy.maxBodyBytes,
237
+ (body) => fetch(backendUrl(request.url), { ...init, body }),
238
+ );
239
+ if (!upstream) return true;
240
+ await relayAnswer(request, response, upstream, {
241
+ fetched: true,
242
+ isProxiedPath,
222
243
  });
223
- await relayAnswer(request, response, upstream, { fetched: true });
224
244
  }
225
245
 
226
246
  /**
@@ -240,7 +260,7 @@ function serveAsset(pathname, request, response) {
240
260
  async function ssrRenderer(devServer) {
241
261
  const renderer = devServer
242
262
  ? await devServer.ssrLoadModule("/ssr-renderer.ts")
243
- : await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH));
263
+ : await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_URL.href));
244
264
  await icons.provideIconCollections(renderer);
245
265
  return renderer;
246
266
  }
@@ -250,6 +270,7 @@ function clientRenderedShell(page) {
250
270
  body: inertiaAppHtml(page),
251
271
  head: { headTags: "", htmlAttrs: "", bodyAttrs: "" },
252
272
  overlays: "",
273
+ modules: [],
253
274
  };
254
275
  }
255
276
 
@@ -290,7 +311,10 @@ export async function renderHtml(
290
311
  : await serverRendered(page, serverFetch, requestCookies, devServer);
291
312
  if (rendered.redirect)
292
313
  return { html: "", status: 200, redirect: rendered.redirect };
293
- const preloads = await documentStyleTags(devServer, page, template);
314
+ const headEnd =
315
+ (await documentStyleTags(devServer, rendered.modules, template)) +
316
+ (devServer ? "" : localeCatalogPreloadTag(rendered.catalogLocale)) +
317
+ publicOptionsScript();
294
318
  const html = template
295
319
  .replace("<title>Antelope DMS</title>", () =>
296
320
  withFaviconPlaceholder(rendered.head.headTags),
@@ -299,7 +323,7 @@ export async function renderHtml(
299
323
  htmlTag(attributes, rendered.head.htmlAttrs),
300
324
  )
301
325
  .replace("<body", `<body ${rendered.head.bodyAttrs}`)
302
- .replace("</head>", `${preloads}</head>`)
326
+ .replace("</head>", `${headEnd}</head>`)
303
327
  .replace(
304
328
  '<div id="dms-overlays" class="isolate"></div>',
305
329
  `<div id="dms-overlays" class="isolate">${rendered.overlays}</div>`,
@@ -308,13 +332,28 @@ export async function renderHtml(
308
332
  return { html, status: rendered.error?.statusCode ?? 200 };
309
333
  }
310
334
 
311
- function renderRequestHtml(page, request, rule = {}) {
312
- const fetch = serverComponentFetch(request);
335
+ function renderRequestHtml(page, request, rule = {}, reads) {
336
+ const fetch = reads ?? serverComponentFetch(request);
313
337
  return renderHtml(page, request.url, fetch, request.headers.cookie, {
314
338
  ssr: rule.ssr,
315
339
  });
316
340
  }
317
341
 
342
+ /**
343
+ * Renders a page document, at the query the page completes as it renders:
344
+ * see `renderInPlace`.
345
+ */
346
+ function renderDocument(page, request, rule, auth) {
347
+ const fetch = serverComponentFetch(request);
348
+ return renderInPlace(
349
+ request,
350
+ page,
351
+ fetch,
352
+ (served) => renderRequestHtml(served, request, rule, fetch),
353
+ (props, url) => propsForQuery(props, url, pageBackend(request), auth),
354
+ );
355
+ }
356
+
318
357
  async function writeBackendError(error, request, response) {
319
358
  const auth = await currentAuth();
320
359
  const status = Number.isInteger(error?.status) ? error.status : 502;
@@ -332,9 +371,6 @@ async function writeBackendError(error, request, response) {
332
371
  if (await redirectAccessRefusal(error, request, response, loadRenderer))
333
372
  return;
334
373
  console.error("DMS backend request failed", error);
335
- const retryHeaders = error?.retryAfter
336
- ? { "retry-after": error.retryAfter }
337
- : {};
338
374
  if (isFrontendVisit(request)) {
339
375
  const props = {
340
376
  path: pathname,
@@ -348,24 +384,18 @@ async function writeBackendError(error, request, response) {
348
384
  request,
349
385
  response,
350
386
  status,
351
- { ...inertiaHeaders(page.version), ...retryHeaders },
387
+ inertiaHeaders(page.version),
352
388
  JSON.stringify(page),
353
389
  );
354
390
  }
355
391
  const { html } = await renderRequestHtml(page, request);
356
- return writeContent(
357
- request,
358
- response,
359
- status,
360
- { ...htmlHeaders(), ...retryHeaders },
361
- html,
362
- );
392
+ return writeContent(request, response, status, htmlHeaders(), html);
363
393
  }
364
394
  const payload =
365
395
  error instanceof UpstreamError || error instanceof RequestBodyError
366
396
  ? { message: error.message }
367
397
  : UNEXPECTED_ERROR_BODY;
368
- response.writeHead(status, { "content-type": JSON_TYPE, ...retryHeaders });
398
+ response.writeHead(status, { "content-type": JSON_TYPE });
369
399
  response.end(JSON.stringify(payload));
370
400
  }
371
401
 
@@ -425,15 +455,16 @@ export async function handleRequest(request, response) {
425
455
  if (localRoute) return localRoute[1](request, response);
426
456
  const devServer = await developmentServer();
427
457
  const modules = await loadServerModules(devServer);
428
- const owner = requestOwner(request.method, pathname, modules, (path) =>
429
- hasRendererFile(devServer, path),
430
- );
458
+ const hasFile = (path) => hasRendererFile(devServer, path);
459
+ const owner = requestOwner(request.method, pathname, modules, hasFile);
431
460
  if (devServer) response.setHeader("x-dms-route-owner", ownerLabel(owner));
432
461
  if (owner.kind === "renderer")
433
462
  return serveRendererFile(devServer, request, response, pathname);
434
463
  if (owner.kind === "module")
435
464
  return handleModuleRoute(request, response, pathname, modules);
436
- if (owner.kind === "backend") return proxy(request, response, owner.proxy);
465
+ const isProxiedPath = (path) => reachesBackend(path, modules, hasFile);
466
+ if (owner.kind === "backend")
467
+ return proxy(request, response, owner.proxy, isProxiedPath);
437
468
  if (owner.kind === "not-found") return writeNotFound(response);
438
469
  if (requestFailure.hasMalformedPath(request.url))
439
470
  return requestFailure.writeBadRequest(response);
@@ -455,16 +486,17 @@ export async function handleRequest(request, response) {
455
486
  JSON.stringify(page),
456
487
  );
457
488
  }
458
- const rendered = await renderRequestHtml(page, request, rule);
489
+ const { rendered, served } = await renderDocument(page, request, rule, auth);
459
490
  if (rendered.redirect) {
460
491
  redirectFrontendVisit(request, response, rendered.redirect);
461
492
  return;
462
493
  }
494
+ const location = served === page ? {} : { "content-location": served.url };
463
495
  return writeContent(
464
496
  request,
465
497
  response,
466
498
  rendered.status,
467
- { ...htmlHeaders(), ...headersFor(rendered.status) },
499
+ { ...htmlHeaders(), ...headersFor(rendered.status), ...location },
468
500
  rendered.html,
469
501
  );
470
502
  }
@@ -487,19 +519,7 @@ async function writeRequestFailure(error, request, response) {
487
519
  await writeBackendError(error, request, response);
488
520
  }
489
521
 
490
- /**
491
- * Aborted once the client is gone, ending a wait for a waking backend. An
492
- * embedder's response that emits no events never aborts it.
493
- */
494
- function abandonedSignal(response) {
495
- const abandoned = new AbortController();
496
- if (typeof response.once === "function")
497
- response.once("close", () => abandoned.abort());
498
- return abandoned.signal;
499
- }
500
-
501
522
  export async function handleRequestSafely(request, response) {
502
- request.dmsAbandoned = abandonedSignal(response);
503
523
  try {
504
524
  if (refuseForeignTarget(request, response)) return;
505
525
  await handleRequest(request, response);
@@ -515,7 +535,7 @@ if (
515
535
  process.argv[1] &&
516
536
  import.meta.url === new URL(`file://${process.argv[1]}`).href
517
537
  ) {
518
- frontendHttpServer = createServer(handleRequestSafely);
538
+ frontendHttpServer = createServer(SERVER_OPTIONS, handleRequestSafely);
519
539
  frontendHttpServer.on("clientError", (error, socket) =>
520
540
  handleClientError(error, socket, maxHeaderSize),
521
541
  );
@@ -0,0 +1,119 @@
1
+ // Vue's SFC compiler turns `defineProps<Props>()` into runtime prop types, and
2
+ // it reads a type from the component's own imports only. A type a module
3
+ // auto-imports (its declared `app/types`, `app/composables`…, and Vue's own
4
+ // `Ref`, `Component`…) is visible to TypeScript through auto-imports.d.ts but
5
+ // not to the compiler, which drops it from the prop without a word:
6
+ // `boolean | RowActionConfig` became `{ type: Boolean }` in a production build.
7
+ //
8
+ // The compiler does read ambient declarations from `script.globalTypeFiles`,
9
+ // but not the `export type { … } from` re-exports auto-imports.d.ts is made
10
+ // of. This writes those types as ambient aliases it can follow, from the same
11
+ // directories and through the same scanner as the auto-imports, before Vite
12
+ // compiles anything, and rewrites them when a dev server sees a module's
13
+ // declared directories change.
14
+
15
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { createRequire } from "node:module";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ // The very unimport the auto-import plugin scans with, so both read the
20
+ // directories the same way. Imported by URL, not by path: on Windows,
21
+ // `import()` reads `C:\...` as a URL whose scheme is `c:` and refuses it.
22
+ const unimportUrl = pathToFileURL(
23
+ createRequire(
24
+ createRequire(import.meta.url).resolve("unplugin-auto-import/package.json"),
25
+ ).resolve("unimport"),
26
+ );
27
+ const { builtinPresets, resolvePreset, scanDirExports } = await import(
28
+ unimportUrl.href
29
+ );
30
+
31
+ const HEADER =
32
+ "// Generated by @antelopejs/dms-frontend from the auto-imported types, for\n" +
33
+ "// Vue's SFC compiler (script.globalTypeFiles). Not read by TypeScript.\n";
34
+
35
+ const withoutExtension = (path) => path.replace(/\.[cm]?[jt]sx?$/, "");
36
+
37
+ /**
38
+ * The ambient aliases of the auto-imported types: one per name, in the order
39
+ * the auto-import plugin reads them (Vue's preset, then the scanned
40
+ * directories), the last one winning, as unimport's own deduplication keeps
41
+ * the last of two imports of the same name. A module that exports its own
42
+ * `Component` thus shadows Vue's, for the compiler as for TypeScript.
43
+ *
44
+ * @param {(string | { glob: string, types?: boolean })[]} dirs the auto-import
45
+ * directories, as handed to the auto-import plugin
46
+ * @param {string} cwd the workspace
47
+ */
48
+ export async function sfcGlobalTypes(dirs, cwd) {
49
+ const vue = await resolvePreset(builtinPresets.vue);
50
+ const scanned = await scanDirExports(dirs, { cwd, types: true });
51
+ const aliases = new Map();
52
+ for (const entry of [...vue, ...scanned]) {
53
+ if (!entry.type) continue;
54
+ const name = entry.as ?? entry.name;
55
+ const source = JSON.stringify(withoutExtension(entry.from));
56
+ aliases.set(
57
+ name,
58
+ `declare type ${name} = import(${source}).${entry.name};`,
59
+ );
60
+ }
61
+ return `${HEADER}${[...aliases.values()].join("\n")}\n`;
62
+ }
63
+
64
+ function writeIfChanged(file, content) {
65
+ let current;
66
+ try {
67
+ current = readFileSync(file, "utf8");
68
+ } catch {
69
+ current = undefined;
70
+ }
71
+ if (current === content) return false;
72
+ // Through a temporary file: a build starting meanwhile reads the previous
73
+ // declarations or the new ones, never a half-written file the compiler
74
+ // would then keep in its cache.
75
+ const temporary = `${file}.${process.pid}.tmp`;
76
+ writeFileSync(temporary, content);
77
+ renameSync(temporary, file);
78
+ return true;
79
+ }
80
+
81
+ /** Writes the declarations once, before Vite's config is returned. */
82
+ export async function writeSfcGlobalTypes(dirs, cwd, file) {
83
+ writeIfChanged(file, await sfcGlobalTypes(dirs, cwd));
84
+ }
85
+
86
+ /**
87
+ * Keeps the declarations in step on a dev server: a type file added, renamed
88
+ * or removed rewrites them, and the Vue plugin reloads the components whose
89
+ * props read the file.
90
+ */
91
+ export function sfcGlobalTypesWatcher(dirs, cwd, file) {
92
+ return {
93
+ name: "dms-sfc-global-types",
94
+ apply: "serve",
95
+ configureServer(server) {
96
+ let pending;
97
+ const refresh = (path) => {
98
+ if (!/\.[cm]?[jt]sx?$/.test(path) || path === file) return;
99
+ clearTimeout(pending);
100
+ pending = setTimeout(() => {
101
+ // The file is under the workspace, which Vite watches: rewriting it
102
+ // is the change the Vue plugin reloads the dependent components on.
103
+ // A failed scan or write keeps the previous declarations: it must
104
+ // not end the dev server.
105
+ sfcGlobalTypes(dirs, cwd)
106
+ .then((content) => writeIfChanged(file, content))
107
+ .catch((error) =>
108
+ server.config.logger.warn(
109
+ `Could not update ${file}, the types Vue's SFC compiler ` +
110
+ `reads: ${error instanceof Error ? error.message : error}`,
111
+ ),
112
+ );
113
+ }, 100);
114
+ };
115
+ for (const event of ["add", "change", "unlink"])
116
+ server.watcher.on(event, refresh);
117
+ },
118
+ };
119
+ }
@@ -28,7 +28,9 @@ import {
28
28
  setDmsServerRuntimeResolver,
29
29
  setupFrontendModules,
30
30
  trackDmsServerScopes,
31
+ withPublicOptions,
31
32
  } from "./frontend-module";
33
+ import { parsePublicOptions } from "./public-options";
32
34
  import { frontendModules } from "./frontend-modules.generated";
33
35
  import {
34
36
  SSR_ICONS_ID,
@@ -53,8 +55,19 @@ export interface DmsSsrResult {
53
55
  body: string;
54
56
  head: DmsSsrHead;
55
57
  overlays: string;
58
+ /**
59
+ * The id of every single-file component the render created, its path from
60
+ * the workspace root as the client manifest keys it: the server links their
61
+ * stylesheets.
62
+ */
63
+ modules: string[];
56
64
  error?: DmsErrorData;
57
65
  redirect?: string;
66
+ /**
67
+ * The locale of the catalog the page was rendered with: the server preloads
68
+ * that catalog's file, which the browser loads before it hydrates.
69
+ */
70
+ catalogLocale?: string;
58
71
  }
59
72
 
60
73
  export interface DmsSsrHead {
@@ -64,6 +77,8 @@ export interface DmsSsrHead {
64
77
  }
65
78
 
66
79
  interface DmsSsrContext {
80
+ /** Filled by `@vitejs/plugin-vue`: each component adds its id as it sets up. */
81
+ modules?: Set<string>;
67
82
  teleports?: Record<string, string>;
68
83
  }
69
84
 
@@ -122,7 +137,13 @@ function iconsScript(names: Set<string>): string {
122
137
  return `<script id="${SSR_ICONS_ID}" type="application/json">${value}</script>`;
123
138
  }
124
139
 
125
- await setupFrontendModules(frontendModules, appConfigs);
140
+ await setupFrontendModules(
141
+ withPublicOptions(
142
+ frontendModules,
143
+ parsePublicOptions(process.env.DMS_PUBLIC_OPTIONS),
144
+ ),
145
+ appConfigs,
146
+ );
126
147
 
127
148
  export function isDmsFrontendPage(path: string): boolean {
128
149
  return hasDmsPage(path);
@@ -173,6 +194,7 @@ async function renderDmsPageWithRuntime(
173
194
  let icons = new Set<string>();
174
195
  let iconSymbols = new Map<string, string>();
175
196
  let payload: DmsPayload = { data: {}, errors: {}, state: {} };
197
+ let catalogLocale: (() => string | undefined) | undefined;
176
198
  const inertiaResult = await createInertiaApp({
177
199
  page,
178
200
  // Read as the HTML is complete: data a run settles afterwards was never
@@ -198,6 +220,7 @@ async function renderDmsPageWithRuntime(
198
220
  runtime,
199
221
  serverFetch,
200
222
  });
223
+ catalogLocale = configured.catalogLocale;
201
224
  return app;
202
225
  },
203
226
  });
@@ -208,6 +231,7 @@ async function renderDmsPageWithRuntime(
208
231
  body: "",
209
232
  head: { bodyAttrs: "", headTags: "", htmlAttrs: "" },
210
233
  overlays: "",
234
+ modules: [],
211
235
  redirect: runtime.serverRedirect,
212
236
  };
213
237
  }
@@ -238,6 +262,8 @@ async function renderDmsPageWithRuntime(
238
262
  htmlAttrs: renderedHead.htmlAttrs,
239
263
  },
240
264
  overlays: ssrContext.teleports?.["#dms-overlays"] ?? "",
265
+ modules: [...(ssrContext.modules ?? [])],
266
+ catalogLocale: catalogLocale?.(),
241
267
  };
242
268
  } finally {
243
269
  // Only once the head and the async data are serialized: both still read
@@ -0,0 +1,45 @@
1
+ // Nuxt UI prints the `meta`, `ctrl` and `alt` of a key hint (`UKbd`, the
2
+ // `kbds` of its menus) from a map every hint shares, blank until the first
3
+ // hint mounts: a hint hydrating after that, in an async component, printed
4
+ // "⌘" or "Ctrl" over the server's blank, a hydration mismatch on every
5
+ // platform. Its `useKbd` is routed to the DMS runtime's `useDmsKbd`, which
6
+ // keeps each hint on the server's value until it mounts and lets the
7
+ // application give the platform to the server render
8
+ // (`provideDmsKbdPlatform`). The build fails if Nuxt UI changes the form
9
+ // rewritten here, rather than letting the mismatch come back unnoticed.
10
+
11
+ /** Nuxt UI's key hint composable. */
12
+ const USE_KBD = /\/@nuxt\/ui\/dist\/runtime\/composables\/useKbd\.js$/;
13
+ /** Its export as Nuxt UI 4 writes it: one composable shared by every hint. */
14
+ const SHARED_EXPORT =
15
+ /export const useKbd = \/\* @__PURE__ \*\/ createSharedComposable\(_useKbd\);/;
16
+ const ROUTED_EXPORT =
17
+ 'import { useDmsKbd } from "#dms/frontend-module";\n' +
18
+ "const useSharedKbd = /* @__PURE__ */ createSharedComposable(_useKbd);\n" +
19
+ "export const useKbd = () => useDmsKbd(useSharedKbd, kbdKeysMap);";
20
+
21
+ /**
22
+ * The rewrite, as a Vite plugin.
23
+ *
24
+ * @param isNuxtUiFile `(id) => boolean`, whether a module is Nuxt UI's
25
+ */
26
+ export function uiKbd(isNuxtUiFile) {
27
+ return {
28
+ name: "dms-ui-kbd",
29
+ enforce: "pre",
30
+ transform(code, id) {
31
+ const path = id.replaceAll("\\", "/").split("?")[0];
32
+ if (!isNuxtUiFile(id) || !USE_KBD.test(path)) return null;
33
+ if (
34
+ !SHARED_EXPORT.test(code) ||
35
+ !code.includes("export const kbdKeysMap")
36
+ )
37
+ this.error(
38
+ `${id} defines useKbd in a form the DMS does not rewrite: its key ` +
39
+ "hints would not hydrate cleanly. Update ui-kbd.mjs for this " +
40
+ "Nuxt UI release.",
41
+ );
42
+ return { code: code.replace(SHARED_EXPORT, ROUTED_EXPORT), map: null };
43
+ },
44
+ };
45
+ }