@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
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
4
4
  import { serverAuth } from "./auth-hooks.mjs";
5
5
  import { answerModuleRoute } from "./module-event.mjs";
6
- import { isNonNullObject, isString } from "./type-guards.mjs";
6
+ import { isNonNullObject, isRecord, isString } from "./type-guards.mjs";
7
7
  import {
8
8
  matchModuleRoute,
9
9
  mayHaveModuleRoute,
@@ -81,19 +81,24 @@ function readJsonFile(url, fallback) {
81
81
  const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
82
82
 
83
83
  /**
84
- * The object holding `path`'s last key, when every key up to it is own.
84
+ * The object holding `path`'s last key, and that key, when every key up to it
85
+ * is own.
85
86
  *
86
87
  * @param {Record<string, unknown>} target
87
88
  * @param {string[]} path
89
+ * @returns {{ holder: Record<string, unknown>, key: string } | undefined}
88
90
  */
89
- function holderOf(target, path) {
91
+ function slotOf(target, path) {
92
+ const key = path.at(-1);
93
+ if (key === undefined || UNSAFE_KEYS.has(key)) return undefined;
90
94
  let node = target;
91
- for (const key of path.slice(0, -1)) {
92
- if (UNSAFE_KEYS.has(key) || !Object.hasOwn(node, key)) return undefined;
93
- node = node[key];
94
- if (!isNonNullObject(node)) return undefined;
95
+ for (const step of path.slice(0, -1)) {
96
+ if (UNSAFE_KEYS.has(step) || !Object.hasOwn(node, step)) return undefined;
97
+ const next = node[step];
98
+ if (!isRecord(next)) return undefined;
99
+ node = next;
95
100
  }
96
- return UNSAFE_KEYS.has(path.at(-1)) ? undefined : node;
101
+ return { holder: node, key };
97
102
  }
98
103
 
99
104
  /**
@@ -104,10 +109,12 @@ function holderOf(target, path) {
104
109
  * @param {string} value
105
110
  */
106
111
  function fillPath(target, path, value) {
107
- const holder = holderOf(target, path);
108
- const key = path.at(-1);
109
- if (holder && (holder[key] === undefined || holder[key] === ""))
110
- holder[key] = value;
112
+ const slot = slotOf(target, path);
113
+ if (
114
+ slot &&
115
+ (slot.holder[slot.key] === undefined || slot.holder[slot.key] === "")
116
+ )
117
+ slot.holder[slot.key] = value;
111
118
  }
112
119
 
113
120
  /**
@@ -118,10 +125,13 @@ function fillPath(target, path, value) {
118
125
  * @param {string} value
119
126
  */
120
127
  function fillEmptied(target, path, value) {
121
- const holder = holderOf(target, path);
122
- const key = path.at(-1);
123
- if (holder && Object.hasOwn(holder, key) && holder[key] === "")
124
- holder[key] = value;
128
+ const slot = slotOf(target, path);
129
+ if (
130
+ slot &&
131
+ Object.hasOwn(slot.holder, slot.key) &&
132
+ slot.holder[slot.key] === ""
133
+ )
134
+ slot.holder[slot.key] = value;
125
135
  }
126
136
 
127
137
  /**
@@ -327,7 +337,7 @@ export async function loadServerModules(devServer) {
327
337
  * module route serves is the module's for every method: another method is
328
338
  * answered `405`.
329
339
  *
330
- * @param {import("node:http").IncomingMessage} request The Node request
340
+ * @param {import("./request-target.mjs").ServerRequest} request The Node request
331
341
  * @param {import("node:http").ServerResponse} response The Node response
332
342
  * @param {string} pathname The request's normalized path
333
343
  * @param {ServerModules} modules What `loadServerModules` or `createServerModules` returned
@@ -2,7 +2,9 @@ import { createServer } from "node:http";
2
2
  import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
3
3
  import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
4
4
  import { createServerModules, handleModuleRoute } from "./module-server.mjs";
5
- import { failureStatus } from "./request-failure.mjs";
5
+ import { failureStatus, writeBadRequest } from "./request-failure.mjs";
6
+ import { isServerRequest } from "./request-target.mjs";
7
+ import { isString } from "./type-guards.mjs";
6
8
 
7
9
  /**
8
10
  * What the frontend server answers an API request whose handler failed.
@@ -52,6 +54,10 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
52
54
  warn: (message) => warnings.push(message),
53
55
  });
54
56
  const server = createServer((request, response) => {
57
+ if (!isServerRequest(request)) {
58
+ writeBadRequest(response);
59
+ return;
60
+ }
55
61
  const { pathname } = new URL(request.url, "http://frontend.local");
56
62
  handleModuleRoute(request, response, pathname, modules)
57
63
  .then((answered) => {
@@ -61,16 +67,25 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
61
67
  /** @param {unknown} error */ (error) => writeFailure(error, response),
62
68
  );
63
69
  });
64
- await new Promise((settle) => server.listen(0, "127.0.0.1", settle));
65
- const { port } = server.address();
70
+ /** @type {Promise<void>} */
71
+ const listening = new Promise((settle) =>
72
+ server.listen(0, "127.0.0.1", settle),
73
+ );
74
+ await listening;
75
+ const address = server.address();
76
+ if (address === null || isString(address))
77
+ throw new TypeError("The module test server is not listening on a port");
78
+ const { port } = address;
79
+ /** @returns {Promise<void>} */
80
+ const close = () =>
81
+ new Promise((settle) => {
82
+ server.closeAllConnections();
83
+ server.close(() => settle());
84
+ });
66
85
  return {
67
86
  url: `http://127.0.0.1:${port}`,
68
87
  modules,
69
88
  warnings,
70
- close: () =>
71
- new Promise((settle) => {
72
- server.closeAllConnections();
73
- server.close(() => settle());
74
- }),
89
+ close,
75
90
  };
76
91
  }
