@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.
Files changed (123) hide show
  1. package/README.md +18 -24
  2. package/dist/commands/build-action.js +3 -2
  3. package/dist/commands/dev-action.js +2 -1
  4. package/dist/commands/prepare-action.js +1 -1
  5. package/dist/commands/start-action.js +3 -24
  6. package/dist/commands/workspace-task.js +2 -1
  7. package/dist/config.js +3 -0
  8. package/dist/derived-outputs.js +3 -3
  9. package/dist/discovery.js +17 -6
  10. package/dist/entry-weight.js +15 -0
  11. package/dist/fs-sync.js +7 -4
  12. package/dist/index.js +2 -1
  13. package/dist/internal/built-workspace.d.ts +4 -0
  14. package/dist/internal/built-workspace.js +43 -0
  15. package/dist/internal/ensure.d.ts +1 -0
  16. package/dist/internal/ensure.js +8 -0
  17. package/dist/internal/locale-catalogs.d.ts +8 -3
  18. package/dist/internal/locale-catalogs.js +16 -6
  19. package/dist/internal/manifest-decoder.d.ts +7 -0
  20. package/dist/internal/manifest-decoder.js +81 -0
  21. package/dist/internal/own-release.d.ts +5 -0
  22. package/dist/internal/own-release.js +20 -0
  23. package/dist/internal/plain-object.d.ts +1 -0
  24. package/dist/internal/plain-object.js +6 -0
  25. package/dist/internal/text-list.d.ts +1 -0
  26. package/dist/internal/text-list.js +10 -0
  27. package/dist/internal/vite-manifest.d.ts +6 -0
  28. package/dist/internal/vite-manifest.js +24 -0
  29. package/dist/layer-watch.js +25 -24
  30. package/dist/layers.d.ts +1 -1
  31. package/dist/layers.js +3 -1
  32. package/dist/locale-files.d.ts +2 -1
  33. package/dist/locale-files.js +4 -0
  34. package/dist/manifest.d.ts +1 -1
  35. package/dist/manifest.js +44 -31
  36. package/dist/materialize.js +6 -2
  37. package/dist/module-set.js +2 -1
  38. package/dist/node-options.d.ts +1 -0
  39. package/dist/node-options.js +8 -0
  40. package/dist/output.d.ts +15 -9
  41. package/dist/output.js +30 -18
  42. package/dist/private-values.d.ts +1 -1
  43. package/dist/private-values.js +5 -3
  44. package/dist/project-modules.js +13 -8
  45. package/dist/renderer-range.js +5 -3
  46. package/dist/server-process.js +1 -1
  47. package/dist/verify-source-build.js +10 -2
  48. package/dist/verify-source-client-bundle.d.ts +1 -0
  49. package/dist/verify-source-client-bundle.js +30 -0
  50. package/dist/verify-source-runner.js +44 -60
  51. package/dist/verify-source-sources.js +49 -11
  52. package/dist/workspace-setup.js +1 -1
  53. package/package.json +6 -4
  54. package/skills/dms-frontend/SKILL.md +1 -1
  55. package/templates/vue/app-runtime.ts +90 -16
  56. package/templates/vue/client-server-guard.mjs +16 -0
  57. package/templates/vue/compress-assets.mjs +9 -1
  58. package/templates/vue/entry-weight.mjs +22 -0
  59. package/templates/vue/frontend-module.ts +1 -0
  60. package/templates/vue/frontend-runtime/module-registration.ts +2 -2
  61. package/templates/vue/frontend-runtime/state.ts +2 -2
  62. package/templates/vue/frontend-runtime/types.ts +8 -2
  63. package/templates/vue/globals.d.ts +5 -0
  64. package/templates/vue/head-order.mjs +7 -0
  65. package/templates/vue/icon-bundle.ts +31 -0
  66. package/templates/vue/icon-scan.mjs +4 -0
  67. package/templates/vue/message-subset.ts +65 -0
  68. package/templates/vue/network-error.ts +4 -0
  69. package/templates/vue/server/auth/backend.mjs +41 -4
  70. package/templates/vue/server/auth/client-ip.mjs +52 -4
  71. package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
  72. package/templates/vue/server/auth/session.mjs +81 -2
  73. package/templates/vue/server/auth-hooks.mjs +15 -5
  74. package/templates/vue/server/backend-proxies.mjs +65 -9
  75. package/templates/vue/server/backend-request.mjs +61 -0
  76. package/templates/vue/server/backend-response.mjs +23 -6
  77. package/templates/vue/server/built-files.mjs +7 -0
  78. package/templates/vue/server/cli-report.mjs +53 -11
  79. package/templates/vue/server/client-manifest.mjs +23 -1
  80. package/templates/vue/server/content.mjs +18 -1
  81. package/templates/vue/server/dev-private-files.mjs +17 -1
  82. package/templates/vue/server/dev-styles.mjs +38 -0
  83. package/templates/vue/server/email.mjs +9 -3
  84. package/templates/vue/server/favicon.mjs +3 -0
  85. package/templates/vue/server/generated-files.mjs +211 -0
  86. package/templates/vue/server/homepage.mjs +21 -9
  87. package/templates/vue/server/html-tag.mjs +4 -0
  88. package/templates/vue/server/icons.mjs +80 -7
  89. package/templates/vue/server/in-place-render.mjs +35 -0
  90. package/templates/vue/server/inertia.mjs +61 -2
  91. package/templates/vue/server/module-event.mjs +98 -17
  92. package/templates/vue/server/module-route-types.mjs +78 -0
  93. package/templates/vue/server/module-routes.mjs +138 -31
  94. package/templates/vue/server/module-server.mjs +98 -27
  95. package/templates/vue/server/module-testing.mjs +14 -5
  96. package/templates/vue/server/page-props.mjs +92 -2
  97. package/templates/vue/server/page-redirects.mjs +37 -0
  98. package/templates/vue/server/path-patterns.mjs +13 -1
  99. package/templates/vue/server/proxy-compression.mjs +22 -0
  100. package/templates/vue/server/proxy-headers.mjs +11 -1
  101. package/templates/vue/server/proxy-redirects.mjs +20 -5
  102. package/templates/vue/server/proxy-request.mjs +57 -0
  103. package/templates/vue/server/proxy-transfers.mjs +33 -5
  104. package/templates/vue/server/public-endpoints.mjs +138 -23
  105. package/templates/vue/server/public-options.mjs +20 -6
  106. package/templates/vue/server/request-failure.mjs +23 -0
  107. package/templates/vue/server/request-routing.mjs +26 -9
  108. package/templates/vue/server/request-target.mjs +9 -3
  109. package/templates/vue/server/route-rules.mjs +39 -15
  110. package/templates/vue/server/ssr-module.mjs +32 -0
  111. package/templates/vue/server/static-files.mjs +24 -1
  112. package/templates/vue/server/type-guards.mjs +71 -0
  113. package/templates/vue/server/vite-errors.mjs +32 -5
  114. package/templates/vue/server/web-body.mjs +3 -0
  115. package/templates/vue/server-module.ts +10 -1
  116. package/templates/vue/server.mjs +132 -148
  117. package/templates/vue/sfc-global-types.mjs +112 -13
  118. package/templates/vue/ssr-renderer.ts +12 -3
  119. package/templates/vue/ui-inertia-page.mjs +16 -1
  120. package/templates/vue/ui-kbd.mjs +2 -1
  121. package/templates/vue/ui-lazy-icons.mjs +44 -0
  122. package/templates/vue/ui-request-state.mjs +2 -1
  123. 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;
