@antelopejs/dms-frontend 0.10.0 → 0.10.1

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 (74) hide show
  1. package/README.md +34 -11
  2. package/dist/commands/build-action.d.ts +3 -0
  3. package/dist/commands/build-action.js +74 -6
  4. package/dist/commands/build.js +7 -0
  5. package/dist/commands/clean-action.js +1 -1
  6. package/dist/commands/clean.js +1 -1
  7. package/dist/commands/dev-action.js +2 -2
  8. package/dist/commands/dev.js +1 -1
  9. package/dist/config.js +1 -0
  10. package/dist/derived-outputs.d.ts +1 -0
  11. package/dist/derived-outputs.js +3 -1
  12. package/dist/help.js +1 -1
  13. package/dist/internal/prepared-workspace.d.ts +12 -0
  14. package/dist/internal/prepared-workspace.js +74 -0
  15. package/dist/layer-watch.js +1 -1
  16. package/dist/locale-files.d.ts +4 -2
  17. package/dist/locale-files.js +11 -9
  18. package/dist/manifest.js +2 -1
  19. package/dist/ports.d.ts +3 -1
  20. package/dist/ports.js +1 -1
  21. package/dist/project-modules.d.ts +5 -1
  22. package/dist/project-modules.js +10 -7
  23. package/dist/verify-source-runner.js +2 -44
  24. package/dist/verify-source-workspace.d.ts +2 -0
  25. package/dist/verify-source-workspace.js +111 -0
  26. package/dist/workspace-setup.d.ts +1 -0
  27. package/dist/workspace-setup.js +3 -2
  28. package/package.json +7 -5
  29. package/templates/vue/backend-boundary.mjs +132 -0
  30. package/templates/vue/client-chunking.mjs +2 -0
  31. package/templates/vue/entry-weight.mjs +9 -5
  32. package/templates/vue/frontend-build.ts +4 -3
  33. package/templates/vue/head-order.mjs +3 -2
  34. package/templates/vue/icon-api.mjs +2 -0
  35. package/templates/vue/server/auth/backend.mjs +4 -4
  36. package/templates/vue/server/auth/establish-endpoints.mjs +2 -2
  37. package/templates/vue/server/auth/session.mjs +5 -2
  38. package/templates/vue/server/auth-declarations.mjs +46 -0
  39. package/templates/vue/server/auth-hooks.mjs +41 -3
  40. package/templates/vue/server/backend-proxies.mjs +16 -12
  41. package/templates/vue/server/backend-response.mjs +3 -3
  42. package/templates/vue/server/cli-report.mjs +22 -1
  43. package/templates/vue/server/client-error.mjs +5 -0
  44. package/templates/vue/server/content.mjs +24 -11
  45. package/templates/vue/server/email-bundle-watch.mjs +9 -3
  46. package/templates/vue/server/email.mjs +5 -3
  47. package/templates/vue/server/generated-files.mjs +16 -6
  48. package/templates/vue/server/icons.mjs +15 -11
  49. package/templates/vue/server/in-place-render.mjs +3 -3
  50. package/templates/vue/server/inertia.mjs +17 -3
  51. package/templates/vue/server/module-event.mjs +28 -16
  52. package/templates/vue/server/module-route-types.mjs +2 -2
  53. package/templates/vue/server/module-routes.mjs +24 -76
  54. package/templates/vue/server/module-server.mjs +27 -17
  55. package/templates/vue/server/module-testing.mjs +23 -8
  56. package/templates/vue/server/page-props.mjs +6 -5
  57. package/templates/vue/server/page-redirects.mjs +1 -1
  58. package/templates/vue/server/path-patterns.mjs +22 -11
  59. package/templates/vue/server/proxy-headers.mjs +1 -1
  60. package/templates/vue/server/proxy-redirects.mjs +1 -0
  61. package/templates/vue/server/proxy-request.mjs +2 -1
  62. package/templates/vue/server/proxy-transfers.mjs +17 -11
  63. package/templates/vue/server/public-endpoints.mjs +12 -16
  64. package/templates/vue/server/request-failure.mjs +1 -1
  65. package/templates/vue/server/request-routing.mjs +6 -1
  66. package/templates/vue/server/request-target.mjs +22 -9
  67. package/templates/vue/server/route-rules.mjs +12 -6
  68. package/templates/vue/server/static-files.mjs +12 -7
  69. package/templates/vue/server/vite-errors.mjs +13 -2
  70. package/templates/vue/server/web-body.mjs +2 -1
  71. package/templates/vue/server-module.ts +2 -1
  72. package/templates/vue/server.mjs +41 -20
  73. package/templates/vue/ui-inertia-page.mjs +1 -1
  74. package/templates/vue/vite.config.ts +9 -0
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.materializeWorkspace = materializeWorkspace;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const yaml_1 = require("yaml");
7
+ const common_1 = require("./common");
8
+ const ensure_1 = require("./internal/ensure");
9
+ const guards_1 = require("./internal/guards");
10
+ const plain_object_1 = require("./internal/plain-object");
11
+ const output_1 = require("./output");
12
+ function removeDevelopmentDependencies(root) {
13
+ const packagePath = (0, node_path_1.join)(root, "package.json");
14
+ if (!(0, node_fs_1.existsSync)(packagePath))
15
+ return;
16
+ const packageData = JSON.parse((0, node_fs_1.readFileSync)(packagePath, "utf8"));
17
+ if (!(0, plain_object_1.isPlainObject)(packageData))
18
+ return;
19
+ delete packageData.devDependencies;
20
+ (0, node_fs_1.writeFileSync)(packagePath, `${JSON.stringify(packageData, null, 2)}\n`);
21
+ }
22
+ const PATH_PROTOCOLS = ["file:", "link:"];
23
+ const WORKSPACE_PROTOCOL = "workspace:";
24
+ const LOCKFILE = "pnpm-lock.yaml";
25
+ const WORKSPACE_FILE = "pnpm-workspace.yaml";
26
+ const OWN_LOCKFILES = /^\s*shared-workspace-lockfile\s*=\s*false\s*$/m;
27
+ function findUp(directory, file) {
28
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(directory, file)))
29
+ return directory;
30
+ const parent = (0, node_path_1.dirname)(directory);
31
+ return parent === directory ? undefined : findUp(parent, file);
32
+ }
33
+ function sharesLockfile(root) {
34
+ const npmrc = (0, node_path_1.join)(root, ".npmrc");
35
+ if ((0, node_fs_1.existsSync)(npmrc) && OWN_LOCKFILES.test((0, node_fs_1.readFileSync)(npmrc, "utf8")))
36
+ return false;
37
+ const settings = (0, yaml_1.parse)((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, WORKSPACE_FILE), "utf8"));
38
+ return !((0, plain_object_1.isPlainObject)(settings) && settings.sharedWorkspaceLockfile === false);
39
+ }
40
+ function findLockfile(directory) {
41
+ const workspaceRoot = findUp(directory, WORKSPACE_FILE);
42
+ const lockfileRoot = workspaceRoot && sharesLockfile(workspaceRoot)
43
+ ? workspaceRoot
44
+ : findUp(directory, LOCKFILE);
45
+ if (!lockfileRoot)
46
+ return undefined;
47
+ const lockfile = (0, node_path_1.join)(lockfileRoot, LOCKFILE);
48
+ const outsideWorkspace = workspaceRoot !== undefined &&
49
+ (0, node_path_1.relative)(workspaceRoot, lockfileRoot).startsWith("..");
50
+ return (0, node_fs_1.existsSync)(lockfile) && !outsideWorkspace ? lockfile : undefined;
51
+ }
52
+ function readLayerOverrides(layer) {
53
+ const lockfile = findLockfile(layer.path);
54
+ if (!lockfile)
55
+ return [];
56
+ const root = (0, node_path_1.dirname)(lockfile);
57
+ const overrides = [];
58
+ for (const document of (0, yaml_1.parseAllDocuments)((0, node_fs_1.readFileSync)(lockfile, "utf8"))) {
59
+ const [error] = document.errors;
60
+ (0, ensure_1.ensure)(error === undefined, `${(0, output_1.showPath)(lockfile)} is not a pnpm lockfile`, error?.message);
61
+ const lock = document.toJS();
62
+ if (!(0, plain_object_1.isPlainObject)(lock) || !(0, plain_object_1.isPlainObject)(lock.overrides))
63
+ continue;
64
+ for (const [name, spec] of Object.entries(lock.overrides)) {
65
+ if (!(0, guards_1.isText)(spec) || spec.startsWith(WORKSPACE_PROTOCOL))
66
+ continue;
67
+ overrides.push([name, resolveSpec(root, spec)]);
68
+ }
69
+ }
70
+ return overrides;
71
+ }
72
+ function resolveSpec(root, spec) {
73
+ const protocol = PATH_PROTOCOLS.find((prefix) => spec.startsWith(prefix));
74
+ if (!protocol)
75
+ return spec;
76
+ const path = spec.slice(protocol.length);
77
+ return (0, node_path_1.isAbsolute)(path) ? spec : `${protocol}${(0, node_path_1.resolve)(root, path)}`;
78
+ }
79
+ function materializeWorkspace(workspace, layers, localPackages) {
80
+ const templateRoot = (0, node_path_1.join)((0, common_1.getPackageRoot)(), "templates", "vue");
81
+ const templateFiles = (0, node_fs_1.readdirSync)(templateRoot).filter((file) => !file.startsWith("npmrc"));
82
+ for (const file of templateFiles)
83
+ (0, node_fs_1.cpSync)((0, node_path_1.join)(templateRoot, file), (0, node_path_1.join)(workspace, file), {
84
+ recursive: true,
85
+ });
86
+ (0, common_1.materializeLayers)(workspace, layers);
87
+ const registry = (0, common_1.createFrontendModuleRegistry)(workspace, layers);
88
+ registry.modules.forEach((module) => {
89
+ removeDevelopmentDependencies(module.root);
90
+ });
91
+ (0, common_1.writeFrontendModuleRegistry)(workspace, layers);
92
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspace, "dms-main.css"), '@import "tailwindcss";\n@import "@nuxt/ui";\n');
93
+ (0, common_1.writeWorkspacePackageJson)(workspace, layers);
94
+ const packagePath = (0, node_path_1.join)(workspace, "package.json");
95
+ const workspacePackage = JSON.parse((0, node_fs_1.readFileSync)(packagePath, "utf8"));
96
+ (0, ensure_1.ensure)((0, plain_object_1.isPlainObject)(workspacePackage), "The workspace package.json is not a JSON object");
97
+ workspacePackage.pnpm = {
98
+ overrides: {
99
+ ...Object.fromEntries(layers.flatMap(readLayerOverrides)),
100
+ ...Object.fromEntries(layers.map((layer) => [
101
+ String(layer.packageName),
102
+ "workspace:*",
103
+ ])),
104
+ ...Object.fromEntries(Object.entries(localPackages).map(([name, path]) => [
105
+ name,
106
+ `link:${(0, node_path_1.resolve)(path)}`,
107
+ ])),
108
+ },
109
+ };
110
+ (0, node_fs_1.writeFileSync)(packagePath, `${JSON.stringify(workspacePackage, null, 2)}\n`);
111
+ }
@@ -31,6 +31,7 @@ export interface SetupWorkspaceOptions {
31
31
  workspaceDir?: string;
32
32
  project?: ProjectModulesOptions;
33
33
  beforeInstall?: () => Promise<void>;
34
+ install?: boolean;
34
35
  }