@@ -46,7 +46,7 @@ const NOT_FOUND = 404;
46
46
  *
47
47
  * @typedef {{
48
48
  * signInPath: string,
49
- * view: (session: unknown) => { user?: unknown, session?: unknown }
49
+ * view: (session: import("./auth/session.mjs").Session | undefined) => { user?: unknown, session?: unknown }
50
50
  * }} PageAuth
51
51
  */
52
52
 
@@ -66,8 +66,9 @@ export function pageRoute(props) {
66
66
  * browser holding the layout of another account or workspace, after a sign-in,
67
67
  * a sign-out or a switch, holds another key, so it is sent the right one.
68
68
  *
69
- * @param {unknown} session The sealed session: any JSON value, since only its
70
- * `accountId` and `activeTenantId` are read, as empty when it holds none
69
+ * @param {{ accountId?: string, activeTenantId?: string } | undefined} session
70
+ * The sealed session, of which only the `accountId` and `activeTenantId` are
71
+ * read, as empty when it holds none
71
72
  * @returns {string}
72
73
  */
73
74
  export function sharedOnceKey(session) {
@@ -118,7 +119,7 @@ function pagePath(url, withShared) {
118
119
  }
119
120
  for (const [name, value] of pageQuery) query.set(name, value);
120
121
  if (!withShared) query.set(SHARED_PROP, "false");
121
- return `/dms/page?${query}`;
122
+ return `/dms/page?${query.toString()}`;
122
123
  }
123
124
 
124
125
  /**
@@ -206,7 +207,7 @@ export async function propsForQuery(props, url, backend, auth) {
206
207
  * session's layout, sent now or already held: declaring it otherwise would let
207
208
  * a layout of another session pass for this one.
208
209
  *
209
- * @param {import("node:http").IncomingMessage} request
210
+ * @param {import("./request-target.mjs").ServerRequest} request
210
211
  * @param {PageBackend} backend
211
212
  * @param {PageAuth} auth
212
213
  * @returns {Promise<{
@@ -17,7 +17,7 @@ export function redirectToHomepage(request, response, pathname) {
17
17
  }
18
18
 
19
19
  /**
20
- * @param {import("node:http").IncomingMessage} request
20
+ * @param {import("./request-target.mjs").ServerRequest} request
21
21
  * @param {import("node:http").ServerResponse} response
22
22
  * @param {import("./page-props.mjs").PageProps} props
23
23
  * @param {import("./module-server.mjs").ServerModules["serverAuth"]} auth
@@ -2,6 +2,19 @@ import { isString } from "./type-guards.mjs";
2
2
 
3
3
  const SEGMENT = /^[A-Za-z0-9._~-]+$/;
4
4
 
5
+ /** The forms a path pattern takes, as the warnings name them. */
6
+ export const PATH_PATTERN_FORMS = "/x, /x/* or /x/**";
7
+
8
+ /**
9
+ * The wildcard a pattern may end with: what it matches below the literal
10
+ * segments, and how it ranks against a pattern with the same literals.
11
+ */
12
+ const TAILS = new Map([
13
+ ["*", { matcher: "/[^/]+", rank: 3 }],
14
+ ["**", { matcher: "(?:/.*)?", rank: 0 }],
15
+ ]);
16
+ const NO_TAIL = { matcher: "", rank: 1 };
17
+
5
18
  /**
6
19
  * @typedef {object} CompiledPathPattern
7
20
  * @property {string} pattern The pattern as declared
@@ -17,7 +30,8 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
17
30
  *
18
31
  * Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
19
32
  * page itself, `/x/*` one segment below it, `/x/**` the page and everything
20
- * below it. Nothing else: no prefix match inside a segment, no query string.
33
+ * below it. Nothing else: a wildcard only as the last segment, no prefix
34
+ * match inside a segment, no query string.
21
35
  *
22
36
  * @param {unknown} pattern
23
37
  * @returns {CompiledPathPattern | undefined}
@@ -25,26 +39,23 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
25
39
  export function compilePathPattern(pattern) {
26
40
  if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
27
41
  const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
28
- const deep = segments.at(-1) === "**";
29
- const literal = deep ? segments.slice(0, -1) : segments;
42
+ const last = TAILS.get(segments.at(-1) ?? "");
43
+ const literal = last ? segments.slice(0, -1) : segments;
30
44
  if (
31
45
  literal.some(
32
46
  (segment) =>
33
- segment !== "*" &&
34
- (!SEGMENT.test(segment) || segment === "." || segment === ".."),
47
+ !SEGMENT.test(segment) || segment === "." || segment === "..",
35
48
  )
36
49
  )
37
50
  return undefined;
51
+ const tail = last ?? NO_TAIL;
38
52
  const body = literal
39
- .map((segment) =>
40
- segment === "*" ? "/[^/]+" : `/${segment.replaceAll(".", "\\.")}`,
41
- )
53
+ .map((segment) => `/${segment.replaceAll(".", "\\.")}`)
42
54
  .join("");
43
- const matcher = new RegExp(`^${body}${deep ? "(?:/.*)?" : ""}/?$`);
44
- const literals = literal.filter((segment) => segment !== "*").length;
55
+ const matcher = new RegExp(`^${body}${tail.matcher}/?$`);
45
56
  return {
46
57
  pattern,
47
- specificity: literals * 4 + literal.length * 2 + (deep ? 0 : 1),
58
+ specificity: literal.length * 6 + tail.rank,
48
59
  test: (pathname) => matcher.test(pathname === "" ? "/" : pathname),
49
60
  };
50
61
  }
@@ -57,7 +57,7 @@ export function writeProxyHeaders(
57
57
  for (const name of [...HOP_BY_HOP_HEADERS, ...named]) delete headers[name];
58
58
  delete headers["set-cookie"];
59
59
  const cookies = upstream.headers.getSetCookie?.() ?? [];
60
- if (cookies.length) headers["set-cookie"] = cookies;
60
+ if (cookies.length) Object.assign(headers, { "set-cookie": cookies });
61
61
  if (headers.location && upstream.url)
62
62
  headers.location = relayedLocation(
63
63
  headers.location,
@@ -6,6 +6,7 @@ export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6
6
  * @returns {string | undefined}
7
7
  */
8
8
  function originOf(url) {
9
+ if (url === undefined) return undefined;
9
10
  try {
10
11
  return new URL(url).origin;
11
12
  } catch {
@@ -21,7 +21,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
21
21
  * the storage's signed URL, on the public origin the server itself may not
22
22
  * reach, which turned every download into a 502.
23
23
  *
24
- * @param {import("node:http").IncomingMessage} request
24
+ * @param {import("./request-target.mjs").ServerRequest} request
25
25
  * @param {import("node:http").ServerResponse} response
26
26
  * @param {import("./backend-proxies.mjs").BackendProxy} backendProxy
27
27
  * @param {(path: string) => boolean} isProxiedPath
@@ -36,6 +36,7 @@ export async function proxy(request, response, backendProxy, isProxiedPath) {
36
36
  if (response.closed) return;
37
37
  const abandoned = new AbortController();
38
38
  response.once("close", () => abandoned.abort());
39
+ /** @type {RequestInit} */
39
40
  const init = {
40
41
  signal: abandoned.signal,
41
42
  method: request.method,
@@ -1,3 +1,5 @@
1
+ import { isObjectLike } from "./type-guards.mjs";
2
+
1
3
  const PAYLOAD_TOO_LARGE = 413;
2
4
  const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
3
5
  /**
@@ -41,7 +43,7 @@ async function* limitedChunks(request, maxBodyBytes) {
41
43
  }
42
44
 
43
45
  /**
44
- * @param {import("node:http").IncomingMessage} request
46
+ * @param {import("./request-target.mjs").ServerRequest} request
45
47
  * @param {number | undefined} maxBodyBytes
46
48
  */
47
49
  function requestBody(request, maxBodyBytes) {
@@ -70,7 +72,7 @@ function declaresTooLargeBody(request, maxBodyBytes) {
70
72
  function isBodyTooLarge(error) {
71
73
  return (
72
74
  error instanceof BodyTooLargeError ||
73
- error?.cause instanceof BodyTooLargeError
75
+ (isObjectLike(error) && error.cause instanceof BodyTooLargeError)
74
76
  );
75
77
  }
76
78
 
@@ -93,7 +95,10 @@ function refuseTooLargeBody(request, response) {
93
95
  connection: "close",
94
96
  });
95
97
  response.write(TOO_LARGE_BODY);
96
- if (request.complete) return response.end();
98
+ if (request.complete) {
99
+ response.end();
100
+ return;
101
+ }
97
102
  const close = () => {
98
103
  clearTimeout(timer);
99
104
  response.end();
@@ -117,7 +122,7 @@ function refuseTooLargeBody(request, response) {
117
122
  * before a byte is passed on, a chunked body is cut off as soon as it goes
118
123
  * over.
119
124
  *
120
- * @param {import("node:http").IncomingMessage} request
125
+ * @param {import("./request-target.mjs").ServerRequest} request
121
126
  * @param {import("node:http").ServerResponse} response
122
127
  * @param {number | undefined} maxBodyBytes
123
128
  * @param {(
@@ -125,18 +130,19 @@ function refuseTooLargeBody(request, response) {
125
130
  * ) => Promise<Response>} send The backend request with the body to stream:
126
131
  * none for a GET or a HEAD, the request itself, or its bytes cut off past
127
132
  * the limit
128
- * @returns {Promise<Response | import("node:http").ServerResponse | undefined>}
129
- * The backend's answer, or once the body went over and `response` got the
130
- * 413: `undefined`, or `response` itself when the request had been read
131
- * already (`response.end()`'s own return)
133
+ * @returns {Promise<Response | undefined>} The backend's answer, or
134
+ * `undefined` once the body went over and `response` got the 413
132
135
  */
133
136
  export async function sendWithinLimit(request, response, maxBodyBytes, send) {
134
- if (declaresTooLargeBody(request, maxBodyBytes))
135
- return refuseTooLargeBody(request, response);
137
+ if (declaresTooLargeBody(request, maxBodyBytes)) {
138
+ refuseTooLargeBody(request, response);
139
+ return undefined;
140
+ }
136
141
  try {
137
142
  return await send(requestBody(request, maxBodyBytes));
138
143
  } catch (error) {
139
144
  if (!isBodyTooLarge(error)) throw error;
140
- return refuseTooLargeBody(request, response);
145
+ refuseTooLargeBody(request, response);
146
+ return undefined;
141
147
  }
142
148
  }
@@ -2,13 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { json } from "./auth/backend.mjs";
3
3
  import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./auth/client-ip.mjs";
4
4
  import { PROXY_AUTH_MODES } from "./backend-proxies.mjs";
5
- import {
6
- isNonNullObject,
7
- isNumber,
8
- isObjectLike,
9
- isRecord,
10
- isString,
11
- } from "./type-guards.mjs";
5
+ import { isNumber, isObjectLike, isRecord, isString } from "./type-guards.mjs";
12
6
 
13
7
  /**
14
8
  * A public endpoint, compiled from a declaration that passed every check.
@@ -96,7 +90,7 @@ export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
96
90
  * when the proxies are resolved.
97
91
  *
98
92
  * @param {unknown} entry
99
- * @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number }}
93
+ * @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number, module?: unknown }}
100
94
  */
101
95
  function isProxyDeclaration(entry) {
102
96
  return (
@@ -252,11 +246,11 @@ function pathMatcher(path) {
252
246
  * A declaration made usable, or `undefined` when any part of it is malformed:
253
247
  * a bad entry is dropped whole, never widened to what it might have meant.
254
248
  *
255
- * @param {PublicEndpointDeclaration | null | undefined} entry
249
+ * @param {unknown} entry
256
250
  * @returns {PublicEndpoint | undefined}
257
251
  */
258
252
  export function compilePublicEndpoint(entry) {
259
- if (!isNonNullObject(entry)) return undefined;
253
+ if (!isObjectLike(entry)) return undefined;
260
254
  if (!isString(entry.path) || !PUBLIC_PATH.test(entry.path)) return undefined;
261
255
  const methods = normalizedMethods(entry.methods);
262
256
  const headers = normalizedHeaders(entry.headers);
@@ -284,7 +278,9 @@ export function allowedPublicEndpoints(
284
278
  ) {
285
279
  return [...declared, ...environmentEndpoints(declaration)].flatMap(
286
280
  (entry) => {
287
- const endpoint = compilePublicEndpoint(entry);
281
+ const endpoint = isRecord(entry)
282
+ ? compilePublicEndpoint(entry)
283
+ : undefined;
288
284
  if (!endpoint)
289
285
  console.warn(
290
286
  `Ignoring malformed public endpoint ${JSON.stringify(entry)}: expected ` +
@@ -337,23 +333,23 @@ export function matchPublicEndpoint(
337
333
  * Origin. Under `auth: "session"`, a state-changing request needs one unless
338
334
  * it reaches a public endpoint, which is why the proxy never sends a public
339
335
  * endpoint the session: a cross-site form posting to it acts as nobody.
336
+ * Under either mode, a request that reaches a public endpoint carries the
337
+ * endpoint's declared headers.
340
338
  *
341
- * @param {import("node:http").IncomingMessage} request The incoming request
339
+ * @param {import("./request-target.mjs").ServerRequest} request The incoming request
342
340
  * @param {ReadonlySet<string>} safeMethods Methods that change nothing and need no Origin
343
341
  * @param {import("./backend-proxies.mjs").BackendProxy} proxy The backend proxy the request reached
344
342
  * @returns `{ anonymous, headers, isRefused }`
345
343
  */
346
344
  export function proxyAccess(request, safeMethods, proxy) {
347
- if (proxy.auth === "none")
348
- return { anonymous: true, headers: [], isRefused: false };
349
345
  const publicEndpoint = matchPublicEndpoint(
350
346
  request.method,
351
347
  new URL(request.url, "http://frontend.local").pathname,
352
348
  );
353
- if (publicEndpoint)
349
+ if (publicEndpoint || proxy.auth === "none")
354
350
  return {
355
351
  anonymous: true,
356
- headers: publicEndpoint.headers,
352
+ headers: publicEndpoint?.headers ?? [],
357
353
  isRefused: false,
358
354
  };
359
355
  return {
@@ -38,7 +38,7 @@ export function failureStatus(error) {
38
38
  */
39
39
  export function isResponseGone(error, response) {
40
40
  return (
41
- error?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
41
+ (isObjectLike(error) && error.code === "ERR_STREAM_PREMATURE_CLOSE") ||
42
42
  response.destroyed ||
43
43
  response.writableEnded ||
44
44
  response.headersSent
@@ -1,4 +1,5 @@
1
1
  import { matchBackendProxy } from "./backend-proxies.mjs";
2
+ import { isIconRequest } from "./icons.mjs";
2
3
  import { isRendererFilePath, moduleRouteMethods } from "./module-routes.mjs";
3
4
 
4
5
  /**
@@ -37,7 +38,11 @@ const PAGE_METHODS = new Set(["GET", "HEAD"]);
37
38
  * `{ kind: "not-found" }`
38
39
  */
39
40
  export function requestOwner(method, pathname, modules, hasRendererFile) {
40
- if (isRendererFilePath(pathname) || hasRendererFile(pathname))
41
+ if (
42
+ isIconRequest(pathname) ||
43
+ isRendererFilePath(pathname) ||
44
+ hasRendererFile(pathname)
45
+ )
41
46
  return { kind: "renderer" };
42
47
  const route = modules.routes.find((candidate) =>
43
48
  candidate.matcher.test(pathname),
@@ -1,4 +1,3 @@
1
- import { writeBadRequest } from "./request-failure.mjs";
2
1
  import { isString } from "./type-guards.mjs";
3
2
 
4
3
  const FRONTEND_ORIGIN = "http://frontend.local";
@@ -30,7 +29,7 @@ export function isOriginForm(target) {
30
29
  */
31
30
  export function backendUrl(
32
31
  target,
33
- backendBaseUrl = process.env.DMS_API_BASE_URL,
32
+ backendBaseUrl = process.env.DMS_API_BASE_URL ?? "",
34
33
  ) {
35
34
  const { pathname, search } = new URL(target, FRONTEND_ORIGIN);
36
35
  const url = new URL(new URL(backendBaseUrl).origin);
@@ -40,8 +39,24 @@ export function backendUrl(
40
39
  }
41
40
 
42
41
  /**
43
- * Answers 400 to a request whose target is not origin-form, before any route
44
- * reads it. Returns whether it answered.
42
+ * A request a server received, whose `url` and `method` Node always sets
43
+ * (`isServerRequest`).
44
+ *
45
+ * @typedef {import("node:http").IncomingMessage & { url: string, method: string }} ServerRequest
46
+ */
47
+
48
+ /**
49
+ * @template {import("node:http").IncomingMessage} Request
50
+ * @param {Request} request
51
+ * @returns {request is Request & ServerRequest}
52
+ */
53
+ export function isServerRequest(request) {
54
+ return isString(request.url) && isString(request.method);
55
+ }
56
+
57
+ /**
58
+ * Whether the request target is origin-form, the only kind the server routes:
59
+ * a request that fails it is answered 400 before any route reads it.
45
60
  *
46
61
  * The request target as the server reads it. Node hands `request.url` over
47
62
  * verbatim, so it is not always a path: `//host/x`, `/\host/x` and the absolute
@@ -50,10 +65,8 @@ export function backendUrl(
50
65
  * backend read the same normalized path from here.
51
66
  *
52
67
  * @param {import("node:http").IncomingMessage} request
53
- * @param {import("node:http").ServerResponse} response
68
+ * @returns {request is ServerRequest}
54
69
  */
55
- export function refuseForeignTarget(request, response) {
56
- if (isOriginForm(request.url)) return false;
57
- writeBadRequest(response);
58
- return true;
70
+ export function hasOriginFormTarget(request) {
71
+ return isOriginForm(request.url) && isString(request.method);
59
72
  }
@@ -1,5 +1,11 @@
1
- import { compilePathPattern } from "./path-patterns.mjs";
2
- import { isNonNullObject, isObjectLike, isString } from "./type-guards.mjs";
1
+ import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
2
+ import {
3
+ isNonNullObject,
4
+ isNumber,
5
+ isObjectLike,
6
+ isRecord,
7
+ isString,
8
+ } from "./type-guards.mjs";
3
9
 
4
10
  /** @typedef {import("../server-module.ts").DmsRouteRule} RouteRule */
5
11
  /** @typedef {RouteRule & { headers: Record<string, string> }} MergedRouteRule */
@@ -37,7 +43,7 @@ const RESERVED_HEADERS = new Set([
37
43
 
38
44
  /** @param {unknown} value */
39
45
  function isSeconds(value) {
40
- return Number.isSafeInteger(value) && value >= 0;
46
+ return isNumber(value) && Number.isSafeInteger(value) && value >= 0;
41
47
  }
42
48
 
43
49
  /** @param {unknown} headers */
@@ -63,7 +69,7 @@ function headersRejection(headers) {
63
69
  function cacheRejection(cache) {
64
70
  if (cache === undefined || cache === false) return undefined;
65
71
  if (
66
- !isNonNullObject(cache) ||
72
+ !isRecord(cache) ||
67
73
  cache.public !== true ||
68
74
  !isSeconds(cache.maxAge) ||
69
75
  (cache.staleWhileRevalidate !== undefined &&
@@ -85,8 +91,8 @@ function cacheRejection(cache) {
85
91
  */
86
92
  export function ruleRejection(pattern, rule) {
87
93
  if (!compilePathPattern(pattern))
88
- return "the pattern is not /x, /x/* or /x/**";
89
- if (!isNonNullObject(rule)) return "it is not an object";
94
+ return `the pattern is not ${PATH_PATTERN_FORMS}`;
95
+ if (!isRecord(rule)) return "it is not an object";
90
96
  if (rule.ssr !== undefined && rule.ssr !== false)
91
97
  return "ssr is either left out or false";
92
98
  return headersRejection(rule.headers) ?? cacheRejection(rule.cache);
@@ -1,5 +1,6 @@
1
1
  import { createReadStream, existsSync, statSync } from "node:fs";
2
2
  import { extname } from "node:path";
3
+ import { acceptedCodings } from "./content.mjs";
3
4
 
4
5
  /**
5
6
  * What a build or a module's `public/` directory may hold. A type missing
@@ -48,22 +49,26 @@ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
48
49
  */
49
50
  const REVALIDATED_CACHE = "no-cache";
50
51
 
52
+ /** The suffix the build gives the copy of a file in each coding. */
53
+ const VARIANT_SUFFIXES = { br: ".br", gzip: ".gz" };
54
+
51
55
  /** @param {string} path */
52
56
  const isFile = (path) => existsSync(path) && statSync(path).isFile();
53
57
 
54
58
  /**
55
- * The precompressed variant of `file` the client accepts, if the build wrote one.
59
+ * The precompressed variant of `file` the client wants most among those the
60
+ * build wrote, or `file` itself.
56
61
  *
57
62
  * @param {string} file
58
63
  * @param {import("node:http").IncomingMessage} request
59
64
  */
60
65
  function acceptedVariant(file, request) {
61
- const accepted = request.headers["accept-encoding"] ?? "";
62
- if (accepted.includes("br") && isFile(`${file}.br`))
63
- return { file: `${file}.br`, encoding: "br" };
64
- if (accepted.includes("gzip") && isFile(`${file}.gz`))
65
- return { file: `${file}.gz`, encoding: "gzip" };
66
- return { file, encoding: undefined };
66
+ const encoding = acceptedCodings(request).find((coding) =>
67
+ isFile(`${file}${VARIANT_SUFFIXES[coding]}`),
68
+ );
69
+ return encoding
70
+ ? { file: `${file}${VARIANT_SUFFIXES[encoding]}`, encoding }
71
+ : { file, encoding: undefined };
67
72
  }
68
73
 
69
74
  /**
@@ -1,3 +1,5 @@
1
+ import { isObjectLike, isString } from "./type-guards.mjs";
2
+
1
3
  const INTERNAL_ERROR_STATUS = 500;
2
4
  const ERROR_CONTENT_TYPE = "text/plain; charset=utf-8";
3
5
  const VITE_ERROR = Symbol("dms.viteError");
@@ -69,9 +71,18 @@ export function runViteMiddlewares(devServer, request, response) {
69
71
  });
70
72
  }
71
73
 
72
- /** Answers 500 with Vite's error message instead of rendering a page. */
74
+ /**
75
+ * Answers 500 with Vite's error message instead of rendering a page.
76
+ *
77
+ * @param {import("node:http").ServerResponse} response
78
+ * @param {unknown} error
79
+ */
73
80
  export function writeViteError(response, error) {
74
81
  response.statusCode = INTERNAL_ERROR_STATUS;
75
82
  response.setHeader("content-type", ERROR_CONTENT_TYPE);
76
- response.end(error?.message ?? String(error));
83
+ response.end(
84
+ isObjectLike(error) && isString(error.message)
85
+ ? error.message
86
+ : String(error),
87
+ );
77
88
  }
@@ -17,10 +17,11 @@ import { pipeline } from "node:stream/promises";
17
17
  */
18
18
  export async function pipeWebBody(body, ...streams) {
19
19
  const response = streams.at(-1);
20
+ if (!response) throw new TypeError("pipeWebBody needs a response to write");
20
21
  if (response.closed || response.destroyed) {
21
22
  for (const stream of streams.slice(0, -1)) stream.destroy();
22
23
  await body.cancel().catch(() => {});
23
24
  return;
24
25
  }
25
- await pipeline(Readable.fromWeb(body), ...streams);
26
+ await pipeline([Readable.fromWeb(body), ...streams]);
26
27
  }
@@ -186,7 +186,8 @@ export interface DmsServerRouteOptions {
186
186
  * The session logic the frontend server runs outside any route, which the
187
187
  * module owning sign-in provides. Without `publicSession`, pages carry no
188
188
  * `user` and `session`; without `refreshSession`, a session the backend
189
- * refuses is not renewed; `signInPath` is `/auth` by default.
189
+ * refuses is not renewed; `signInPath` is `/auth` by default. The server
190
+ * calls each hook with the registered object as `this`.
190
191
  */
191
192
  export interface DmsServerAuth {
192
193
  /** Where a signed-out visit to a private page goes, with `?redirect=`. */