@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
@@ -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]
@@ -1,3 +1,4 @@
1
+ import { resolveAuth } from "./auth-declarations.mjs";
1
2
  import { resolveBackendProxies } from "./backend-proxies.mjs";
2
3
  import { compilePathPattern } from "./path-patterns.mjs";
3
4
  import { matchPublicEndpoint } from "./public-endpoints.mjs";
@@ -12,8 +13,8 @@ import {
12
13
  /** @typedef {(message: string) => void} Warn */
13
14
  /**
14
15
  * @import {
15
- * AuthProvider, DeclaredModule, ModuleDeclared, RouteRegistration,
16
- * ServerDeclarations, ServerModuleDeclaration, ServerRoute
16
+ * DeclaredModule, ModuleDeclared, RouteRegistration,
17
+ * ServerDeclarations, ServerMethod, ServerModuleDeclaration, ServerRoute
17
18
  * } from "./module-route-types.mjs"
18
19
  * @import { PublicEndpoint } from "./public-endpoints.mjs"
19
20
  */
@@ -156,13 +157,19 @@ function collectDeclarations(setup) {
156
157
  return declared;
157
158
  }
158
159
 
160
+ /**
161
+ * @param {unknown} name
162
+ * @returns {name is ServerMethod}
163
+ */
164
+ function isMethod(name) {
165
+ return isString(name) && METHODS.has(name);
166
+ }
167
+
159
168
  /** @param {unknown} method */
160
169
  function methodsOf(method) {
161
170
  /** @type {readonly unknown[]} */
162
171
  const methods = Array.isArray(method) ? method : [method];
163
- return methods.length && methods.every((name) => METHODS.has(name))
164
- ? methods
165
- : undefined;
172
+ return methods.length && methods.every(isMethod) ? methods : undefined;
166
173
  }
167
174
 
168
175
  /** @param {RouteRegistration} registration */
@@ -176,7 +183,7 @@ function registrationRejection(registration) {
176
183
  }
177
184
 
178
185
  /**
179
- * @param {RouteRegistration["method"]} method
186
+ * @param {string | readonly string[]} method
180
187
  * @param {string} path
181
188
  */
182
189
  function describeRoute(method, path) {
@@ -241,15 +248,16 @@ function declarationsOfModule(declaration, warn) {
241
248
  function routesOfModule(module, warn) {
242
249
  const { id, priority = 0 } = module;
243
250
  return module.declared.routes.flatMap((registration, order) => {
251
+ const methods = methodsOf(registration.method);
244
252
  const reason = registrationRejection(registration);
245
- if (reason) {
253
+ if (reason || !methods) {
246
254
  warn(
247
255
  `Frontend module ${id} declares route ${describeRoute(registration.method, registration.path)}, which is ignored: ${reason}.`,
248
256
  );
249
257
  return [];
250
258
  }
251
259
  const compiled = compilePath(registration.path);
252
- return methodsOf(registration.method).map((method) => ({
260
+ return methods.map((method) => ({
253
261
  ...compiled,
254
262
  id,
255
263
  priority,
@@ -399,70 +407,6 @@ function resolveRoutes(modules, warn, publicEndpoints) {
399
407
  return routes;
400
408
  }
401
409
 
402
- const AUTH_HOOKS = ["refreshSession", "publicSession"];
403
-
404
- /** @param {unknown} auth */
405
- function authRejection(auth) {
406
- if (!isNonNullObject(auth)) return "it is not an object";
407
- for (const hook of AUTH_HOOKS)
408
- if (auth[hook] !== undefined && !isFunction(auth[hook]))
409
- return `${hook} is not a function`;
410
- if (
411
- auth.signInPath !== undefined &&
412
- (!isString(auth.signInPath) ||
413
- !compilePathPattern(auth.signInPath) ||
414
- auth.signInPath.includes("*"))
415
- )
416
- return "signInPath is not a page path";
417
- if (
418
- auth.signInExempt !== undefined &&
419
- !(
420
- Array.isArray(auth.signInExempt) &&
421
- auth.signInExempt.every((pattern) => compilePathPattern(pattern))
422
- )
423
- )
424
- return "signInExempt is not a list of /x, /x/* or /x/** patterns";
425
- return undefined;
426
- }
427
-
428
- /**
429
- * The auth hooks: those of the highest-priority module that registers them.
430
- * A module registering them twice, or after another one, is reported.
431
- *
432
- * @param {DeclaredModule[]} modules
433
- * @param {Warn} warn
434
- */
435
- function resolveAuth(modules, warn) {
436
- /** @type {AuthProvider | undefined} */
437
- let provider;
438
- for (const module of modules) {
439
- for (const auth of module.declared.auth) {
440
- const reason = authRejection(auth);
441
- if (reason) {
442
- warn(
443
- `Frontend module ${module.id} registers auth hooks, which are ignored: ${reason}.`,
444
- );
445
- continue;
446
- }
447
- if (provider) {
448
- warn(
449
- `Frontend module ${module.id} registers auth hooks, which are ignored: ${provider.id} (priority ${provider.priority ?? 0}) provides them.`,
450
- );
451
- continue;
452
- }
453
- provider = {
454
- id: module.id,
455
- priority: module.priority ?? 0,
456
- signInPath: auth.signInPath,
457
- signInExempt: auth.signInExempt?.map(compilePathPattern),
458
- refreshSession: auth.refreshSession,
459
- publicSession: auth.publicSession,
460
- };
461
- }
462
- }
463
- return provider;
464
- }
465
-
466
410
  /**
467
411
  * The route rules: of two declarations of the same pattern, the module of
468
412
  * higher priority keeps it, with a warning.
@@ -482,18 +426,22 @@ function resolveRules(modules, warn) {
482
426
  continue;
483
427
  }
484
428
  for (const [pattern, rule] of Object.entries(rules)) {
485
- const reason = ruleRejection(pattern, rule);
429
+ const compiled = compilePathPattern(pattern);
486
430
  const current = kept.get(pattern);
487
- if (reason || current) {
431
+ const reason =
432
+ ruleRejection(pattern, rule) ??
433
+ (current &&
434
+ `${current.id} (priority ${current.priority}) declares it`);
435
+ if (!compiled || reason) {
488
436
  warn(
489
- `Frontend module ${module.id} declares the route rule "${pattern}", which is ignored: ${reason ?? `${current.id} (priority ${current.priority}) declares it`}.`,
437
+ `Frontend module ${module.id} declares the route rule "${pattern}", which is ignored: ${reason}.`,
490
438
  );
491
439
  continue;
492
440
  }
493
441
  kept.set(pattern, {
494
442
  id: module.id,
495
443
  priority: module.priority ?? 0,
496
- pattern: compilePathPattern(pattern),
444
+ pattern: compiled,
497
445
  rule,
498
446
  });
499
447
  }