35
36
  export interface SetupWorkspaceResult {
36
37
  workspaceDir: string;
@@ -299,7 +299,8 @@ async function setupWorkspace(opts) {
299
299
  writeDerivedOutputs(workspaceDir, layers);
300
300
  (0, materialize_1.writeDmsMainCss)(workspaceDir, layers);
301
301
  const layerPaths = layers.map((layer) => layer.path);
302
- const shouldInstall = needsInstall(workspaceDir, layerPaths, opts.force);
302
+ const shouldInstall = opts.install !== false &&
303
+ needsInstall(workspaceDir, layerPaths, opts.force);
303
304
  if (shouldInstall) {
304
305
  await opts.beforeInstall?.();
305
306
  await installDeps(workspaceDir, (0, child_output_1.createPathMapper)(workspaceDir, layers));
@@ -321,6 +322,6 @@ function writeDerivedOutputs(workspaceDir, layers) {
321
322
  if (!(error instanceof locale_files_1.InvalidLocaleFileError))
322
323
  throw error;
323
324
  const showSource = (0, child_output_1.createPathMapper)(workspaceDir, layers);
324
- throw new locale_files_1.InvalidLocaleFileError(showSource(error.path), error.reason);
325
+ throw new locale_files_1.InvalidLocaleFileError(showSource(error.path), error.reason, error.details);
325
326
  }
326
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -66,7 +66,7 @@
66
66
  "test:real-source": "pnpm run build && node dist/verify-source-runner.js",
67
67
  "format": "oxfmt .",
68
68
  "lint": "oxlint && antelopejs-check-interface-ranges && pnpm run docs:reference:check && pnpm run source-checks",
69
- "typecheck": "tsc --noEmit",
69
+ "typecheck": "tsc --noEmit && tsx scripts/typecheck-projects.ts tsconfig.templates.json tsconfig.tests.json",
70
70
  "lint:fix": "oxlint --fix --max-warnings=1000000 && oxfmt .",
71
71
  "knip": "knip --dependencies",
72
72
  "knip:all": "knip",
@@ -87,12 +87,13 @@
87
87
  "chokidar": "^4.0.3",
88
88
  "commander": "^13.1.0",
89
89
  "ignore": "^7.0.5",
90
- "semver": "^7.7.4"
90
+ "semver": "^7.7.4",
91
+ "yaml": "^2.9.1"
91
92
  },
92
93
  "devDependencies": {
93
- "@antelopejs/core": "^1.20.0",
94
+ "@antelopejs/core": "^1.21.0",
94
95
  "@antelopejs/interface-dms": "^0.10.0",
95
- "@antelopejs/tooling-configs": "^0.2.17",
96
+ "@antelopejs/tooling-configs": "^0.3.3",
96
97
  "@iconify/utils": "^3.1.0",
97
98
  "@iconify/vue": "^5.0.1",
98
99
  "@inertiajs/vue3": "^3.7.1",
@@ -104,6 +105,7 @@
104
105
  "@vueuse/core": "14.4.0",
105
106
  "defu": "^6.1.4",
106
107
  "devalue": "^5.9.4",
108
+ "fast-check": "^4.10.2",
107
109
  "knip": "^6.34.0",
108
110
  "lefthook": "^2.2.2",
109
111
  "ofetch": "^1.5.1",
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { isRecord, isString } from "./server/type-guards.mjs";
4
+
5
+ const BACKEND_SOURCE = "src";
6
+ const QUERY_OR_HASH = /[?#].*$/;
7
+
8
+ /**
9
+ * @param {string} manifest
10
+ * @returns {string | undefined}
11
+ */
12
+ function manifestName(manifest) {
13
+ try {
14
+ /** @type {unknown} */
15
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
16
+ return isRecord(parsed) && isString(parsed.name) ? parsed.name : undefined;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @typedef {{ root: string, name: string }} BackendPackage
24
+ */
25
+
26
+ /**
27
+ * The backend package that holds `directory`: the nearest `package.json` above
28
+ * it, when it names one of `backendPackages`.
29
+ *
30
+ * @param {string} directory
31
+ * @param {ReadonlySet<string>} backendPackages
32
+ * @param {Map<string, BackendPackage | null>} known
33
+ * @returns {BackendPackage | null}
34
+ */
35
+ function owningBackend(directory, backendPackages, known) {
36
+ const cached = known.get(directory);
37
+ if (cached !== undefined) return cached;
38
+ const manifest = join(directory, "package.json");
39
+ const parent = dirname(directory);
40
+ /** @type {BackendPackage | null} */
41
+ let owner = null;
42
+ if (existsSync(manifest)) {
43
+ const name = manifestName(manifest);
44
+ owner =
45
+ name !== undefined && backendPackages.has(name)
46
+ ? { root: directory, name }
47
+ : null;
48
+ } else if (parent !== directory) {
49
+ owner = owningBackend(parent, backendPackages, known);
50
+ }
51
+ known.set(directory, owner);
52
+ return owner;
53
+ }
54
+
55
+ /**
56
+ * The real path of a resolved module id under the `src` of a backend package,
57
+ * and that package's name, or `undefined`. The package that owns the file
58
+ * decides, not where it lies, so a backend linked or installed into the
59
+ * workspace is caught as well as its source checkout. Virtual ids and ids that
60
+ * are not files are ignored.
61
+ *
62
+ * @param {string} id
63
+ * @param {ReadonlySet<string>} backendPackages
64
+ * @param {Map<string, BackendPackage | null>} known
65
+ * @returns {{ file: string, name: string } | undefined}
66
+ */
67
+ function backendSourceFile(id, backendPackages, known) {
68
+ const path = id.replace(QUERY_OR_HASH, "");
69
+ if (!isAbsolute(path)) return undefined;
70
+ let file;
71
+ try {
72
+ file = realpathSync(path);
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ const owner = owningBackend(dirname(file), backendPackages, known);
77
+ if (owner === null) return undefined;
78
+ return relative(owner.root, file).split(sep)[0] === BACKEND_SOURCE
79
+ ? { file, name: owner.name }
80
+ : undefined;
81
+ }
82
+
83
+ /**
84
+ * @param {ReadonlySet<string>} backendPackages
85
+ * @returns {import("vite").Plugin}
86
+ */
87
+ function boundaryCheck(backendPackages) {
88
+ /** @type {Map<string, BackendPackage | null>} */
89
+ const known = new Map();
90
+ return {
91
+ name: "dms-backend-boundary",
92
+ enforce: "pre",
93
+ async resolveId(source, importer, options) {
94
+ const resolved = await this.resolve(source, importer, {
95
+ ...options,
96
+ skipSelf: true,
97
+ });
98
+ if (resolved === null) return null;
99
+ const backend = backendSourceFile(resolved.id, backendPackages, known);
100
+ if (backend === undefined) return resolved;
101
+ const from =
102
+ importer === undefined
103
+ ? "an entry point"
104
+ : importer.replace(QUERY_OR_HASH, "");
105
+ return this.error(
106
+ `Backend boundary: ${from} imports ${backend.file}, which is under the src of ${backend.name}. ` +
107
+ "The frontend reaches the backend only through its HTTP interface.",
108
+ );
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Fail the build, a worker build and the dev server when a module of the
115
+ * frontend resolves to a file under the `src` of a backend package: the package
116
+ * that holds a module's layer, as the registry names it. The browser and the
117
+ * server renderer reach the backend only through its HTTP interface.
118
+ *
119
+ * The check reads what the whole resolution chain returns, so an alias, a
120
+ * symlink and an id marked external are all seen through. Vite builds workers
121
+ * with their own plugin list, so the plugin adds the check there too.
122
+ *
123
+ * @param {readonly string[]} backendPackages
124
+ * @returns {import("vite").Plugin}
125
+ */
126
+ export function rejectBackendSources(backendPackages) {
127
+ const names = new Set(backendPackages);
128
+ return {
129
+ ...boundaryCheck(names),
130
+ config: () => ({ worker: { plugins: () => [boundaryCheck(names)] } }),
131
+ };
132
+ }
@@ -18,6 +18,8 @@
18
18
  * quarter to four fifths, but every group is loaded whole by each page that
19
19
  * uses any of it, and the anonymous pages pay 70 to 115 KB of Brotli for code
20
20
  * only the signed-in shell uses.
21
+ *
22
+ * @type {import("vite").Rolldown.CodeSplittingOptions}
21
23
  */
22
24
  export const clientCodeSplitting = {
23
25
  groups: [{ name: "entry", tags: ["$initial"] }],
@@ -55,23 +55,27 @@ export function entryOwner(id, modules) {
55
55
  * their static imports, transitively, by file name.
56
56
  *
57
57
  * @param {import("vite").Rollup.OutputBundle} bundle
58
- * @returns {(import("vite").Rollup.OutputChunk | undefined)[]}
58
+ * @returns {import("vite").Rollup.OutputChunk[]}
59
59
  */
60
60
  function entryClosure(bundle) {
61
61
  const chunks = Object.values(bundle).filter(
62
+ /**
63
+ * @param {import("vite").Rollup.OutputChunk | import("vite").Rollup.OutputAsset} output
64
+ * @returns {output is import("vite").Rollup.OutputChunk}
65
+ */
62
66
  (output) => output.type === "chunk",
63
67
  );
64
68
  const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
65
- /** @type {Set<string>} */
66
- const seen = new Set();
69
+ /** @type {Map<string, import("vite").Rollup.OutputChunk>} */
70
+ const seen = new Map();
67
71
  /** @param {import("vite").Rollup.OutputChunk | undefined} chunk */
68
72
  const visit = (chunk) => {
69
73
  if (!chunk || seen.has(chunk.fileName)) return;
70
- seen.add(chunk.fileName);
74
+ seen.set(chunk.fileName, chunk);
71
75
  for (const file of chunk.imports ?? []) visit(byName.get(file));
72
76
  };
73
77
  for (const chunk of chunks) if (chunk.isEntry) visit(chunk);
74
- return [...seen].map((file) => byName.get(file));
78
+ return [...seen.values()];
75
79
  }
76
80
 
77
81
  /**
@@ -31,10 +31,11 @@ export interface DmsFrontendBuild {
31
31
 
32
32
  /**
33
33
  * Adds Vite plugins to the application's build, for `ajs dms dev`,
34
- * `ajs dms build` and the preview alike.
34
+ * `ajs dms build` and the preview alike, and to the bundle of each web worker
35
+ * it starts with `new Worker(new URL(...))`.
35
36
  *
36
- * `create` runs in the build process each time Vite loads its configuration,
37
- * and returns real Vite plugin objects (an array, each with a `name`; false,
37
+ * `create` runs in the build process each time Vite loads its configuration
38
+ * and once more for each worker bundle, and returns real Vite plugin objects (an array, each with a `name`; false,
38
39
  * null and undefined entries are skipped and nested arrays flattened, as in a
39
40
  * Vite configuration) or nothing. It is synchronous: a Promise is refused,
40
41
  * because the configuration is built synchronously. The plugins come after
@@ -35,8 +35,9 @@ function deprioritize(html) {
35
35
  export function orderHeadForFirstPaint(html) {
36
36
  const prioritized = deprioritize(html);
37
37
  const stylesheet = prioritized.match(STYLESHEET);
38
- const script = prioritized.match(SCRIPT);
39
- if (!stylesheet || !script || stylesheet.index < script.index)
38
+ const stylesheetAt = prioritized.search(STYLESHEET);
39
+ const scriptAt = prioritized.search(SCRIPT);
40
+ if (!stylesheet || scriptAt < 0 || stylesheetAt < scriptAt)
40
41
  return prioritized;
41
42
  return prioritized
42
43
  .replace(STYLESHEET, "")
@@ -9,6 +9,8 @@ export const ICON_API_PATH = "/api/_dms/icons/";
9
9
  /**
10
10
  * The `@iconify/vue` API provider serving every icon the client bundle lacks
11
11
  * from `origin`, instead of `api.iconify.design` and its fallbacks.
12
+ *
13
+ * @param {string} origin
12
14
  */
13
15
  export function sameOriginIconProvider(origin) {
14
16
  return { resources: [origin], path: ICON_API_PATH };
@@ -1,5 +1,5 @@
1
1
  import { REDIRECT_STATUSES } from "../proxy-redirects.mjs";
2
- import { isString } from "../type-guards.mjs";
2
+ import { isObjectLike, isString } from "../type-guards.mjs";
3
3
  import { clientCountryHeader, clientIp, clientIpHeader } from "./client-ip.mjs";
4
4
 
5
5
  /**
@@ -39,7 +39,7 @@ export const GENERIC_ERROR_MESSAGE = "error.500.description";
39
39
  */
40
40
  export function publicBackendMessage(data) {
41
41
  /** @type {unknown} */
42
- const message = isString(data) ? data : data?.message;
42
+ const message = isObjectLike(data) ? data.message : data;
43
43
  return isString(message) && PUBLIC_AUTH_MESSAGE.test(message)
44
44
  ? message
45
45
  : undefined;
@@ -59,7 +59,7 @@ export function relayedHeaders(request, names) {
59
59
  ...clientCountryHeader(request),
60
60
  ...Object.fromEntries(
61
61
  names.flatMap((name) =>
62
- request.headers[name] ? [[name, request.headers[name]]] : [],
62
+ request.headers[name] ? [[name, String(request.headers[name])]] : [],
63
63
  ),
64
64
  ),
65
65
  };
@@ -86,7 +86,7 @@ export function relayedHeaders(request, names) {
86
86
  * is not JSON
87
87
  */
88
88
  export async function backend(path, request, options = {}) {
89
- /** @type {Record<string, string | string[]>} */
89
+ /** @type {Record<string, string>} */
90
90
  const headers = Object.fromEntries(
91
91
  Object.entries(options.headers ?? {}).map(([name, value]) => [
92
92
  name.toLowerCase(),
@@ -71,8 +71,8 @@ export function allowedEstablishEndpoints(
71
71
  .map((entry) => entry.trim());
72
72
  return [
73
73
  ...new Set(
74
- [...declared, ...fromEnvironment].filter(
75
- (entry) => isString(entry) && BACKEND_API_PATH.test(entry),
74
+ [...declared, ...fromEnvironment].flatMap((entry) =>
75
+ isString(entry) && BACKEND_API_PATH.test(entry) ? [entry] : [],
76
76
  ),
77
77
  ),
78
78
  ];
@@ -96,7 +96,10 @@ function decode(value) {
96
96
  );
97
97
  decipher.setAuthTag(payload.subarray(12, 28));
98
98
  return JSON.parse(
99
- Buffer.concat([decipher.update(payload.subarray(28)), decipher.final()]),
99
+ Buffer.concat([
100
+ decipher.update(payload.subarray(28)),
101
+ decipher.final(),
102
+ ]).toString(),
100
103
  );
101
104
  } catch {
102
105
  return undefined;
@@ -150,7 +153,7 @@ function appendCookie(response, name, value, cookieAttributes) {
150
153
  response.setHeader(
151
154
  "set-cookie",
152
155
  current
153
- ? [cookie, ...(Array.isArray(current) ? current : [current])]
156
+ ? [cookie, ...(Array.isArray(current) ? current : [String(current)])]
154
157
  : cookie,
155
158
  );
156
159
  }
@@ -0,0 +1,46 @@
1
+ import { authRejection } from "./auth-hooks.mjs";
2
+ import { compilePathPattern } from "./path-patterns.mjs";
3
+
4
+ /**
5
+ * @import { AuthProvider, DeclaredModule } from "./module-route-types.mjs"
6
+ */
7
+
8
+ /**
9
+ * The auth hooks: those of the highest-priority module that registers them.
10
+ * A module registering them twice, or after another one, is reported.
11
+ *
12
+ * @param {DeclaredModule[]} modules
13
+ * @param {(message: string) => void} warn
14
+ */
15
+ export function resolveAuth(modules, warn) {
16
+ /** @type {AuthProvider | undefined} */
17
+ let provider;
18
+ for (const module of modules) {
19
+ for (const auth of module.declared.auth) {
20
+ const reason = authRejection(auth);
21
+ if (reason) {
22
+ warn(
23
+ `Frontend module ${module.id} registers auth hooks, which are ignored: ${reason}.`,
24
+ );
25
+ continue;
26
+ }
27
+ if (provider) {
28
+ warn(
29
+ `Frontend module ${module.id} registers auth hooks, which are ignored: ${provider.id} (priority ${provider.priority ?? 0}) provides them.`,
30
+ );
31
+ continue;
32
+ }
33
+ provider = {
34
+ id: module.id,
35
+ priority: module.priority ?? 0,
36
+ signInPath: auth.signInPath,
37
+ signInExempt: auth.signInExempt?.flatMap(
38
+ (pattern) => compilePathPattern(pattern) ?? [],
39
+ ),
40
+ refreshSession: auth.refreshSession?.bind(auth),
41
+ publicSession: auth.publicSession?.bind(auth),
42
+ };
43
+ }
44
+ }
45
+ return provider;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { readSession, setRequestSession } from "./auth/session.mjs";
2
2
  import { createServerEvent } from "./module-event.mjs";
3
- import { compilePathPattern } from "./path-patterns.mjs";
3
+ import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
4
+ import { isFunction, isRecord, isString } from "./type-guards.mjs";
4
5
 
5
6
  const DEFAULT_SIGN_IN_PATH = "/auth";
6
7
  const DEFAULT_ONBOARDING_PATH = "/onboarding";
@@ -17,7 +18,44 @@ const HOOK_ROUTE = Object.freeze({ bodyLimit: 0 });
17
18
  */
18
19
  function defaultExempt(signInPath) {
19
20
  const signIn = signInPath === "/" ? "/" : `${signInPath}/**`;
20
- return [signIn, DEFAULT_ONBOARDING_PATH].map(compilePathPattern);
21
+ return [signIn, DEFAULT_ONBOARDING_PATH].flatMap(
22
+ (pattern) => compilePathPattern(pattern) ?? [],
23
+ );
24
+ }
25
+
26
+ const AUTH_HOOKS = ["refreshSession", "publicSession"];
27
+
28
+ /**
29
+ * Why an auth hooks registration is ignored, or `undefined`.
30
+ *
31
+ * @param {unknown} auth
32
+ */
33
+ export function authRejection(auth) {
34
+ if (!isRecord(auth)) return "it is not an object";
35
+ for (const hook of AUTH_HOOKS)
36
+ if (auth[hook] !== undefined && !isFunction(auth[hook]))
37
+ return `${hook} is not a function`;
38
+ if (
39
+ auth.signInPath !== undefined &&
40
+ (!isString(auth.signInPath) ||
41
+ !compilePathPattern(auth.signInPath) ||
42
+ auth.signInPath.includes("*"))
43
+ )
44
+ return "signInPath is not a page path";
45
+ return exemptionRejection(auth.signInExempt);
46
+ }
47
+
48
+ /** @param {unknown} signInExempt */
49
+ function exemptionRejection(signInExempt) {
50
+ if (signInExempt === undefined) return undefined;
51
+ if (!Array.isArray(signInExempt))
52
+ return `signInExempt is not a list of ${PATH_PATTERN_FORMS} patterns`;
53
+ for (const pattern of signInExempt)
54
+ if (!compilePathPattern(pattern))
55
+ return isString(pattern)
56
+ ? `signInExempt holds "${pattern}", which is not ${PATH_PATTERN_FORMS}`
57
+ : "signInExempt holds an entry that is not a string";
58
+ return undefined;
21
59
  }
22
60
 
23
61
  /**
@@ -72,7 +110,7 @@ export function serverAuth(provider, context) {
72
110
  * Renews the request's session after the backend refused it. Returns the
73
111
  * new session, which the rest of the request runs with, or `undefined`.
74
112
  *
75
- * @param {import("node:http").IncomingMessage} request
113
+ * @param {import("./request-target.mjs").ServerRequest} request
76
114
  * @param {import("node:http").ServerResponse} response
77
115
  */
78
116
  async refresh(request, response) {
@@ -1,5 +1,5 @@
1
- import { compilePathPattern } from "./path-patterns.mjs";
2
- import { isNonNullObject } from "./type-guards.mjs";
1
+ import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
2
+ import { isNonNullObject, isString } from "./type-guards.mjs";
3
3
 
4
4
  /** @typedef {import("../server-module.ts").DmsBackendProxyOptions} ProxyOptions */
5
5
  /**
@@ -53,12 +53,13 @@ function literalPrefix(pattern) {
53
53
  /**
54
54
  * Why a proxy declaration is ignored, or `undefined`.
55
55
  *
56
- * @param {string} pattern
56
+ * @param {string | undefined} pattern
57
57
  * @param {ProxyOptions | undefined} options
58
58
  */
59
59
  export function proxyRejection(pattern, options) {
60
60
  const compiled = compilePathPattern(pattern);
61
- if (!compiled) return "the pattern is not /x, /x/* or /x/**";
61
+ if (!compiled || !isString(pattern))
62
+ return `the pattern is not ${PATH_PATTERN_FORMS}`;
62
63
  const prefix = literalPrefix(pattern);
63
64
  if (prefix === "/")
64
65
  return "its first segment is not literal: it would take every page";
@@ -81,15 +82,15 @@ export function proxyRejection(pattern, options) {
81
82
  }
82
83
 
83
84
  /**
84
- * @param {string} pattern A pattern `proxyRejection` accepted
85
+ * @param {import("./path-patterns.mjs").CompiledPathPattern} compiled A pattern `proxyRejection` accepted
85
86
  * @param {ProxyOptions | undefined} options
86
87
  * @param {string} owner Who declared it
87
88
  * @returns {BackendProxy}
88
89
  */
89
- function compileProxy(pattern, options, owner) {
90
+ function compileProxy(compiled, options, owner) {
90
91
  return {
91
- pattern: compilePathPattern(pattern),
92
- path: pattern,
92
+ pattern: compiled,
93
+ path: compiled.pattern,
93
94
  owner,
94
95
  auth: options?.auth ?? DEFAULT_AUTH,
95
96
  maxBodyBytes: options?.maxBodyBytes,
@@ -122,15 +123,18 @@ export function resolveBackendProxies(declared, manifest, warn) {
122
123
  * @param {string} owner
123
124
  */
124
125
  const add = (pattern, options, owner) => {
125
- const reason = proxyRejection(pattern, options);
126
+ const compiled = compilePathPattern(pattern);
126
127
  const current = kept.get(pattern);
127
- if (reason || current) {
128
+ const reason =
129
+ proxyRejection(pattern, options) ??
130
+ (current && `${current.owner} declares it`);
131
+ if (!compiled || reason) {
128
132
  warn(
129
- `${owner} declares the backend proxy "${pattern}", which is ignored: ${reason ?? `${current.owner} declares it`}.`,
133
+ `${owner} declares the backend proxy "${pattern}", which is ignored: ${reason}.`,
130
134
  );
131
135
  return;
132
136
  }
133
- kept.set(pattern, compileProxy(pattern, options, owner));
137
+ kept.set(pattern, compileProxy(compiled, options, owner));
134
138
  };
135
139
  for (const { id, pattern, options } of declared)
136
140
  add(pattern, options, `Frontend module ${id}`);
@@ -1,6 +1,6 @@
1
1
  import { GENERIC_ERROR_MESSAGE } from "./auth/backend.mjs";
2
2
  import { isFrontendVisit, redirectFrontendVisit } from "./inertia.mjs";
3
- import { isString } from "./type-guards.mjs";
3
+ import { isObjectLike, isString } from "./type-guards.mjs";
4
4
 
5
5
  /**
6
6
  * What a JSON caller gets when the backend fails: the generic i18n key of
@@ -45,7 +45,7 @@ async function refusalCode(response) {
45
45
  try {
46
46
  /** @type {unknown} */
47
47
  const data = JSON.parse(text);
48
- value = isString(data) ? data : data?.message;
48
+ value = isObjectLike(data) ? data.message : data;
49
49
  } catch {}
50
50
  return isString(value) && REFUSAL_CODE.test(value) ? value : undefined;
51
51
  }
@@ -67,7 +67,7 @@ export async function backendResponseError(response) {
67
67
  * qualifies: a refused destination sent to itself again would loop.
68
68
  *
69
69
  * @param {unknown} error What the request failed with
70
- * @param {import("node:http").IncomingMessage} request
70
+ * @param {import("./request-target.mjs").ServerRequest} request
71
71
  * @param {import("node:http").ServerResponse} response
72
72
  * @param {() => Promise<typeof import("../ssr-renderer.ts")>} loadRenderer
73
73
  * @returns {Promise<boolean>} Whether the visit was redirected