@antelopejs/dms-frontend 0.10.0 → 0.10.2

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 (75) 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 +6 -1
  22. package/dist/project-modules.js +26 -16
  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/dist/workspace.js +1 -1
  29. package/package.json +20 -7
  30. package/templates/vue/backend-boundary.mjs +132 -0
  31. package/templates/vue/client-chunking.mjs +2 -0
  32. package/templates/vue/entry-weight.mjs +9 -5
  33. package/templates/vue/frontend-build.ts +4 -3
  34. package/templates/vue/head-order.mjs +3 -2
  35. package/templates/vue/icon-api.mjs +2 -0
  36. package/templates/vue/server/auth/backend.mjs +4 -4
  37. package/templates/vue/server/auth/establish-endpoints.mjs +2 -2
  38. package/templates/vue/server/auth/session.mjs +5 -2
  39. package/templates/vue/server/auth-declarations.mjs +46 -0
  40. package/templates/vue/server/auth-hooks.mjs +41 -3
  41. package/templates/vue/server/backend-proxies.mjs +16 -12
  42. package/templates/vue/server/backend-response.mjs +3 -3
  43. package/templates/vue/server/cli-report.mjs +22 -1
  44. package/templates/vue/server/client-error.mjs +5 -0
  45. package/templates/vue/server/content.mjs +24 -11
  46. package/templates/vue/server/email-bundle-watch.mjs +9 -3
  47. package/templates/vue/server/email.mjs +5 -3
  48. package/templates/vue/server/generated-files.mjs +16 -6
  49. package/templates/vue/server/icons.mjs +15 -11
  50. package/templates/vue/server/in-place-render.mjs +3 -3
  51. package/templates/vue/server/inertia.mjs +17 -3
  52. package/templates/vue/server/module-event.mjs +28 -16
  53. package/templates/vue/server/module-route-types.mjs +2 -2
  54. package/templates/vue/server/module-routes.mjs +24 -76
  55. package/templates/vue/server/module-server.mjs +27 -17
  56. package/templates/vue/server/module-testing.mjs +23 -8
  57. package/templates/vue/server/page-props.mjs +6 -5
  58. package/templates/vue/server/page-redirects.mjs +1 -1
  59. package/templates/vue/server/path-patterns.mjs +22 -11
  60. package/templates/vue/server/proxy-headers.mjs +1 -1
  61. package/templates/vue/server/proxy-redirects.mjs +1 -0
  62. package/templates/vue/server/proxy-request.mjs +2 -1
  63. package/templates/vue/server/proxy-transfers.mjs +17 -11
  64. package/templates/vue/server/public-endpoints.mjs +12 -16
  65. package/templates/vue/server/request-failure.mjs +1 -1
  66. package/templates/vue/server/request-routing.mjs +6 -1
  67. package/templates/vue/server/request-target.mjs +22 -9
  68. package/templates/vue/server/route-rules.mjs +12 -6
  69. package/templates/vue/server/static-files.mjs +12 -7
  70. package/templates/vue/server/vite-errors.mjs +13 -2
  71. package/templates/vue/server/web-body.mjs +2 -1
  72. package/templates/vue/server-module.ts +2 -1
  73. package/templates/vue/server.mjs +41 -20
  74. package/templates/vue/ui-inertia-page.mjs +1 -1
  75. package/templates/vue/vite.config.ts +9 -0
@@ -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
@@ -13,7 +13,11 @@ const WILDCARD_ADDRESSES = ["0.0.0.0", "::"];
13
13
  */
14
14
  async function report(message, line, write) {
15
15
  if (!process.send) return write(line);
16
- await new Promise((sent) => process.send(message, () => sent()));
16
+ /** @type {Promise<void>} */
17
+ const sent = new Promise((resolve) =>
18
+ process.send?.(message, () => resolve()),
19
+ );
20
+ await sent;
17
21
  }
18
22
 
