@antelopejs/dms-frontend 0.8.0 → 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 (47) hide show
  1. package/dist/commands/build-action.js +3 -0
  2. package/dist/commands/dev-action.d.ts +2 -0
  3. package/dist/commands/dev-action.js +12 -5
  4. package/dist/commands/dev.js +3 -1
  5. package/dist/commands/start-action.js +36 -5
  6. package/dist/common.d.ts +1 -0
  7. package/dist/common.js +1 -0
  8. package/dist/config.js +3 -0
  9. package/dist/derived-outputs.d.ts +1 -5
  10. package/dist/derived-outputs.js +46 -33
  11. package/dist/dev-modules.d.ts +5 -0
  12. package/dist/dev-modules.js +88 -0
  13. package/dist/entry-weight.d.ts +24 -0
  14. package/dist/entry-weight.js +86 -0
  15. package/dist/help.js +5 -1
  16. package/dist/layer-watch.js +3 -2
  17. package/dist/layers.d.ts +1 -0
  18. package/dist/locale-files.d.ts +6 -0
  19. package/dist/locale-files.js +75 -0
  20. package/dist/manifest.js +2 -0
  21. package/dist/private-values.d.ts +1 -0
  22. package/dist/private-values.js +16 -0
  23. package/dist/project-modules-child.d.ts +13 -0
  24. package/dist/project-modules-child.js +26 -0
  25. package/dist/project-modules.d.ts +1 -0
  26. package/dist/project-modules.js +73 -38
  27. package/dist/workspace-setup.js +41 -3
  28. package/dist/workspace.d.ts +6 -0
  29. package/package.json +9 -2
  30. package/templates/vue/app-runtime.ts +8 -1
  31. package/templates/vue/entry-weight.mjs +132 -0
  32. package/templates/vue/frontend-module.ts +144 -1
  33. package/templates/vue/gitignore +1 -0
  34. package/templates/vue/main.ts +21 -0
  35. package/templates/vue/network-error.ts +3 -1
  36. package/templates/vue/package.json +1 -1
  37. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  38. package/templates/vue/pnpm-workspace.yaml +5 -0
  39. package/templates/vue/server/client-manifest.mjs +12 -0
  40. package/templates/vue/server/in-place-render.mjs +110 -0
  41. package/templates/vue/server/page-props.mjs +10 -0
  42. package/templates/vue/server/proxy-transfers.mjs +35 -9
  43. package/templates/vue/server.mjs +39 -29
  44. package/templates/vue/sfc-global-types.mjs +119 -0
  45. package/templates/vue/ssr-renderer.ts +8 -0
  46. package/templates/vue/ui-kbd.mjs +45 -0
  47. package/templates/vue/vite.config.ts +32 -1
@@ -12,7 +12,9 @@ import { defu } from "defu";
12
12
  import { type FetchOptions, ofetch } from "ofetch";
