@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.
- package/dist/commands/build-action.js +3 -0
- package/dist/commands/dev-action.d.ts +2 -0
- package/dist/commands/dev-action.js +12 -5
- package/dist/commands/dev.js +3 -1
- package/dist/commands/start-action.js +36 -5
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.js +3 -0
- package/dist/derived-outputs.d.ts +1 -5
- package/dist/derived-outputs.js +46 -33
- package/dist/dev-modules.d.ts +5 -0
- package/dist/dev-modules.js +88 -0
- package/dist/entry-weight.d.ts +24 -0
- package/dist/entry-weight.js +86 -0
- package/dist/help.js +5 -1
- package/dist/layer-watch.js +3 -2
- package/dist/layers.d.ts +1 -0
- package/dist/locale-files.d.ts +6 -0
- package/dist/locale-files.js +75 -0
- package/dist/manifest.js +2 -0
- package/dist/private-values.d.ts +1 -0
- package/dist/private-values.js +16 -0
- package/dist/project-modules-child.d.ts +13 -0
- package/dist/project-modules-child.js +26 -0
- package/dist/project-modules.d.ts +1 -0
- package/dist/project-modules.js +73 -38
- package/dist/workspace-setup.js +41 -3
- package/dist/workspace.d.ts +6 -0
- package/package.json +9 -2
- package/templates/vue/app-runtime.ts +8 -1
- package/templates/vue/entry-weight.mjs +132 -0
- package/templates/vue/frontend-module.ts +144 -1
- package/templates/vue/gitignore +1 -0
- package/templates/vue/main.ts +21 -0
- package/templates/vue/network-error.ts +3 -1
- package/templates/vue/package.json +1 -1
- package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
- package/templates/vue/pnpm-workspace.yaml +5 -0
- package/templates/vue/server/client-manifest.mjs +12 -0
- package/templates/vue/server/in-place-render.mjs +110 -0
- package/templates/vue/server/page-props.mjs +10 -0
- package/templates/vue/server/proxy-transfers.mjs +35 -9
- package/templates/vue/server.mjs +39 -29
- package/templates/vue/sfc-global-types.mjs +119 -0
- package/templates/vue/ssr-renderer.ts +8 -0
- package/templates/vue/ui-kbd.mjs +45 -0
- 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
|
-
|
|
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);
|
package/templates/vue/gitignore
CHANGED
|
@@ -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/
|
package/templates/vue/main.ts
CHANGED
|
@@ -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>
|
|
@@ -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
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
-
/**
|
|
58
|
-
|
|
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.
|
|
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
|
}
|
package/templates/vue/server.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 (
|
|
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
|
|
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
|
}
|