19
23
  /**
@@ -40,6 +44,23 @@ export async function reportReady(address, port) {
40
44
  );
41
45
  }
42
46
 
47
+ /** The `ANTELOPEJS_QUIET` values that leave a run verbose, as `@antelopejs/core` reads them. */
48
+ const DISABLED_FLAG_VALUES = ["0", "false"];
49
+
50
+ /**
51
+ * Prints the routing summary, unless the run is quiet: `-q` given to `ajs` or
52
+ * `ajs dms` reaches the server as `ANTELOPEJS_QUIET`, and a quiet ready server
53
+ * prints only its `Local` url.
54
+ *
55
+ * @param {readonly string[]} lines What `routingReport` returned
56
+ * @param {NodeJS.ProcessEnv} [env]
57
+ */
58
+ export function reportRouting(lines, env = process.env) {
59
+ const quiet = env.ANTELOPEJS_QUIET;
60
+ if (quiet && !DISABLED_FLAG_VALUES.includes(quiet.toLowerCase())) return;
61
+ for (const line of lines) console.log(line);
62
+ }
63
+
43
64
  /**
44
65
  * The server cannot listen: a port taken by another process, an address this
45
66
  * host does not have. Reported instead of Node's unhandled-error dump, then
@@ -11,6 +11,7 @@ const HEADER_OVERFLOW_HINT_INTERVAL_MS = 60_000;
11
11
 
12
12
  let lastHeaderOverflowHint = -Infinity;
13
13
 
14
+ /** @param {number} maxHeaderSize */
14
15
  function warnHeaderOverflow(maxHeaderSize) {
15
16
  const now = Date.now();
16
17
  if (now - lastHeaderOverflowHint < HEADER_OVERFLOW_HINT_INTERVAL_MS) return;
@@ -32,6 +33,10 @@ function warnHeaderOverflow(maxHeaderSize) {
32
33
  *
33
34
  * Once a response went out on this connection, a status line would land in the
34
35
  * middle of it.
36
+ *
37
+ * @param {NodeJS.ErrnoException} error
38
+ * @param {import("node:stream").Duplex & { bytesWritten?: number }} socket
39
+ * @param {number} maxHeaderSize
35
40
  */
36
41
  export function handleClientError(error, socket, maxHeaderSize) {
37
42
  let response = BAD_REQUEST_RESPONSE;
@@ -11,17 +11,14 @@ export const MINIMUM_COMPRESSION_BYTES = 1_024;
11
11
  const DYNAMIC_BROTLI_QUALITY = 4;
12
12
 
13
13
  /**
14
- * A compressor for a body generated or relayed now, in the encoding the client
15
- * accepts (Brotli first), or `undefined` when it accepts neither. `streaming`
16
- * flushes what each write gives at once, for a body written over time: the
17
- * client gets each part when it is written, not when a block fills.
18
- *
19
- * Implicit identity is a fallback, not a preference over compression.
14
+ * The codings among `br` and `gzip` the client accepts, most wanted first
15
+ * (Brotli on a tie). Empty when it accepts neither, or ranks identity above
16
+ * both: implicit identity is a fallback, not a preference over compression.
20
17
  *
21
18
  * @param {import("node:http").IncomingMessage} request
22
- * @param {{ streaming?: boolean }} [options]
19
+ * @returns {("br" | "gzip")[]}
23
20
  */
24
- export function acceptedEncoder(request, { streaming = false } = {}) {
21
+ export function acceptedCodings(request) {
25
22
  const accepted = request.headers["accept-encoding"] ?? "";
26
23
  /** @type {Map<string, number>} */
27
24
  const qualities = new Map();
@@ -46,8 +43,24 @@ export function acceptedEncoder(request, { streaming = false } = {}) {
46
43
  const br = quality("br");
47
44
  const gzip = quality("gzip");
48
45
  const identity = qualities.get("identity") ?? 0;
49
- if (identity > Math.max(br, gzip)) return undefined;
50
- if (br > 0 && br >= gzip)
46
+ if (identity > Math.max(br, gzip)) return [];
47
+ /** @type {("br" | "gzip")[]} */
48
+ const ranked = gzip > br ? ["gzip", "br"] : ["br", "gzip"];
49
+ return ranked.filter((coding) => quality(coding) > 0);
50
+ }
51
+
52
+ /**
53
+ * A compressor for a body generated or relayed now, in the encoding the client
54
+ * wants most (see `acceptedCodings`), or `undefined` when it accepts neither.
55
+ * `streaming` flushes what each write gives at once, for a body written over
56
+ * time: the client gets each part when it is written, not when a block fills.
57
+ *
58
+ * @param {import("node:http").IncomingMessage} request
59
+ * @param {{ streaming?: boolean }} [options]
60
+ */
61
+ export function acceptedEncoder(request, { streaming = false } = {}) {
62
+ const [coding] = acceptedCodings(request);
63
+ if (coding === "br")
51
64
  return {
52
65
  encoding: "br",
53
66
  stream: createBrotliCompress({
@@ -57,7 +70,7 @@ export function acceptedEncoder(request, { streaming = false } = {}) {
57
70
  },
58
71
  }),
59
72
  };
60
- if (gzip > 0)
73
+ if (coding === "gzip")
61
74
  return {
62
75
  encoding: "gzip",
63
76
  stream: createGzip(
@@ -1,13 +1,15 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { build } from "vite";
3
+ import { isObjectLike } from "./type-guards.mjs";
3
4
 
4
5
  const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
5
6
 
6
7
  process.on("disconnect", () => process.exit(0));
7
8
 
9
+ /** @type {(() => void) | undefined} */
8
10
  let startBuild;
9
11
  process.on("message", (message) => {
10
- if (message?.state !== "build") return;
12
+ if (!isObjectLike(message) || message.state !== "build") return;
11
13
  startBuild?.();
12
14
  startBuild = undefined;
13
15
  });
@@ -17,10 +19,12 @@ const serverHandoff = {
17
19
  name: "dms-email-bundle-handoff",
18
20
  buildStart() {
19
21
  if (!process.send) return;
20
- return new Promise((start) => {
22
+ /** @type {Promise<void>} */
23
+ const held = new Promise((start) => {
21
24
  startBuild = start;
22
- process.send({ state: "building" });
25
+ process.send?.({ state: "building" });
23
26
  });
27
+ return held;
24
28
  },
25
29
  };
26
30
 
@@ -34,6 +38,8 @@ const watcher = await build({
34
38
  build: { watch: {} },
35
39
  });
36
40
 
41
+ if (!("on" in watcher))
42
+ throw new TypeError("The email bundle build did not start a watcher");
37
43
  watcher.on("event", (event) => {
38
44
  if (event.code === "END") process.send?.({ state: "ready" });
39
45
  else if (event.code === "ERROR") process.send?.({ state: "failed" });
@@ -1,6 +1,7 @@
1
1
  import { fork } from "node:child_process";
2
2
  import { constants, setPriority } from "node:os";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isObjectLike } from "./type-guards.mjs";
4
5
 
5
6
  const EMAIL_RENDERER_URL = new URL(
6
7
  "../dist/server/email-renderer.js",
@@ -75,10 +76,11 @@ export function watchEmailBundle() {
75
76
  cwd: fileURLToPath(new URL("..", import.meta.url)),
76
77
  });
77
78
  try {
78
- setPriority(builder.pid, constants.priority.PRIORITY_BELOW_NORMAL);
79
+ if (builder.pid !== undefined)
80
+ setPriority(builder.pid, constants.priority.PRIORITY_BELOW_NORMAL);
79
81
  } catch {}
80
82
  builder.on("message", (message) => {
81
- if (message?.state !== "building") {
83
+ if (!isObjectLike(message) || message.state !== "building") {
82
84
  emailBuildSettled();
83
85
  return;
84
86
  }
@@ -115,7 +117,7 @@ async function waitForEmailBuild() {
115
117
  /** @returns {Promise<typeof import("../email-renderer.ts")>} */
116
118
  function loadEmailRenderer() {
117
119
  const url = new URL(EMAIL_RENDERER_URL);
118
- if (emailBundleVersion) url.searchParams.set("v", emailBundleVersion);
120
+ if (emailBundleVersion) url.searchParams.set("v", String(emailBundleVersion));
119
121
  return import(url.href);
120
122
  }
121
123
 
@@ -115,6 +115,14 @@ function isManifestChunk(value) {
115
115
  );
116
116
  }
117
117
 
118
+ /**
119
+ * @param {unknown} value
120
+ * @returns {value is import("vite").Manifest}
121
+ */
122
+ function isManifest(value) {
123
+ return isPlainRecord(value) && Object.values(value).every(isManifestChunk);
124
+ }
125
+
118
126
  /**
119
127
  * The manifest `vite build` wrote to `dist/client/.vite/manifest.json`.
120
128
  *
@@ -123,7 +131,7 @@ function isManifestChunk(value) {
123
131
  * @throws {Error} When it is not an object of chunks
124
132
  */
125
133
  export function viteManifestOf(content) {
126
- if (!isPlainRecord(content) || !Object.values(content).every(isManifestChunk))
134
+ if (!isManifest(content))
127
135
  throw new Error("the client manifest is not an object of Vite chunks");
128
136
  return content;
129
137
  }
@@ -132,8 +140,12 @@ export function viteManifestOf(content) {
132
140
  * The members of an Iconify collection that `iconCollectionOf` checks, the
133
141
  * ones an icon query and a server render read.
134
142
  *
143
+ * @typedef {Parameters<typeof import("@iconify/utils").getIcons>[0]} Iconify
144
+ */
145
+
146
+ /**
135
147
  * @typedef {Pick<
136
- * Parameters<typeof import("@iconify/utils").getIcons>[0],
148
+ * Iconify,
137
149
  * | "prefix"
138
150
  * | "icons"
139
151
  * | "aliases"
@@ -142,10 +154,8 @@ export function viteManifestOf(content) {
142
154
  * | "top"
143
155
  * | "width"
144
156
  * | "height"
145
- * | "rotate"
146
- * | "hFlip"
147
- * | "vFlip"
148
- * >} IconCollection
157
+ * > &
158
+ * Pick<Iconify["icons"][string], "rotate" | "hFlip" | "vFlip">} IconCollection
149
159
  */
150
160
 
151
161
  /**
@@ -104,12 +104,12 @@ async function loadCollection(root, prefix) {
104
104
  */
105
105
  function collection(root, prefix) {
106
106
  const key = join(root, prefix);
107
- if (!collections.has(key)) {
108
- const loading = loadCollection(root, prefix);
109
- loading.catch(() => collections.delete(key));
110
- collections.set(key, loading);
111
- }
112
- return collections.get(key);
107
+ const cached = collections.get(key);
108
+ if (cached) return cached;
109
+ const loading = loadCollection(root, prefix);
110
+ loading.catch(() => collections.delete(key));
111
+ collections.set(key, loading);
112
+ return loading;
113
113
  }
114
114
 
115
115
  /**
@@ -128,7 +128,7 @@ export async function installedCollections(root) {
128
128
  .filter((prefix) => ICON_NAME.test(prefix))
129
129
  .map((prefix) => collection(root, prefix)),
130
130
  );
131
- return loaded.filter(Boolean);
131
+ return loaded.filter((loadedCollection) => loadedCollection !== undefined);
132
132
  }
133
133
 
134
134
  /**
@@ -193,13 +193,17 @@ function requestedIcons(url) {
193
193
  }
194
194
 
195
195
  /**
196
- * Whether the generated server answers `pathname` with `iconResponse`.
196
+ * Whether the generated server answers `pathname` with `iconResponse`: the
197
+ * icon endpoint, with or without its trailing slash, and every path under it.
197
198
  *
198
199
  * @param {string} pathname
199
200
  * @returns {boolean}
200
201
  */
201
202
  export function isIconRequest(pathname) {
202
- return pathname.startsWith(ICON_API_PATH);
203
+ return (
204
+ pathname === ICON_API_PATH.slice(0, -1) ||
205
+ pathname.startsWith(ICON_API_PATH)
206
+ );
203
207
  }
204
208
 
205
209
  /**
@@ -214,7 +218,7 @@ export function isIconRequest(pathname) {
214
218
  * @returns {Promise<IconAnswer>}
215
219
  */
216
220
  export async function iconResponse(root, method, url) {
217
- if (!READ_METHODS.has(method)) {
221
+ if (method === undefined || !READ_METHODS.has(method)) {
218
222
  const response = errorResponse(405, "Method not allowed");
219
223
  response.headers.allow = [...READ_METHODS].join(", ");
220
224
  return response;
@@ -234,7 +238,7 @@ export async function iconResponse(root, method, url) {
234
238
  * Serve an Iconify API query from the collections installed in the workspace,
235
239
  * the same way under `ajs dms dev` and `ajs dms start`: both run this server.
236
240
  *
237
- * @param {import("node:http").IncomingMessage} request
241
+ * @param {import("./request-target.mjs").ServerRequest} request
238
242
  * @param {import("node:http").ServerResponse} response
239
243
  */
240
244
  export async function handleIcons(request, response) {
@@ -44,7 +44,7 @@ function reusableReadKey(path, options, headers) {
44
44
  * A read returns a copy: the render that read it may still change the object it
45
45
  * got. A body that cannot be copied is read again.
46
46
  *
47
- * @param {() => HeadersInit} backendHeaders
47
+ * @param {() => ConstructorParameters<typeof Headers>[0]} backendHeaders
48
48
  * @returns {ServerReads}
49
49
  */
50
50
  export function serverReads(backendHeaders) {
@@ -90,7 +90,7 @@ export function serverReads(backendHeaders) {
90
90
  * same path. Any other redirect keeps its own response.
91
91
  *
92
92
  * @param {import("node:http").IncomingMessage} request
93
- * @param {string | undefined} from
93
+ * @param {string} from
94
94
  * @param {string} location
95
95
  * @returns {boolean}
96
96
  */
@@ -114,7 +114,7 @@ function rendersInPlace(request, from, location) {
114
114
  * A page that completes its query as it renders is rendered at that query in
115
115
  * the same request, rather than answered with a redirect.
116
116
  *
117
- * @template {{ url: string | undefined, props: unknown }} Page
117
+ * @template {{ url: string, props: unknown }} Page
118
118
  * @template {{ redirect?: string }} Rendered
119
119
  * @param {import("node:http").IncomingMessage} request
120
120
  * @param {Page} page
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isObjectLike, isString } from "./type-guards.mjs";
4
5
 
5
6
  const CLIENT_MANIFEST_PATH = fileURLToPath(
6
7
  new URL("../dist/client/.vite/manifest.json", import.meta.url),
@@ -53,14 +54,26 @@ export const DMS_PAGE_COMPONENT = "DmsDynamicPage";
53
54
  * @typedef {{
54
55
  * component: string,
55
56
  * props: Record<string, unknown>,
56
- * url: string | undefined,
57
+ * url: string,
57
58
  * version: string,
58
59
  * onceProps?: OnceProps
59
60
  * }} InertiaPage
60
61
  */
61
62
 
62
63
  /**
63
- * @param {string | undefined} url
64
+ * Whether the page has the props the server render needs: the path and the
65
+ * page the backend described. A full visit carries both; the page payload is
66
+ * the backend's JSON, read as the renderer reads it.
67
+ *
68
+ * @param {InertiaPage} page
69
+ * @returns {page is InertiaPage & import("../ssr-renderer.ts").DmsSsrPage}
70
+ */
71
+ export function isSsrPage(page) {
72
+ return isString(page.props.path) && isObjectLike(page.props.page);
73
+ }
74
+
75
+ /**
76
+ * @param {string} url
64
77
  * @param {Record<string, unknown> | undefined} props
65
78
  * @param {string} [version]
66
79
  * @param {OnceProps} [onceProps]
@@ -72,6 +85,7 @@ export function createInertiaPage(
72
85
  version = assetVersion(),
73
86
  onceProps,
74
87
  ) {
88
+ /** @type {InertiaPage} */
75
89
  const page = {
76
90
  component: DMS_PAGE_COMPONENT,
77
91
  props: { ...props, errors: props?.errors ?? {} },
@@ -180,7 +194,7 @@ export function isFrontendVisit(request) {
180
194
  if (request.method !== "GET") return false;
181
195
  return (
182
196
  Boolean(request.headers["x-inertia"]) ||
183
- request.headers.accept?.includes("text/html")
197
+ request.headers.accept?.includes("text/html") === true
184
198
  );
185
199
  }
186
200
 
@@ -25,13 +25,15 @@ import {
25
25
  import { backendUrl } from "./request-target.mjs";
26
26
  import {
27
27
  isFunction,
28
- isNonNullObject,
29
28
  isNumber,
29
+ isObjectLike,
30
+ isRecord,
30
31
  isString,
31
32
  } from "./type-guards.mjs";
32
33
  import { pipeWebBody } from "./web-body.mjs";
33
34
 
34
35
  /** @typedef {import("node:http").IncomingMessage} IncomingMessage */
36
+ /** @typedef {import("./request-target.mjs").ServerRequest} ServerRequest */
35
37
  /** @typedef {import("node:http").ServerResponse} ServerResponse */
36
38
  /** @typedef {import("../server-module.ts").DmsServerEvent} ServerEvent */
37
39
  /** @typedef {import("../server-module.ts").DmsSession} Session */
@@ -40,7 +42,7 @@ import { pipeWebBody } from "./web-body.mjs";
40
42
  * What an event is made from.
41
43
  *
42
44
  * @typedef {object} EventContext
43
- * @property {IncomingMessage} request
45
+ * @property {ServerRequest} request
44
46
  * @property {ServerResponse} response
45
47
  * @property {Pick<import("./module-route-types.mjs").ServerRoute, "bodyLimit">} route
46
48
  * @property {Readonly<Record<string, string>>} params
@@ -97,7 +99,7 @@ function limitedBody(request, limit) {
97
99
  }
98
100
 
99
101
  /**
100
- * @param {IncomingMessage} request
102
+ * @param {ServerRequest} request
101
103
  * @param {EventContext["route"]} route
102
104
  */
103
105
  function webRequest(request, route) {
@@ -124,9 +126,13 @@ async function readJson(webRequestValue) {
124
126
  const chunks = [];
125
127
  const reader = webRequestValue.body.getReader();
126
128
  for (;;) {
127
- const { done, value } = await reader.read();
128
- if (done) break;
129
- chunks.push(value);
129
+ const read = await reader.read();
130
+ if (read.done) break;
131
+ /** @type {unknown} */
132
+ const chunk = read.value;
133
+ if (!(chunk instanceof Uint8Array))
134
+ throw new RequestBodyError(400, "Invalid request body");
135
+ chunks.push(chunk);
130
136
  }
131
137
  if (!chunks.length) return {};
132
138
  try {
@@ -237,8 +243,16 @@ function frozenCopy(value) {
237
243
  */
238
244
  export function createServerEvent(context) {
239
245
  const { request, response, route, params } = context;
240
- const event = {
241
- request: webRequest(request, route),
246
+ const webRequestValue = webRequest(request, route);
247
+ // SAFETY: `server-module.ts` lets a handler name the JSON shape it expects from the backend and from the client, as `Response.json()` does; the server parses that JSON and hands it on unchecked, which these two members are the only place to state.
248
+ const parsedJson = /** @type {Pick<ServerEvent, "backend" | "readJson">} */ ({
249
+ backend: (/** @type {string} */ path, init = {}) =>
250
+ backend(path, request, init),
251
+ readJson: () => readJson(webRequestValue),
252
+ });
253
+ return {
254
+ ...parsedJson,
255
+ request: webRequestValue,
242
256
  params: Object.freeze({ ...params }),
243
257
  session: sessionAccess(request, response, context.view),
244
258
  cookies: cookieAccess(
@@ -247,8 +261,6 @@ export function createServerEvent(context) {
247
261
  context.module.id,
248
262
  context.authOwner,
249
263
  ),
250
- backend: (/** @type {string} */ path, init = {}) =>
251
- backend(path, request, init),
252
264
  backendUrl: (/** @type {string} */ path) => backendUrl(path),
253
265
  clientIp: () => clientIp(request),
254
266
  module: Object.freeze({
@@ -266,19 +278,18 @@ export function createServerEvent(context) {
266
278
  ...(context.app?.authEstablishEndpoints ?? []),
267
279
  ]),
268
280
  }),
281
+ url: new URL(webRequestValue.url),
269
282
  };
270
- event.url = new URL(event.request.url);
271
- event.readJson = () => readJson(event.request);
272
- return event;
273
283
  }
274
284
 
275
285
  /** @param {unknown} value */
276
286
  function isResponse(value) {
277
287
  return (
278
288
  value instanceof Response ||
279
- (isNonNullObject(value) &&
289
+ (isRecord(value) &&
280
290
  isNumber(value.status) &&
281
- isFunction(value.headers?.get) &&
291
+ isObjectLike(value.headers) &&
292
+ isFunction(value.headers.get) &&
282
293
  "body" in value)
283
294
  );
284
295
  }
@@ -304,7 +315,7 @@ function asResponse(result) {
304
315
  * on the frontend's origin when the frontend proxies its path, no cookie of
305
316
  * another site its fetch reached.
306
317
  *
307
- * @param {IncomingMessage} request
318
+ * @param {ServerRequest} request
308
319
  * @param {ServerResponse} response
309
320
  * @param {unknown} result
310
321
  * @param {(path: string) => boolean} isProxiedPath
@@ -313,6 +324,7 @@ async function writeServerResponse(request, response, result, isProxiedPath) {
313
324
  const answer = asResponse(result);
314
325
  const cookies = [response.getHeader("set-cookie") ?? []]
315
326
  .flat()
327
+ .map(String)
316
328
  .concat(relayableCookies(answer));
317
329
  answer.headers.forEach((value, name) => {
318
330
  if (name === "set-cookie") return;
@@ -2,8 +2,8 @@ export {};
2
2
 
3
3
  /** @typedef {import("../server-module.ts").DmsServerMethod} ServerMethod */
4
4
  /**
5
- * What `registerAuth` declared. The hooks are read as plain functions, as the
6
- * server calls them.
5
+ * What `registerAuth` declared. The server binds each hook to the declared
6
+ * object, so a hook may use `this`.
7
7
  *
8
8
  * @typedef {object} AuthDeclaration
9
9
  * @property {string} [signInPath]