@@ -34,6 +34,7 @@ export type {
34
34
  DmsFetchTransformContext,
35
35
  UseAsyncDataOptions,
36
36
  UseFetchOptions,
37
+ DmsServerFetch,
37
38
  DmsErrorData,
38
39
  DmsPayload,
39
40
  DmsStateOptions,
@@ -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?: typeof ofetch;
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?: typeof ofetch,
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, ofetch } from "ofetch";
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?: typeof ofetch;
280
+ $fetch?: DmsServerFetch;
275
281
  }
276
282
  export interface DmsErrorData {
277
283
  statusCode?: number;
@@ -1 +1,6 @@
1
1
  declare const defineDmsAppConfig: typeof import("./frontend-module").defineDmsAppConfig;
2
+ declare module "virtual:nuxt-ui-icons" {
3
+ export function init(
4
+ add: (name: string, data: import("@iconify/vue").IconifyIcon) => boolean,
5
+ ): void;
6
+ }
@@ -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
- const message = typeof data === "string" ? data : data?.message;
32
- return typeof message === "string" && PUBLIC_AUTH_MESSAGE.test(message)
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 request
41
- * @param names Lowercase names of the request headers to copy when present
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 typeof value === "string" && value.trim() ? value.trim() : undefined;
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 typeof value === "string" && value.trim()
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
- return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
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) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
75
+ (entry) => isString(entry) && BACKEND_API_PATH.test(entry),
72
76
  ),
73
77
  ),
74
78
  ];