@antelopejs/dms-frontend 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -24
- package/dist/commands/build-action.js +3 -2
- package/dist/commands/dev-action.js +2 -1
- package/dist/commands/prepare-action.js +1 -1
- package/dist/commands/start-action.js +3 -24
- package/dist/commands/workspace-task.js +2 -1
- package/dist/config.js +3 -0
- package/dist/derived-outputs.js +3 -3
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- package/dist/index.js +2 -1
- package/dist/internal/built-workspace.d.ts +4 -0
- package/dist/internal/built-workspace.js +43 -0
- package/dist/internal/ensure.d.ts +1 -0
- package/dist/internal/ensure.js +8 -0
- package/dist/internal/locale-catalogs.d.ts +8 -3
- package/dist/internal/locale-catalogs.js +16 -6
- package/dist/internal/manifest-decoder.d.ts +7 -0
- package/dist/internal/manifest-decoder.js +81 -0
- package/dist/internal/own-release.d.ts +5 -0
- package/dist/internal/own-release.js +20 -0
- package/dist/internal/plain-object.d.ts +1 -0
- package/dist/internal/plain-object.js +6 -0
- package/dist/internal/text-list.d.ts +1 -0
- package/dist/internal/text-list.js +10 -0
- package/dist/internal/vite-manifest.d.ts +6 -0
- package/dist/internal/vite-manifest.js +24 -0
- package/dist/layer-watch.js +25 -24
- package/dist/layers.d.ts +1 -1
- package/dist/layers.js +3 -1
- package/dist/locale-files.d.ts +2 -1
- package/dist/locale-files.js +4 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +44 -31
- package/dist/materialize.js +6 -2
- package/dist/module-set.js +2 -1
- package/dist/node-options.d.ts +1 -0
- package/dist/node-options.js +8 -0
- package/dist/output.d.ts +15 -9
- package/dist/output.js +30 -18
- package/dist/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.js +13 -8
- package/dist/renderer-range.js +5 -3
- package/dist/server-process.js +1 -1
- package/dist/verify-source-build.js +10 -2
- package/dist/verify-source-client-bundle.d.ts +1 -0
- package/dist/verify-source-client-bundle.js +30 -0
- package/dist/verify-source-runner.js +44 -60
- package/dist/verify-source-sources.js +49 -11
- package/dist/workspace-setup.js +1 -1
- package/package.json +6 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +90 -16
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +22 -0
- package/templates/vue/frontend-module.ts +1 -0
- package/templates/vue/frontend-runtime/module-registration.ts +2 -2
- package/templates/vue/frontend-runtime/state.ts +2 -2
- package/templates/vue/frontend-runtime/types.ts +8 -2
- package/templates/vue/globals.d.ts +5 -0
- package/templates/vue/head-order.mjs +7 -0
- package/templates/vue/icon-bundle.ts +31 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/message-subset.ts +65 -0
- package/templates/vue/network-error.ts +4 -0
- package/templates/vue/server/auth/backend.mjs +41 -4
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
- package/templates/vue/server/auth/session.mjs +81 -2
- package/templates/vue/server/auth-hooks.mjs +15 -5
- package/templates/vue/server/backend-proxies.mjs +65 -9
- package/templates/vue/server/backend-request.mjs +61 -0
- package/templates/vue/server/backend-response.mjs +23 -6
- package/templates/vue/server/built-files.mjs +7 -0
- package/templates/vue/server/cli-report.mjs +53 -11
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +18 -1
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email.mjs +9 -3
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +211 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +80 -7
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +61 -2
- package/templates/vue/server/module-event.mjs +98 -17
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +138 -31
- package/templates/vue/server/module-server.mjs +98 -27
- package/templates/vue/server/module-testing.mjs +14 -5
- package/templates/vue/server/page-props.mjs +92 -2
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +13 -1
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +11 -1
- package/templates/vue/server/proxy-redirects.mjs +20 -5
- package/templates/vue/server/proxy-request.mjs +57 -0
- package/templates/vue/server/proxy-transfers.mjs +33 -5
- package/templates/vue/server/public-endpoints.mjs +138 -23
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +23 -0
- package/templates/vue/server/request-routing.mjs +26 -9
- package/templates/vue/server/request-target.mjs +9 -3
- package/templates/vue/server/route-rules.mjs +39 -15
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +24 -1
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +32 -5
- package/templates/vue/server/web-body.mjs +3 -0
- package/templates/vue/server-module.ts +10 -1
- package/templates/vue/server.mjs +132 -148
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +12 -3
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-lazy-icons.mjs +44 -0
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +2 -0
|
@@ -2,11 +2,20 @@ import { relative, sep } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
const SERVER_ENTRY = /(?:^|[\\/])dms\.server\.[cm]?[jt]sx?$/;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} id
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
5
9
|
function stripQuery(id) {
|
|
6
10
|
const query = id.indexOf("?");
|
|
7
11
|
return query === -1 ? id : id.slice(0, query);
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} file
|
|
16
|
+
* @param {readonly string[]} moduleRoots
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
10
19
|
function insideModuleServerDirectory(file, moduleRoots) {
|
|
11
20
|
return moduleRoots.some((root) => {
|
|
12
21
|
const path = relative(root, file);
|
|
@@ -20,6 +29,10 @@ function insideModuleServerDirectory(file, moduleRoots) {
|
|
|
20
29
|
* Whether `id`, a resolved module id, is server code: a module's server entry
|
|
21
30
|
* (`dms.server.*`) or a file under a `server/` directory of a module. Installed
|
|
22
31
|
* packages never are: `node_modules` has its own `server` directories.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} id
|
|
34
|
+
* @param {readonly string[]} moduleRoots
|
|
35
|
+
* @returns {boolean}
|
|
23
36
|
*/
|
|
24
37
|
export function isServerSource(id, moduleRoots) {
|
|
25
38
|
if (id.startsWith("\0")) return false;
|
|
@@ -44,6 +57,9 @@ export function isServerSource(id, moduleRoots) {
|
|
|
44
57
|
* import; the guard sits in the module graph instead, where every resolved file
|
|
45
58
|
* passes. Kept beside vite.config.ts rather than inside it so the plugin can be
|
|
46
59
|
* exercised on its own, the way head-order.mjs is.
|
|
60
|
+
*
|
|
61
|
+
* @param {readonly string[]} moduleRoots
|
|
62
|
+
* @returns {import("vite").Plugin}
|
|
47
63
|
*/
|
|
48
64
|
export function rejectServerSourcesInClient(moduleRoots) {
|
|
49
65
|
let ssrBuild = false;
|
|
@@ -16,10 +16,14 @@ const MINIMUM_SIZE_BYTES = 1_024;
|
|
|
16
16
|
const compressBrotli = promisify(brotliCompress);
|
|
17
17
|
const compressGzip = promisify(gzip);
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} directory
|
|
21
|
+
* @returns {Promise<string[]>}
|
|
22
|
+
*/
|
|
19
23
|
async function assetFiles(directory) {
|
|
20
24
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
21
25
|
const files = await Promise.all(
|
|
22
|
-
entries.map((entry) => {
|
|
26
|
+
entries.map(async (entry) => {
|
|
23
27
|
const path = join(directory, entry.name);
|
|
24
28
|
return entry.isDirectory() ? assetFiles(path) : [path];
|
|
25
29
|
}),
|
|
@@ -27,6 +31,10 @@ async function assetFiles(directory) {
|
|
|
27
31
|
return files.flat();
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} path
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
*/
|
|
30
38
|
async function compressAsset(path) {
|
|
31
39
|
if (!COMPRESSIBLE_EXTENSIONS.has(extname(path))) return;
|
|
32
40
|
const content = await readFile(path);
|
|
@@ -10,11 +10,20 @@ export const TRANSLATIONS_OWNER = "translations";
|
|
|
10
10
|
|
|
11
11
|
const LOCALE_CATALOG = /[\\/]locales\.generated[\\/]/;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} id
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
13
17
|
function stripQuery(id) {
|
|
14
18
|
const query = id.indexOf("?");
|
|
15
19
|
return query === -1 ? id : id.slice(0, query);
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} root
|
|
24
|
+
* @param {string} file
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
18
27
|
function isInside(root, file) {
|
|
19
28
|
const path = relative(root, file);
|
|
20
29
|
return Boolean(path) && !path.startsWith("..") && !path.startsWith(sep);
|
|
@@ -26,7 +35,9 @@ function isInside(root, file) {
|
|
|
26
35
|
* what every page shares (the renderer's runtime, the UI and icon libraries,
|
|
27
36
|
* an installed package).
|
|
28
37
|
*
|
|
38
|
+
* @param {string} id
|
|
29
39
|
* @param {{ root: string, name?: string, packageName?: string, id: string }[]} modules
|
|
40
|
+
* @returns {string}
|
|
30
41
|
*/
|
|
31
42
|
export function entryOwner(id, modules) {
|
|
32
43
|
if (id.startsWith("\0")) return SHARED_OWNER;
|
|
@@ -42,13 +53,18 @@ export function entryOwner(id, modules) {
|
|
|
42
53
|
/**
|
|
43
54
|
* The files a page loads before its first line runs: the entry chunks and
|
|
44
55
|
* their static imports, transitively, by file name.
|
|
56
|
+
*
|
|
57
|
+
* @param {import("vite").Rollup.OutputBundle} bundle
|
|
58
|
+
* @returns {(import("vite").Rollup.OutputChunk | undefined)[]}
|
|
45
59
|
*/
|
|
46
60
|
function entryClosure(bundle) {
|
|
47
61
|
const chunks = Object.values(bundle).filter(
|
|
48
62
|
(output) => output.type === "chunk",
|
|
49
63
|
);
|
|
50
64
|
const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
|
|
65
|
+
/** @type {Set<string>} */
|
|
51
66
|
const seen = new Set();
|
|
67
|
+
/** @param {import("vite").Rollup.OutputChunk | undefined} chunk */
|
|
52
68
|
const visit = (chunk) => {
|
|
53
69
|
if (!chunk || seen.has(chunk.fileName)) return;
|
|
54
70
|
seen.add(chunk.fileName);
|
|
@@ -64,10 +80,13 @@ function entryClosure(bundle) {
|
|
|
64
80
|
* rendered for each (`renderedLength`, before minification): an estimate that
|
|
65
81
|
* needs nothing the bundle does not already hold.
|
|
66
82
|
*
|
|
83
|
+
* @param {import("vite").Rollup.OutputBundle} bundle
|
|
84
|
+
* @param {{ root: string, name?: string, packageName?: string, id: string }[]} modules
|
|
67
85
|
* @returns {{ files: string[], bytes: number, owners: Record<string, number> }}
|
|
68
86
|
*/
|
|
69
87
|
export function attributeEntry(bundle, modules) {
|
|
70
88
|
const files = entryClosure(bundle);
|
|
89
|
+
/** @type {Record<string, number>} */
|
|
71
90
|
const owners = {};
|
|
72
91
|
let bytes = 0;
|
|
73
92
|
for (const chunk of files) {
|
|
@@ -109,6 +128,9 @@ export function attributeEntry(bundle, modules) {
|
|
|
109
128
|
*
|
|
110
129
|
* The weight is recorded once the bundle is written: plugins that generate the
|
|
111
130
|
* bundle after this one still rewrite the chunks' code.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ root: string, name?: string, packageName?: string, id: string }[]} modules
|
|
133
|
+
* @returns {import("vite").Plugin}
|
|
112
134
|
*/
|
|
113
135
|
export function recordEntryWeight(modules) {
|
|
114
136
|
let ssrBuild = false;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { usePage } from "@inertiajs/vue3";
|
|
2
|
-
import { ofetch } from "ofetch";
|
|
3
2
|
import {
|
|
4
3
|
type Component,
|
|
5
4
|
type EffectScope,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
type DmsPluginRegistrationOptions,
|
|
20
19
|
type DmsPluginSetup,
|
|
21
20
|
type DmsRoute,
|
|
21
|
+
type DmsServerFetch,
|
|
22
22
|
type DmsSharedPagePayload,
|
|
23
23
|
type RouteLocationRaw,
|
|
24
24
|
} from "./types";
|
|
@@ -212,7 +212,7 @@ export interface DmsFrontendRuntime {
|
|
|
212
212
|
/** Whether to warn about what a development render does wrong. */
|
|
213
213
|
devWarnings?: boolean;
|
|
214
214
|
runtimeHooks: Map<string, DmsRuntimeHook[]>;
|
|
215
|
-
serverFetch?:
|
|
215
|
+
serverFetch?: DmsServerFetch;
|
|
216
216
|
currentError: Ref<Error | null>;
|
|
217
217
|
route: DmsRoute;
|
|
218
218
|
currentRoutePattern?: string;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ofetch } from "ofetch";
|
|
2
1
|
import {
|
|
3
2
|
type App,
|
|
4
3
|
effectScope,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
import {
|
|
20
19
|
type DmsAppContext,
|
|
21
20
|
type DmsRoute,
|
|
21
|
+
type DmsServerFetch,
|
|
22
22
|
type DmsRuntimeConfig,
|
|
23
23
|
} from "./types";
|
|
24
24
|
|
|
@@ -65,7 +65,7 @@ export function forgetBrowserRuntime(runtime: DmsFrontendRuntime): void {
|
|
|
65
65
|
|
|
66
66
|
/** Creates mutable runtime state owned by one Vue application. */
|
|
67
67
|
export function createDmsFrontendRuntime(
|
|
68
|
-
serverFetch?:
|
|
68
|
+
serverFetch?: DmsServerFetch,
|
|
69
69
|
hydratedAsyncData?: Record<string, unknown>,
|
|
70
70
|
isServer = false,
|
|
71
71
|
requestCookies?: string,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type InertiaLinkProps } from "@inertiajs/vue3";
|
|
2
|
-
import { type FetchOptions
|
|
2
|
+
import { type FetchOptions } from "ofetch";
|
|
3
3
|
import {
|
|
4
4
|
type App,
|
|
5
5
|
type ComputedRef,
|
|
@@ -246,6 +246,12 @@ export interface UseAsyncDataOptions<T> {
|
|
|
246
246
|
*/
|
|
247
247
|
refreshInterval?: MaybeRefOrGetter<number | null | undefined>;
|
|
248
248
|
}
|
|
249
|
+
/** What the runtime calls to read the backend: `ofetch`, or a stand-in for it. */
|
|
250
|
+
export type DmsServerFetch = (
|
|
251
|
+
request: string,
|
|
252
|
+
options?: FetchOptions,
|
|
253
|
+
) => Promise<unknown>;
|
|
254
|
+
|
|
249
255
|
/**
|
|
250
256
|
* `useDmsFetch`'s options: ofetch's request options, of which the method, the
|
|
251
257
|
* query, the params and the body may be refs or getters, and the async data
|
|
@@ -271,7 +277,7 @@ export interface UseFetchOptions<T>
|
|
|
271
277
|
context: DmsFetchTransformContext,
|
|
272
278
|
) => T | Promise<T>;
|
|
273
279
|
/** Replaces the fetch function; it receives the run's signal. */
|
|
274
|
-
$fetch?:
|
|
280
|
+
$fetch?: DmsServerFetch;
|
|
275
281
|
}
|
|
276
282
|
export interface DmsErrorData {
|
|
277
283
|
statusCode?: number;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const SCRIPT = /<script[^>]*\stype="module"[^>]*><\/script>/;
|
|
2
2
|
const STYLESHEET = /[ \t]*<link rel="stylesheet"[^>]*>\n?/;
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} html
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
4
8
|
function deprioritize(html) {
|
|
5
9
|
return html.replace(SCRIPT, (tag) =>
|
|
6
10
|
tag.includes("fetchpriority")
|
|
@@ -24,6 +28,9 @@ function deprioritize(html) {
|
|
|
24
28
|
* HTTP/1.1 the generated server speaks. Kept beside vite.config.ts rather than
|
|
25
29
|
* inside it so the transform can be exercised on its own, the way
|
|
26
30
|
* client-manifest.mjs is.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} html
|
|
33
|
+
* @returns {string}
|
|
27
34
|
*/
|
|
28
35
|
export function orderHeadForFirstPaint(html) {
|
|
29
36
|
const prioritized = deprioritize(html);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { addIcon, type IconifyIcon } from "@iconify/vue";
|
|
2
|
+
|
|
3
|
+
function register(name: string, data: IconifyIcon): boolean {
|
|
4
|
+
const colon = name.indexOf(":");
|
|
5
|
+
if (colon !== -1 && name.slice(0, colon).includes("-")) {
|
|
6
|
+
addIcon(name.replace(":", "-"), data);
|
|
7
|
+
}
|
|
8
|
+
return addIcon(name, data);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let loading: Promise<void> | undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loads the icons Nuxt UI bundles for the client, once: every icon the
|
|
15
|
+
* application's sources name. A server render has them from the start (Nuxt
|
|
16
|
+
* UI's own plugin, left in place by `ui-lazy-icons.mjs`), so there is nothing
|
|
17
|
+
* to load.
|
|
18
|
+
*
|
|
19
|
+
* A failed load is forgotten and resolves: an icon the bundle would have
|
|
20
|
+
* held is then fetched from the icon API, as one outside the bundle always is.
|
|
21
|
+
*/
|
|
22
|
+
export function loadIconBundle(): Promise<void> {
|
|
23
|
+
if (import.meta.env.SSR) return Promise.resolve();
|
|
24
|
+
loading ??= import("virtual:nuxt-ui-icons").then(
|
|
25
|
+
({ init }) => init(register),
|
|
26
|
+
() => {
|
|
27
|
+
loading = undefined;
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
return loading;
|
|
31
|
+
}
|
|
@@ -19,6 +19,10 @@ const SOURCE_SCRIPT_GLOB = "app/**/*.ts";
|
|
|
19
19
|
* fetched from the Iconify API at run time. Every frontend source root lives
|
|
20
20
|
* under the workspace (`frontend-modules/`, enforced when the registry is
|
|
21
21
|
* written), so a relative glob reaches it. Globs are POSIX on every platform.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} viteRoot
|
|
24
|
+
* @param {readonly string[]} sourceRoots
|
|
25
|
+
* @returns {string[]}
|
|
22
26
|
*/
|
|
23
27
|
export function iconScanGlobs(viteRoot, sourceRoots) {
|
|
24
28
|
const sourceGlobs = sourceRoots.map((root) =>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** The `<script>` the server render leaves in the document for {@link DmsMessageSubset}. */
|
|
2
|
+
export const SSR_MESSAGES_ID = "dms-ssr-messages";
|
|
3
|
+
|
|
4
|
+
type Messages = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The messages a server render read from a locale's catalog, as a catalog of
|
|
8
|
+
* their own, with the locale they belong to.
|
|
9
|
+
*/
|
|
10
|
+
export interface DmsMessageSubset {
|
|
11
|
+
locale: string;
|
|
12
|
+
messages: Messages;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MessageReadRecorder {
|
|
16
|
+
/** The catalog to give the render: every read through it is recorded. */
|
|
17
|
+
messages: Messages;
|
|
18
|
+
/** What the render read so far. */
|
|
19
|
+
subset: () => Messages;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isTraversable(value: unknown): value is object {
|
|
23
|
+
return typeof value === "object" && value !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMessages(value: unknown): value is Messages {
|
|
27
|
+
return isTraversable(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Slot = () => Messages;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wraps a catalog so that the messages read through it are collected, with
|
|
34
|
+
* the path they sit at, into a catalog of their own.
|
|
35
|
+
*
|
|
36
|
+
* A message is recorded when a string is read from the catalog, whether by a
|
|
37
|
+
* lookup (`t`), an existence check (`te`) or a walk over a subtree (`tm`).
|
|
38
|
+
*/
|
|
39
|
+
export function recordMessageReads(messages: Messages): MessageReadRecorder {
|
|
40
|
+
const used: Messages = {};
|
|
41
|
+
const place = (source: object, parent: Slot, key: string): Slot => {
|
|
42
|
+
return () => {
|
|
43
|
+
const holder = parent();
|
|
44
|
+
const existing = holder[key];
|
|
45
|
+
if (isMessages(existing)) return existing;
|
|
46
|
+
const container: Messages = Array.isArray(source) ? [] : {};
|
|
47
|
+
holder[key] = container;
|
|
48
|
+
return container;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const wrap = <T extends object>(source: T, slot: Slot): T =>
|
|
52
|
+
new Proxy(source, {
|
|
53
|
+
get(target, key, receiver) {
|
|
54
|
+
const value: unknown = Reflect.get(target, key, receiver);
|
|
55
|
+
if (typeof key !== "string" || key === "__proto__") return value;
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
slot()[key] = value;
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
if (isTraversable(value)) return wrap(value, place(value, slot, key));
|
|
61
|
+
return value;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return { messages: wrap(messages, () => used), subset: () => used };
|
|
65
|
+
}
|
|
@@ -15,6 +15,10 @@ export interface DmsNetworkErrorTranslator {
|
|
|
15
15
|
|
|
16
16
|
const NETWORK_ERROR_TOAST_ID = "dms-network-error";
|
|
17
17
|
const NETWORK_ERROR_KEY = "error.network";
|
|
18
|
+
/** The messages a module overrides the built-in texts with. */
|
|
19
|
+
export const NETWORK_ERROR_KEYS = ["title", "description"].map(
|
|
20
|
+
(name) => `${NETWORK_ERROR_KEY}.${name}`,
|
|
21
|
+
);
|
|
18
22
|
const DEFAULT_LOCALE = "en";
|
|
19
23
|
// Shipped with the renderer, since no module is guaranteed to translate the
|
|
20
24
|
// case; a module overrides them with `error.network.title` and
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { REDIRECT_STATUSES } from "../proxy-redirects.mjs";
|
|
2
|
+
import { isString } from "../type-guards.mjs";
|
|
2
3
|
import { clientCountryHeader, clientIp, clientIpHeader } from "./client-ip.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -14,6 +15,10 @@ export const CLIENT_HEADERS = [
|
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
export class UpstreamError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {number} status
|
|
20
|
+
* @param {string} message
|
|
21
|
+
*/
|
|
17
22
|
constructor(status, message) {
|
|
18
23
|
super(message);
|
|
19
24
|
this.status = status;
|
|
@@ -27,9 +32,15 @@ const PUBLIC_AUTH_MESSAGE = /^error\.[a-z0-9_.-]{1,100}$/;
|
|
|
27
32
|
*/
|
|
28
33
|
export const GENERIC_ERROR_MESSAGE = "error.500.description";
|
|
29
34
|
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} data A backend answer: any JSON value, or the text of one
|
|
37
|
+
* that is not JSON
|
|
38
|
+
* @returns {string | undefined}
|
|
39
|
+
*/
|
|
30
40
|
export function publicBackendMessage(data) {
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
/** @type {unknown} */
|
|
42
|
+
const message = isString(data) ? data : data?.message;
|
|
43
|
+
return isString(message) && PUBLIC_AUTH_MESSAGE.test(message)
|
|
33
44
|
? message
|
|
34
45
|
: undefined;
|
|
35
46
|
}
|
|
@@ -37,8 +48,10 @@ export function publicBackendMessage(data) {
|
|
|
37
48
|
/**
|
|
38
49
|
* The headers of a client request the server relays to the backend.
|
|
39
50
|
*
|
|
40
|
-
* @param request The client
|
|
41
|
-
*
|
|
51
|
+
* @param {import("./client-ip.mjs").ForwardedRequest} request The client
|
|
52
|
+
* request
|
|
53
|
+
* @param {readonly string[]} names Lowercase names of the request headers to
|
|
54
|
+
* copy when present
|
|
42
55
|
* @returns The client country header, then the copied headers
|
|
43
56
|
*/
|
|
44
57
|
export function relayedHeaders(request, names) {
|
|
@@ -58,8 +71,22 @@ export function relayedHeaders(request, names) {
|
|
|
58
71
|
* `options.headers` adds request headers; the client's address and the
|
|
59
72
|
* relayed client headers are set after them, so no caller replaces those.
|
|
60
73
|
* A redirect is not followed: it is no JSON answer, so it fails with a 502.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} path
|
|
76
|
+
* @param {import("./client-ip.mjs").ForwardedRequest} request The client
|
|
77
|
+
* request
|
|
78
|
+
* @param {{
|
|
79
|
+
* method?: string,
|
|
80
|
+
* body?: unknown,
|
|
81
|
+
* token?: string,
|
|
82
|
+
* relay?: boolean,
|
|
83
|
+
* headers?: Record<string, string>
|
|
84
|
+
* }} [options]
|
|
85
|
+
* @returns {Promise<unknown>} The answer's JSON, `{}` for a successful one that
|
|
86
|
+
* is not JSON
|
|
61
87
|
*/
|
|
62
88
|
export async function backend(path, request, options = {}) {
|
|
89
|
+
/** @type {Record<string, string | string[]>} */
|
|
63
90
|
const headers = Object.fromEntries(
|
|
64
91
|
Object.entries(options.headers ?? {}).map(([name, value]) => [
|
|
65
92
|
name.toLowerCase(),
|
|
@@ -87,6 +114,7 @@ export async function backend(path, request, options = {}) {
|
|
|
87
114
|
throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
|
|
88
115
|
}
|
|
89
116
|
const text = await response.text();
|
|
117
|
+
/** @type {unknown} */
|
|
90
118
|
let data;
|
|
91
119
|
try {
|
|
92
120
|
data = JSON.parse(text);
|
|
@@ -105,12 +133,21 @@ export async function backend(path, request, options = {}) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
export class RequestBodyError extends Error {
|
|
136
|
+
/**
|
|
137
|
+
* @param {number} status
|
|
138
|
+
* @param {string} message
|
|
139
|
+
*/
|
|
108
140
|
constructor(status, message) {
|
|
109
141
|
super(message);
|
|
110
142
|
this.status = status;
|
|
111
143
|
}
|
|
112
144
|
}
|
|
113
145
|
|
|
146
|
+
/**
|
|
147
|
+
* @param {import("node:http").ServerResponse} response
|
|
148
|
+
* @param {number} status
|
|
149
|
+
* @param {unknown} value Anything `JSON.stringify` writes
|
|
150
|
+
*/
|
|
114
151
|
export function json(response, status, value) {
|
|
115
152
|
response.writeHead(status, { "content-type": "application/json" });
|
|
116
153
|
response.end(JSON.stringify(value));
|
|
@@ -1,5 +1,21 @@
|
|
|
1
|
+
import { isString } from "../type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A request, whose socket is a TLS one (`encrypted`) when the server itself
|
|
7
|
+
* terminates TLS.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {import("node:http").IncomingMessage & {
|
|
10
|
+
* socket: import("node:net").Socket & { encrypted?: boolean }
|
|
11
|
+
* }} ForwardedRequest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string | undefined} value A header name from the environment
|
|
16
|
+
* @returns {string | undefined} The lowercase name, `undefined` when it is not
|
|
17
|
+
* a header name
|
|
18
|
+
*/
|
|
3
19
|
function headerName(value) {
|
|
4
20
|
const name = (value ?? "").trim().toLowerCase();
|
|
5
21
|
return HEADER_NAME.test(name) ? name : undefined;
|
|
@@ -17,18 +33,26 @@ function trustedHops() {
|
|
|
17
33
|
* so its presence also vouches for the platform's `X-Forwarded-*`. On a path
|
|
18
34
|
* the platform does not front (a private call from another service), it is
|
|
19
35
|
* absent and the request falls back to `DMS_TRUSTED_PROXY_HOPS`.
|
|
36
|
+
*
|
|
37
|
+
* @param {ForwardedRequest} request
|
|
38
|
+
* @returns {string | undefined}
|
|
20
39
|
*/
|
|
21
40
|
function platformClientIp(request) {
|
|
22
41
|
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
23
42
|
if (!name) return undefined;
|
|
24
43
|
const value = request.headers?.[name];
|
|
25
|
-
return
|
|
44
|
+
return isString(value) && value.trim() ? value.trim() : undefined;
|
|
26
45
|
}
|
|
27
46
|
|
|
47
|
+
/** @param {ForwardedRequest} request */
|
|
28
48
|
function isProxyTrusted(request) {
|
|
29
49
|
return Boolean(platformClientIp(request)) || trustedHops() > 0;
|
|
30
50
|
}
|
|
31
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {ForwardedRequest} request
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
32
56
|
export function requestScheme(request) {
|
|
33
57
|
if (isProxyTrusted(request)) {
|
|
34
58
|
const forwarded = String(request.headers["x-forwarded-proto"] ?? "")
|
|
@@ -41,6 +65,10 @@ export function requestScheme(request) {
|
|
|
41
65
|
return request.socket?.encrypted ? "https" : "http";
|
|
42
66
|
}
|
|
43
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {ForwardedRequest} request
|
|
70
|
+
* @returns {string | undefined}
|
|
71
|
+
*/
|
|
44
72
|
function requestHost(request) {
|
|
45
73
|
if (!isProxyTrusted(request)) return request.headers.host;
|
|
46
74
|
return (
|
|
@@ -62,8 +90,12 @@ function requestHost(request) {
|
|
|
62
90
|
* proxy wrote it (`DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller
|
|
63
91
|
* could have, and the socket's address replaces it. The scheme and the host
|
|
64
92
|
* are read the same way as the CSRF origin check reads them.
|
|
93
|
+
*
|
|
94
|
+
* @param {ForwardedRequest} request
|
|
95
|
+
* @returns {Record<string, string>}
|
|
65
96
|
*/
|
|
66
97
|
export function forwardedHeaders(request) {
|
|
98
|
+
/** @type {Record<string, string>} */
|
|
67
99
|
const headers = {
|
|
68
100
|
"x-forwarded-for": forwardedFor(request),
|
|
69
101
|
"x-forwarded-proto": requestScheme(request),
|
|
@@ -74,6 +106,10 @@ export function forwardedHeaders(request) {
|
|
|
74
106
|
return headers;
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {ForwardedRequest} request
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
77
113
|
function forwardedFor(request) {
|
|
78
114
|
const platformIp = platformClientIp(request);
|
|
79
115
|
if (platformIp) return platformIp;
|
|
@@ -92,12 +128,19 @@ function forwardedFor(request) {
|
|
|
92
128
|
* address this server resolved, so the backend reads the same address
|
|
93
129
|
* without counting hops. Always written, never relayed: a value the caller
|
|
94
130
|
* sent is replaced. None when the variable is unset.
|
|
131
|
+
*
|
|
132
|
+
* @param {ForwardedRequest} request
|
|
133
|
+
* @returns {Record<string, string>}
|
|
95
134
|
*/
|
|
96
135
|
export function clientIpHeader(request) {
|
|
97
136
|
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
98
137
|
return name ? { [name]: clientIp(request) } : {};
|
|
99
138
|
}
|
|
100
139
|
|
|
140
|
+
/**
|
|
141
|
+
* @param {ForwardedRequest} request
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
101
144
|
export function isSameOrigin(request) {
|
|
102
145
|
const origin = request.headers.origin;
|
|
103
146
|
if (!origin) return false;
|
|
@@ -111,6 +154,10 @@ export function isSameOrigin(request) {
|
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
|
|
157
|
+
/**
|
|
158
|
+
* @param {ForwardedRequest} request
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
114
161
|
export function clientIp(request) {
|
|
115
162
|
const platformIp = platformClientIp(request);
|
|
116
163
|
if (platformIp) return platformIp;
|
|
@@ -136,6 +183,9 @@ const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
|
|
|
136
183
|
* from a trusted reverse proxy (the platform's client IP header, or
|
|
137
184
|
* `DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller could have set
|
|
138
185
|
* it, so nothing is relayed.
|
|
186
|
+
*
|
|
187
|
+
* @param {ForwardedRequest} request
|
|
188
|
+
* @returns {Record<string, string>}
|
|
139
189
|
*/
|
|
140
190
|
export function clientCountryHeader(request) {
|
|
141
191
|
const name = headerName(
|
|
@@ -143,9 +193,7 @@ export function clientCountryHeader(request) {
|
|
|
143
193
|
);
|
|
144
194
|
if (!name || !isProxyTrusted(request)) return {};
|
|
145
195
|
const value = request.headers?.[name];
|
|
146
|
-
return
|
|
147
|
-
? { [name]: value.trim() }
|
|
148
|
-
: {};
|
|
196
|
+
return isString(value) && value.trim() ? { [name]: value.trim() } : {};
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isObjectLike, isString } from "../type-guards.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Absolute backend API paths only: no scheme, no authority, no query string,
|
|
@@ -17,6 +18,7 @@ const DECLARED_ENDPOINTS_FILE = new URL(
|
|
|
17
18
|
import.meta.url,
|
|
18
19
|
);
|
|
19
20
|
|
|
21
|
+
/** @type {unknown[] | undefined} */
|
|
20
22
|
let declaredEndpoints;
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -28,13 +30,15 @@ let declaredEndpoints;
|
|
|
28
30
|
* workspace built from a DMS that predates the declaration simply has no file,
|
|
29
31
|
* and the list falls back to whatever the environment names.
|
|
30
32
|
*
|
|
31
|
-
* @param file Generated file to read
|
|
32
|
-
* @returns The declared paths, unfiltered
|
|
33
|
+
* @param {URL | string} [file] Generated file to read
|
|
34
|
+
* @returns {unknown[]} The declared paths, unfiltered
|
|
33
35
|
*/
|
|
34
36
|
export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
35
37
|
try {
|
|
38
|
+
/** @type {unknown} */
|
|
36
39
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
-
|
|
40
|
+
const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
|
|
41
|
+
return Array.isArray(endpoints) ? endpoints : [];
|
|
38
42
|
} catch {
|
|
39
43
|
return [];
|
|
40
44
|
}
|
|
@@ -54,8 +58,8 @@ export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
|
54
58
|
* (`DMS_AUTH_ESTABLISH_ENDPOINTS`). The module that owns sign-in reads them as
|
|
55
59
|
* `event.app.authEstablishEndpoints`.
|
|
56
60
|
*
|
|
57
|
-
* @param declaration Comma-separated absolute backend paths from the environment
|
|
58
|
-
* @param declared Paths declared by the backend's frontend modules
|
|
61
|
+
* @param {string} [declaration] Comma-separated absolute backend paths from the environment
|
|
62
|
+
* @param {unknown[]} [declared] Paths declared by the backend's frontend modules
|
|
59
63
|
* @returns The well-formed backend API paths, without duplicates
|
|
60
64
|
*/
|
|
61
65
|
export function allowedEstablishEndpoints(
|
|
@@ -68,7 +72,7 @@ export function allowedEstablishEndpoints(
|
|
|
68
72
|
return [
|
|
69
73
|
...new Set(
|
|
70
74
|
[...declared, ...fromEnvironment].filter(
|
|
71
|
-
(entry) =>
|
|
75
|
+
(entry) => isString(entry) && BACKEND_API_PATH.test(entry),
|
|
72
76
|
),
|
|
73
77
|
),
|
|
74
78
|
];
|