13
13
  import {
14
14
  type App,
15
+ type AppConfig,
15
16
  type Component,
17
+ type ComponentInternalInstance,
16
18
  type ComponentPublicInstance,
17
19
  type ComputedRef,
18
20
  camelize,
@@ -1853,6 +1855,9 @@ function startAsyncData<T>(
1853
1855
  const idle = entry.result.status.value === "idle";
1854
1856
  if (runtime.isServer) {
1855
1857
  if (!immediate || !onServer) return undefined;
1858
+ // A render that already answers a redirect throws its HTML away: what it
1859
+ // still has to read would reach nobody.
1860
+ if (runtime.serverRedirect) return undefined;
1856
1861
  if (idle && options.lazy) entry.schedule();
1857
1862
  else if (idle) void entry.load();
1858
1863
  const settled = entry.settled();
@@ -2488,6 +2493,35 @@ export function defineDmsPageMeta(meta: Record<string, unknown>): void {
2488
2493
  onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
2489
2494
  }
2490
2495
  function ignoreSettlement(): void {}
2496
+ /**
2497
+ * What Vue's development build passes `errorHandler` for an error thrown by a
2498
+ * component's `setup`, sync or async (production passes a link instead; it has
2499
+ * no warnings to scope either).
2500
+ */
2501
+ const SETUP_FUNCTION_ERROR = "setup function";
2502
+ /**
2503
+ * Whether a Vue warning comes from a component whose `setup` failed, or from
2504
+ * under it. Vue still renders such a component, against the setup state that
2505
+ * never came: in development, where a single-file component's template is
2506
+ * compiled apart from its setup, the template reads the missing bindings, and
2507
+ * every component it uses becomes `undefined` ("Invalid vnode type when
2508
+ * creating vnode: undefined", then "Component <Anonymous> is missing template
2509
+ * or render function" for the comment Vue renders in its place). The error
2510
+ * page replaces that output (a server renders the page again, the browser
2511
+ * swaps it), and the failure itself is already reported.
2512
+ *
2513
+ * Walks internal instances: a `<script setup>` component exposes itself, so
2514
+ * its children's `$parent` is an expose proxy, not the proxy `errorHandler`
2515
+ * received.
2516
+ */
2517
+ function withinFailedSetup(
2518
+ instance: ComponentInternalInstance | null,
2519
+ failedSetups: WeakSet<ComponentInternalInstance>,
2520
+ ): boolean {
2521
+ for (let current = instance; current; current = current.parent)
2522
+ if (failedSetups.has(current)) return true;
2523
+ return false;
2524
+ }
2491
2525
  /** Shows the error page in place of the page, keeping the first failure. */
2492
2526
  function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
2493
2527
  runtime.currentError.value ??=
@@ -2578,6 +2612,80 @@ export const DmsLink: Component = defineComponent({
2578
2612
  },
2579
2613
  });
2580
2614
 
2615
+ /** How a keyboard hint prints its modifiers: macOS symbols, or PC key names. */
2616
+ export type DmsKbdPlatform = "mac" | "other";
2617
+ /**
2618
+ * Where Nuxt UI's key hints read the platform: called in the setup of each
2619
+ * component that prints one, it returns the platform that component renders.
2620
+ * It must read on the server the platform it renders there, and keep it while
2621
+ * its caller hydrates, so the browser's first render matches the server's.
2622
+ */
2623
+ export type DmsKbdPlatformSource = () => Readonly<Ref<DmsKbdPlatform>>;
2624
+
2625
+ const KBD_PLATFORM_KEY: InjectionKey<DmsKbdPlatformSource> =
2626
+ Symbol("dms:kbd-platform");
2627
+ const MODIFIER_KBD_KEYS: Record<
2628
+ DmsKbdPlatform,
2629
+ Record<"meta" | "ctrl" | "alt", string>
2630
+ > = {
2631
+ mac: { meta: "⌘", ctrl: "⌃", alt: "⌥" },
2632
+ other: { meta: "Ctrl", ctrl: "Ctrl", alt: "Alt" },
2633
+ };
2634
+ const isModifierKbdKey = (value: string): value is "meta" | "ctrl" | "alt" =>
2635
+ value === "meta" || value === "ctrl" || value === "alt";
2636
+
2637
+ /**
2638
+ * Gives Nuxt UI's key hints (`UKbd`, the `kbds` of its menus, the dashboard
2639
+ * search button) the application's keyboard platform, so the server prints
2640
+ * `meta` as "⌘" or "Ctrl" from the first render instead of a blank the
2641
+ * browser fills in after mounting.
2642
+ */
2643
+ export function provideDmsKbdPlatform(
2644
+ app: App,
2645
+ source: DmsKbdPlatformSource,
2646
+ ): void {
2647
+ app.provide(KBD_PLATFORM_KEY, source);
2648
+ }
2649
+
2650
+ interface NuxtUiKbd {
2651
+ macOS: ComputedRef<unknown>;
2652
+ getKbdKey: (value?: string) => string | undefined;
2653
+ }
2654
+
2655
+ /**
2656
+ * Nuxt UI's `useKbd` as the DMS runs it (`ui-kbd.mjs` routes it here): a hint
2657
+ * renders the same on the server and in the hydrating browser. Nuxt UI prints
2658
+ * `meta`, `ctrl` and `alt` from a map shared by every hint and filled by the
2659
+ * first one's `onMounted`, so a hint hydrating after that (in an async
2660
+ * component) printed "⌘" over the server's blank. Each hint now keeps the
2661
+ * server's value until its own `onMounted`; with a platform provided
2662
+ * (`provideDmsKbdPlatform`), that value is the platform's from the start.
2663
+ *
2664
+ * @internal
2665
+ */
2666
+ export function useDmsKbd(
2667
+ shared: () => NuxtUiKbd,
2668
+ keys: Readonly<Record<string, string>>,
2669
+ ): NuxtUiKbd {
2670
+ const { macOS } = shared();
2671
+ const instance = getCurrentInstance();
2672
+ const source = instance ? inject(KBD_PLATFORM_KEY, null) : null;
2673
+ const platform = source ? source() : null;
2674
+ // A hydrating component's vnode is bound to its server node before setup.
2675
+ const hydrating = ref(!!instance?.vnode.el);
2676
+ if (hydrating.value) onMounted(() => (hydrating.value = false));
2677
+ return {
2678
+ macOS,
2679
+ getKbdKey(value) {
2680
+ if (!value) return undefined;
2681
+ if (!isModifierKbdKey(value)) return keys[value] || value;
2682
+ if (platform) return MODIFIER_KBD_KEYS[platform.value][value];
2683
+ if (typeof window === "undefined" || hydrating.value) return " ";
2684
+ return MODIFIER_KBD_KEYS[macOS.value ? "mac" : "other"][value];
2685
+ },
2686
+ };
2687
+ }
2688
+
2581
2689
  export const DmsClientOnly = defineComponent({
2582
2690
  name: "DmsClientOnly",
2583
2691
  setup(_, { slots }) {
@@ -2817,6 +2925,17 @@ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
2817
2925
  return readonly(useDmsRuntime().pageLoading);
2818
2926
  }
2819
2927
 
2928
+ /**
2929
+ * Whether the browser is hydrating the page the server rendered, whose first
2930
+ * render must match the server's HTML: from the start of the application to
2931
+ * the tick the page's `<Suspense>` resolves. False on the server, in a page
2932
+ * the server did not render, and after a client navigation. As Nuxt's
2933
+ * `nuxtApp.isHydrating`; read once, it is not reactive.
2934
+ */
2935
+ export function isDmsHydrating(): boolean {
2936
+ return useDmsRuntime().hydrating;
2937
+ }
2938
+
2820
2939
  /**
2821
2940
  * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
2822
2941
  * in step with it. Cleared before the boundary unmounts, so a page left while
@@ -3368,7 +3487,31 @@ export async function installDmsPlugins(
3368
3487
  if (browserRuntime === runtime) browserRuntime = undefined;
3369
3488
  });
3370
3489
  }
3371
- app.config.errorHandler = (reason) => failPage(runtime, reason);
3490
+ const failedSetups = new WeakSet<ComponentInternalInstance>();
3491
+ const warnOutsideFailedSetups: AppConfig["warnHandler"] = (
3492
+ message,
3493
+ instance,
3494
+ trace,
3495
+ ) => {
3496
+ // The comment Vue renders in place of an `undefined` component has no
3497
+ // public instance: the template rendering it is the one that failed.
3498
+ const source = instance?.$ ?? getCurrentInstance();
3499
+ if (!withinFailedSetup(source, failedSetups))
3500
+ console.warn(`[Vue warn]: ${message}${trace ? `\n${trace}` : ""}`);
3501
+ };
3502
+ app.config.errorHandler = (reason, instance, info) => {
3503
+ if (instance && info === SETUP_FUNCTION_ERROR) {
3504
+ failedSetups.add(instance.$);
3505
+ // From the first failure on only: until then Vue keeps its own output,
3506
+ // whose component trace shows the props. An app that already has a
3507
+ // handler keeps it. A server renders a single page per app; in the
3508
+ // browser the app outlives Inertia visits, so once a page has failed
3509
+ // the handler stays for the session and later warnings print as plain
3510
+ // text (no inspectable objects), still all of them.
3511
+ app.config.warnHandler ??= warnOutsideFailedSetups;
3512
+ }
3513
+ failPage(runtime, reason);
3514
+ };
3372
3515
  app.config.globalProperties.$dms = appContext;
3373
3516
  Reflect.set(app, "$dms", appContext);
3374
3517
  Reflect.set(app, "$i18n", i18n);
@@ -10,6 +10,7 @@ frontend-modules/**/i18n/locales/
10
10
  # CSS hot update on plain navigation.
11
11
  auto-imports.d.ts
12
12
  components.d.ts
13
+ sfc-global-types.generated.d.ts
13
14
  # Build output, which the email bundle watcher rewrites during development:
14
15
  # read as Tailwind sources, each rebuild would push a burst of CSS updates.
15
16
  dist/
@@ -60,6 +60,26 @@ if (import.meta.env.DEV) adoptViteDevStyles();
60
60
  // the generated server answers those queries from the installed collections.
61
61
  addAPIProvider("", sameOriginIconProvider(location.origin));
62
62
 
63
+ /**
64
+ * Shows the URL of the page the server sent, when it rendered a query the page
65
+ * completed in place of the one requested (`renderDocument` in server.mjs).
66
+ * Replaced before Inertia starts: Inertia records a first page whose URL is
67
+ * not the address bar's as a new history entry, and Back would then lead to
68
+ * the URL the page completes again.
69
+ */
70
+ function showServedUrl(url: string): void {
71
+ const served = new URL(url, location.href);
72
+ if (
73
+ served.pathname === location.pathname &&
74
+ served.search !== location.search
75
+ )
76
+ history.replaceState(
77
+ history.state,
78
+ "",
79
+ `${served.pathname}${served.search}${location.hash}`,
80
+ );
81
+ }
82
+
63
83
  /**
64
84
  * Sets the modules up, then starts the app.
65
85
  *
@@ -84,6 +104,7 @@ async function startDmsApp(): Promise<void> {
84
104
  createInertiaApp({
85
105
  resolve: resolveDmsInertiaPage,
86
106
  async setup({ App, el, props, plugin }) {
107
+ showServedUrl(props.initialPage.url);
87
108
  const root = { render: () => h(App, props) };
88
109
  const app = el.hasAttribute("data-server-rendered")
89
110
  ? createSSRApp(root)
@@ -18,7 +18,9 @@ const NETWORK_ERROR_KEY = "error.network";
18
18
  const DEFAULT_LOCALE = "en";
19
19
  // Shipped with the renderer, since no module is guaranteed to translate the
20
20
  // case; a module overrides them with `error.network.title` and
21
- // `error.network.description` in its own locale files.
21
+ // `error.network.description` in its own locale files. `te()` reads the
22
+ // visitor's locale alone, and the generated catalogs leave these keys out of
23
+ // their English fill, so only that locale's own message replaces them.
22
24
  const NETWORK_ERROR_MESSAGES: Record<
23
25
  string,
24
26
  Record<"title" | "description", string>
@@ -25,7 +25,7 @@
25
25
  "@unhead/vue": "^2.0.14",
26
26
  "@vitejs/plugin-vue": "^6.0.6",
27
27
  "@vue/server-renderer": "^3.5.0",
28
- "@vueuse/core": "^14.3.0",
28
+ "@vueuse/core": "14.4.0",
29
29
  "defu": "^6.1.4",
30
30
  "devalue": "^5.9.4",
31
31
  "ofetch": "^1.4.1",
@@ -0,0 +1,24 @@
1
+ diff --git a/dist/index.js b/dist/index.js
2
+ index 772a5e9..d433a57 100644
3
+ --- a/dist/index.js
4
+ +++ b/dist/index.js
5
+ @@ -2047,11 +2047,16 @@ function useColorMode(options = {}) {
6
+ let attributeToChange = null;
7
+ if (attribute === "class") {
8
+ const current = value.split(/\s/g);
9
+ + // Patched by @antelopejs/dms-frontend until vueuse ships the fix: only
10
+ + // the classes `el` lacks or still carries count as a change, so a mode
11
+ + // already applied (by the server render, another instance or the
12
+ + // mount re-run) skips the forced style recalculation below.
13
+ Object.values(modes).flatMap((i) => (i || "").split(/\s/g)).filter(Boolean).forEach((v) => {
14
+ - if (current.includes(v)) classesToAdd.add(v);
15
+ - else classesToRemove.add(v);
16
+ + if (current.includes(v)) {
17
+ + if (!el.classList.contains(v)) classesToAdd.add(v);
18
+ + } else if (el.classList.contains(v)) classesToRemove.add(v);
19
+ });
20
+ - } else attributeToChange = {
21
+ + } else if (el.getAttribute(attribute) !== value) attributeToChange = {
22
+ key: attribute,
23
+ value
24
+ };
@@ -9,3 +9,8 @@ patchedDependencies:
9
9
  # Menu and breadcrumb links active on a sibling route (`/test` on `/test-a`):
10
10
  # nuxt/ui#6803, fixed by nuxt/ui#6809.
11
11
  "@nuxt/ui@4.10.0": patches/@nuxt__ui@4.10.0.patch
12
+ # useColorMode re-applies a color mode `<html>` already carries, forcing a
13
+ # full style recalculation on every call (6 on the dashboard home, 216 ms of
14
+ # main thread at 4x CPU): the guard of vueuse#4001 compares the modes with the
15
+ # requested value instead of the element. vueuse 15.0.0 still ships it.
16
+ "@vueuse/core@14.4.0": patches/@vueuse__core@14.4.0.patch
@@ -78,3 +78,15 @@ export function renderedModuleStyleTags(modules, template) {
78
78
  .map((file) => `<link rel="stylesheet" href="/${file}">`)
79
79
  .join("");
80
80
  }
81
+
82
+ /**
83
+ * The link that preloads the built catalog of `locale`, the one the page was
84
+ * rendered with: the browser fetches it next to the entry rather than once the
85
+ * entry runs and asks for it, which would put a round trip before hydration.
86
+ * Nothing without a production build (Vite serves the sources in development).
87
+ */
88
+ export function localeCatalogPreloadTag(locale) {
89
+ const manifest = productionClientManifest();
90
+ const file = locale && manifest?.[`locales.generated/${locale}.ts`]?.file;
91
+ return file ? `<link rel="modulepreload" crossorigin href="/${file}">` : "";
92
+ }
@@ -0,0 +1,110 @@
1
+ // A page that completes its query as it renders, rendered at that query in
2
+ // the same request rather than answered with a redirect.
3
+ //
4
+ // Lives apart from server.mjs so the file stays under the size the linter
5
+ // allows.
6
+
7
+ import { ofetch } from "ofetch";
8
+
9
+ /**
10
+ * How many times one request renders a page in place of a same-path redirect
11
+ * before it answers the redirect: a page that keeps changing its query.
12
+ */
13
+ const IN_PLACE_RENDERS = 2;
14
+
15
+ /**
16
+ * The key of a backend read a later render of the same request may reuse: a
17
+ * plain JSON `GET`, without hooks that would expect to see the response.
18
+ */
19
+ function reusableReadKey(path, options, headers) {
20
+ const method = (options.method ?? "GET").toUpperCase();
21
+ if (method !== "GET" || options.body !== undefined) return undefined;
22
+ if (options.responseType && options.responseType !== "json") return undefined;
23
+ if (Object.keys(options).some((name) => /^on[A-Z]/.test(name)))
24
+ return undefined;
25
+ return JSON.stringify([
26
+ path,
27
+ options.query ?? options.params ?? null,
28
+ [...headers],
29
+ ]);
30
+ }
31
+
32
+ /**
33
+ * The backend reads of a request's server renders, sent with the request's
34
+ * `backendHeaders()` and the read's own. After `reuseCompletedReads()`, a later
35
+ * render of the same request is given a copy of each read a previous render
36
+ * completed instead of reading the backend again.
37
+ */
38
+ export function serverReads(backendHeaders) {
39
+ const completed = new Map();
40
+ let reuse = false;
41
+ const read = (path, options = {}) => {
42
+ const headers = new Headers(backendHeaders());
43
+ new Headers(options.headers).forEach((value, name) => {
44
+ headers.set(name, value);
45
+ });
46
+ const key = reusableReadKey(path, options, headers);
47
+ if (reuse && key && completed.has(key))
48
+ return Promise.resolve(structuredClone(completed.get(key)));
49
+ const reading = ofetch(path, {
50
+ ...options,
51
+ baseURL: process.env.DMS_API_BASE_URL,
52
+ headers,
53
+ });
54
+ if (!key) return reading;
55
+ // A copy: the render that read it may still change the object it got. A
56
+ // body that cannot be copied is read again.
57
+ return reading.then((data) => {
58
+ try {
59
+ completed.set(key, structuredClone(data));
60
+ } catch {}
61
+ return data;
62
+ });
63
+ };
64
+ return Object.assign(read, {
65
+ reuseCompletedReads: () => {
66
+ reuse = true;
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Whether a document request renders `location` in place of redirecting to
73
+ * it: a `GET` whose page only completes its query (`?workspace=`), on the
74
+ * same path. Any other redirect keeps its own response.
75
+ */
76
+ function rendersInPlace(request, from, location) {
77
+ if (request.method !== "GET" || location.includes("#")) return false;
78
+ const current = new URL(from, "http://frontend.local");
79
+ const target = new URL(location, current);
80
+ return (
81
+ target.origin === current.origin && target.pathname === current.pathname
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Renders a page document with `render(page)`. A page that completes its
87
+ * query as it renders (`router.replace` to the same path) is rendered again at
88
+ * that query, with the props `propsAt(props, url)` gives, reusing the reads
89
+ * the first render made (`fetch`), instead of being answered with a redirect
90
+ * the browser would follow with a whole new request. The page served then
91
+ * names the URL the document shows, which the browser puts in the address bar.
92
+ */
93
+ export async function renderInPlace(request, page, fetch, render, propsAt) {
94
+ let rendered = await render(page);
95
+ let served = page;
96
+ for (
97
+ let pass = 0;
98
+ rendered.redirect &&
99
+ pass < IN_PLACE_RENDERS &&
100
+ rendersInPlace(request, served.url, rendered.redirect);
101
+ pass++
102
+ ) {
103
+ const url = new URL(rendered.redirect, "http://frontend.local");
104
+ const props = await propsAt(served.props, url);
105
+ served = { ...served, url: `${url.pathname}${url.search}`, props };
106
+ fetch.reuseCompletedReads();
107
+ rendered = await render(served);
108
+ }
109
+ return { rendered, served };
110
+ }
@@ -100,6 +100,16 @@ async function loadPage(
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * The props of the same page at another query of its path (`url`): the page
105
+ * is read again, since its layout reads the query, and the rest is kept,
106
+ * the site layout included.
107
+ */
108
+ export async function propsForQuery(props, url, backend, auth) {
109
+ const { page } = await loadPage(url, false, backend, auth.signInPath);
110
+ return { ...props, page };
111
+ }
112
+
103
113
  /**
104
114
  * The props of the page a request visits, with the once props to declare.
105
115
  *
@@ -1,14 +1,18 @@
1
1
  // Large transfers through the proxy, a file upload above all. The request
2
2
  // body is streamed to the backend, never buffered, and held to the
3
3
  // `maxBodyBytes` of the proxy it reached: a declared Content-Length over the
4
- // limit is refused before a byte is read, a chunked body is cut off as soon as
5
- // it goes over.
4
+ // limit is refused before a byte is passed on, a chunked body is cut off as
5
+ // soon as it goes over.
6
6
 
7
7
  const PAYLOAD_TOO_LARGE = 413;
8
8
  const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
9
- // Reading on after the refusal would take the whole body the limit exists to
10
- // refuse, so the request stream is left as it is and the connection closed.
9
+ // The refused body is never passed on: the iteration stops where it went
10
+ // over, and the request stream stays open for the lingering close below.
11
11
  const BODY_ITERATION = { destroyOnReturn: false };
12
+ // How long a refused body is read and dropped before the connection closes
13
+ // anyway: long enough for a client to finish sending what was in flight,
14
+ // short enough to bound what a refused client can make the server read.
15
+ const LINGER_MS = 5_000;
12
16
 
13
17
  const BODILESS_METHODS = new Set(["GET", "HEAD"]);
14
18
  const REQUEST_TIMEOUT_MS = 60 * 60 * 1000;
@@ -54,13 +58,35 @@ function isBodyTooLarge(error) {
54
58
  );
55
59
  }
56
60
 
57
- /** Answers 413 and closes the connection, the rest of the body unread. */
58
- function refuseTooLargeBody(response) {
61
+ /**
62
+ * Answers 413 and closes the connection once the client stopped sending.
63
+ *
64
+ * Closing a socket with unread bytes makes the kernel reset the connection,
65
+ * and a client stack that drops what it had not read yet when the reset
66
+ * arrives (macOS, BSD, Windows) reports a network error instead of the 413,
67
+ * the browser's upload failing without a reason. The rest of the body is read
68
+ * and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
69
+ * at most; the response ends with it and the connection then closes cleanly.
70
+ */
71
+ function refuseTooLargeBody(request, response) {
59
72
  response.writeHead(PAYLOAD_TOO_LARGE, {
60
73
  "content-type": "application/json",
61
74
  connection: "close",
62
75
  });
63
- response.end(TOO_LARGE_BODY);
76
+ response.write(TOO_LARGE_BODY);
77
+ if (request.complete) return response.end();
78
+ const close = () => {
79
+ clearTimeout(timer);
80
+ response.end();
81
+ };
82
+ const timer = setTimeout(() => {
83
+ response.end();
84
+ request.socket.destroy();
85
+ }, LINGER_MS);
86
+ timer.unref();
87
+ request.once("end", close);
88
+ request.once("error", close);
89
+ request.resume();
64
90
  }
65
91
 
66
92
  /**
@@ -74,11 +100,11 @@ function refuseTooLargeBody(response) {
74
100
  */
75
101
  export async function sendWithinLimit(request, response, maxBodyBytes, send) {
76
102
  if (declaresTooLargeBody(request, maxBodyBytes))
77
- return refuseTooLargeBody(response);
103
+ return refuseTooLargeBody(request, response);
78
104
  try {
79
105
  return await send(requestBody(request, maxBodyBytes));
80
106
  } catch (error) {
81
107
  if (!isBodyTooLarge(error)) throw error;
82
- return refuseTooLargeBody(response);
108
+ return refuseTooLargeBody(request, response);
83
109
  }
84
110
  }
@@ -2,7 +2,6 @@ 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 {
7
6
  CLIENT_HEADERS,
8
7
  relayedHeaders,
@@ -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,
@@ -50,7 +52,8 @@ import {
50
52
  } from "./server/module-server.mjs";
51
53
  import { HOMEPAGE } from "./server/homepage.mjs";
52
54
  import { publicOptionsScript } from "./server/public-options.mjs";
53
- import { loadPageProps } from "./server/page-props.mjs";
55
+ import { renderInPlace, serverReads } from "./server/in-place-render.mjs";
56
+ import { loadPageProps, propsForQuery } from "./server/page-props.mjs";
54
57
  import { htmlTag } from "./server/html-tag.mjs";
55
58
  import { listenHost } from "./server/listen-host.mjs";
56
59
  import * as icons from "./server/icons.mjs";
@@ -149,17 +152,7 @@ function backendHeaders(request, access = { anonymous: false, headers: [] }) {
149
152
  }
150
153
 
151
154
  function serverComponentFetch(request) {
152
- return (path, options = {}) => {
153
- const headers = new Headers(backendHeaders(request));
154
- new Headers(options.headers).forEach((value, name) => {
155
- headers.set(name, value);
156
- });
157
- return ofetch(path, {
158
- ...options,
159
- baseURL: process.env.DMS_API_BASE_URL,
160
- headers,
161
- });
162
- };
155
+ return serverReads(() => backendHeaders(request));
163
156
  }
164
157
 
165
158
  async function backendJson(path, request) {
@@ -177,18 +170,18 @@ async function currentAuth() {
177
170
  return (await loadServerModules(await developmentServer())).serverAuth;
178
171
  }
179
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
+
180
183
  function pageProps(request, auth) {
181
- return loadPageProps(
182
- request,
183
- {
184
- fetchJson: (path) => backendJson(path, request),
185
- isFrontendPage: async (pathname) =>
186
- (await ssrRenderer(await developmentServer())).isDmsFrontendPage(
187
- pathname,
188
- ),
189
- },
190
- auth,
191
- );
184
+ return loadPageProps(request, pageBackend(request), auth);
192
185
  }
193
186
 
194
187
  function redirectToHomepage(request, response, pathname) {
@@ -320,6 +313,7 @@ export async function renderHtml(
320
313
  return { html: "", status: 200, redirect: rendered.redirect };
321
314
  const headEnd =
322
315
  (await documentStyleTags(devServer, rendered.modules, template)) +
316
+ (devServer ? "" : localeCatalogPreloadTag(rendered.catalogLocale)) +
323
317
  publicOptionsScript();
324
318
  const html = template
325
319
  .replace("<title>Antelope DMS</title>", () =>
@@ -338,13 +332,28 @@ export async function renderHtml(
338
332
  return { html, status: rendered.error?.statusCode ?? 200 };
339
333
  }
340
334
 
341
- function renderRequestHtml(page, request, rule = {}) {
342
- const fetch = serverComponentFetch(request);
335
+ function renderRequestHtml(page, request, rule = {}, reads) {
336
+ const fetch = reads ?? serverComponentFetch(request);
343
337
  return renderHtml(page, request.url, fetch, request.headers.cookie, {
344
338
  ssr: rule.ssr,
345
339
  });
346
340
  }
347
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
+
348
357
  async function writeBackendError(error, request, response) {
349
358
  const auth = await currentAuth();
350
359
  const status = Number.isInteger(error?.status) ? error.status : 502;
@@ -477,16 +486,17 @@ export async function handleRequest(request, response) {
477
486
  JSON.stringify(page),
478
487
  );
479
488
  }
480
- const rendered = await renderRequestHtml(page, request, rule);
489
+ const { rendered, served } = await renderDocument(page, request, rule, auth);
481
490
  if (rendered.redirect) {
482
491
  redirectFrontendVisit(request, response, rendered.redirect);
483
492
  return;
484
493
  }
494
+ const location = served === page ? {} : { "content-location": served.url };
485
495
  return writeContent(
486
496
  request,
487
497
  response,
488
498
  rendered.status,
489
- { ...htmlHeaders(), ...headersFor(rendered.status) },
499
+ { ...htmlHeaders(), ...headersFor(rendered.status), ...location },
490
500
  rendered.html,
491
501
  );
492
502
  }