@antelopejs/dms-frontend 0.9.3 → 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 (140) hide show
  1. package/README.md +46 -29
  2. package/dist/commands/build-action.d.ts +3 -0
  3. package/dist/commands/build-action.js +77 -8
  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 +4 -3
  8. package/dist/commands/dev.js +1 -1
  9. package/dist/commands/prepare-action.js +1 -1
  10. package/dist/commands/start-action.js +3 -24
  11. package/dist/commands/workspace-task.js +2 -1
  12. package/dist/config.js +1 -0
  13. package/dist/derived-outputs.d.ts +1 -0
  14. package/dist/derived-outputs.js +6 -4
  15. package/dist/discovery.js +17 -6
  16. package/dist/entry-weight.js +15 -0
  17. package/dist/fs-sync.js +7 -4
  18. package/dist/help.js +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/internal/built-workspace.d.ts +4 -0
  21. package/dist/internal/built-workspace.js +43 -0
  22. package/dist/internal/ensure.d.ts +1 -0
  23. package/dist/internal/ensure.js +8 -0
  24. package/dist/internal/locale-catalogs.d.ts +8 -3
  25. package/dist/internal/locale-catalogs.js +16 -6
  26. package/dist/internal/manifest-decoder.d.ts +7 -0
  27. package/dist/internal/manifest-decoder.js +81 -0
  28. package/dist/internal/own-release.d.ts +5 -0
  29. package/dist/internal/own-release.js +20 -0
  30. package/dist/internal/plain-object.d.ts +1 -0
  31. package/dist/internal/plain-object.js +6 -0
  32. package/dist/internal/prepared-workspace.d.ts +12 -0
  33. package/dist/internal/prepared-workspace.js +74 -0
  34. package/dist/internal/text-list.d.ts +1 -0
  35. package/dist/internal/text-list.js +10 -0
  36. package/dist/internal/vite-manifest.d.ts +6 -0
  37. package/dist/internal/vite-manifest.js +24 -0
  38. package/dist/layer-watch.js +25 -24
  39. package/dist/layers.d.ts +1 -1
  40. package/dist/layers.js +3 -1
  41. package/dist/locale-files.d.ts +6 -3
  42. package/dist/locale-files.js +15 -9
  43. package/dist/manifest.d.ts +1 -1
  44. package/dist/manifest.js +46 -32
  45. package/dist/materialize.js +6 -2
  46. package/dist/module-set.js +2 -1
  47. package/dist/node-options.d.ts +1 -0
  48. package/dist/node-options.js +8 -0
  49. package/dist/output.d.ts +15 -9
  50. package/dist/output.js +30 -18
  51. package/dist/ports.d.ts +3 -1
  52. package/dist/ports.js +1 -1
  53. package/dist/private-values.d.ts +1 -1
  54. package/dist/private-values.js +5 -3
  55. package/dist/project-modules.d.ts +5 -1
  56. package/dist/project-modules.js +23 -15
  57. package/dist/renderer-range.js +5 -3
  58. package/dist/server-process.js +1 -1
  59. package/dist/verify-source-build.js +10 -2
  60. package/dist/verify-source-client-bundle.d.ts +1 -0
  61. package/dist/verify-source-client-bundle.js +30 -0
  62. package/dist/verify-source-runner.js +36 -94
  63. package/dist/verify-source-sources.js +49 -11
  64. package/dist/verify-source-workspace.d.ts +2 -0
  65. package/dist/verify-source-workspace.js +111 -0
  66. package/dist/workspace-setup.d.ts +1 -0
  67. package/dist/workspace-setup.js +4 -3
  68. package/package.json +11 -7
  69. package/skills/dms-frontend/SKILL.md +1 -1
  70. package/templates/vue/app-runtime.ts +2 -9
  71. package/templates/vue/backend-boundary.mjs +132 -0
  72. package/templates/vue/client-chunking.mjs +2 -0
  73. package/templates/vue/client-server-guard.mjs +16 -0
  74. package/templates/vue/compress-assets.mjs +9 -1
  75. package/templates/vue/entry-weight.mjs +29 -3
  76. package/templates/vue/frontend-build.ts +4 -3
  77. package/templates/vue/frontend-module.ts +1 -0
  78. package/templates/vue/frontend-runtime/module-registration.ts +2 -2
  79. package/templates/vue/frontend-runtime/state.ts +2 -2
  80. package/templates/vue/frontend-runtime/types.ts +8 -2
  81. package/templates/vue/head-order.mjs +10 -2
  82. package/templates/vue/icon-api.mjs +2 -0
  83. package/templates/vue/icon-scan.mjs +4 -0
  84. package/templates/vue/server/auth/backend.mjs +42 -5
  85. package/templates/vue/server/auth/client-ip.mjs +52 -4
  86. package/templates/vue/server/auth/establish-endpoints.mjs +11 -7
  87. package/templates/vue/server/auth/session.mjs +86 -4
  88. package/templates/vue/server/auth-declarations.mjs +46 -0
  89. package/templates/vue/server/auth-hooks.mjs +55 -7
  90. package/templates/vue/server/backend-proxies.mjs +78 -18
  91. package/templates/vue/server/backend-request.mjs +61 -0
  92. package/templates/vue/server/backend-response.mjs +23 -6
  93. package/templates/vue/server/built-files.mjs +7 -0
  94. package/templates/vue/server/cli-report.mjs +75 -12
  95. package/templates/vue/server/client-error.mjs +5 -0
  96. package/templates/vue/server/client-manifest.mjs +23 -1
  97. package/templates/vue/server/content.mjs +40 -10
  98. package/templates/vue/server/dev-private-files.mjs +17 -1
  99. package/templates/vue/server/dev-styles.mjs +38 -0
  100. package/templates/vue/server/email-bundle-watch.mjs +9 -3
  101. package/templates/vue/server/email.mjs +14 -6
  102. package/templates/vue/server/favicon.mjs +3 -0
  103. package/templates/vue/server/generated-files.mjs +221 -0
  104. package/templates/vue/server/homepage.mjs +21 -9
  105. package/templates/vue/server/html-tag.mjs +4 -0
  106. package/templates/vue/server/icons.mjs +93 -16
  107. package/templates/vue/server/in-place-render.mjs +35 -0
  108. package/templates/vue/server/inertia.mjs +76 -3
  109. package/templates/vue/server/module-event.mjs +118 -25
  110. package/templates/vue/server/module-route-types.mjs +78 -0
  111. package/templates/vue/server/module-routes.mjs +151 -96
  112. package/templates/vue/server/module-server.mjs +121 -40
  113. package/templates/vue/server/module-testing.mjs +36 -12
  114. package/templates/vue/server/page-props.mjs +94 -3
  115. package/templates/vue/server/page-redirects.mjs +37 -0
  116. package/templates/vue/server/path-patterns.mjs +35 -12
  117. package/templates/vue/server/proxy-compression.mjs +22 -0
  118. package/templates/vue/server/proxy-headers.mjs +12 -2
  119. package/templates/vue/server/proxy-redirects.mjs +21 -5
  120. package/templates/vue/server/proxy-request.mjs +58 -0
  121. package/templates/vue/server/proxy-transfers.mjs +44 -10
  122. package/templates/vue/server/public-endpoints.mjs +139 -28
  123. package/templates/vue/server/public-options.mjs +20 -6
  124. package/templates/vue/server/request-failure.mjs +24 -1
  125. package/templates/vue/server/request-routing.mjs +32 -10
  126. package/templates/vue/server/request-target.mjs +30 -11
  127. package/templates/vue/server/route-rules.mjs +48 -18
  128. package/templates/vue/server/ssr-module.mjs +32 -0
  129. package/templates/vue/server/static-files.mjs +35 -7
  130. package/templates/vue/server/type-guards.mjs +71 -0
  131. package/templates/vue/server/vite-errors.mjs +45 -7
  132. package/templates/vue/server/web-body.mjs +5 -1
  133. package/templates/vue/server-module.ts +12 -2
  134. package/templates/vue/server.mjs +162 -157
  135. package/templates/vue/sfc-global-types.mjs +112 -13
  136. package/templates/vue/ssr-renderer.ts +2 -2
  137. package/templates/vue/ui-inertia-page.mjs +16 -1
  138. package/templates/vue/ui-kbd.mjs +2 -1
  139. package/templates/vue/ui-request-state.mjs +2 -1
  140. package/templates/vue/vite.config.ts +9 -0
@@ -1,5 +1,21 @@
1
+ import { isString } from "../type-guards.mjs";
2
+
1
3
  const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
2
4
 
5
+ /**
6
+ * A request, whose socket is a TLS one (`encrypted`) when the server itself
7
+ * terminates TLS.
8
+ *
9
+ * @typedef {import("node:http").IncomingMessage & {
10
+ * socket: import("node:net").Socket & { encrypted?: boolean }
11
+ * }} ForwardedRequest
12
+ */
13
+
14
+ /**
15
+ * @param {string | undefined} value A header name from the environment
16
+ * @returns {string | undefined} The lowercase name, `undefined` when it is not
17
+ * a header name
18
+ */
3
19
  function headerName(value) {
4
20
  const name = (value ?? "").trim().toLowerCase();
5
21
  return HEADER_NAME.test(name) ? name : undefined;
@@ -17,18 +33,26 @@ function trustedHops() {
17
33
  * so its presence also vouches for the platform's `X-Forwarded-*`. On a path
18
34
  * the platform does not front (a private call from another service), it is
19
35
  * absent and the request falls back to `DMS_TRUSTED_PROXY_HOPS`.
36
+ *
37
+ * @param {ForwardedRequest} request
38
+ * @returns {string | undefined}
20
39
  */
21
40
  function platformClientIp(request) {
22
41
  const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
23
42
  if (!name) return undefined;
24
43
  const value = request.headers?.[name];
25
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
44
+ return isString(value) && value.trim() ? value.trim() : undefined;
26
45
  }
27
46
 
47
+ /** @param {ForwardedRequest} request */
28
48
  function isProxyTrusted(request) {
29
49
  return Boolean(platformClientIp(request)) || trustedHops() > 0;
30
50
  }
31
51
 
52
+ /**
53
+ * @param {ForwardedRequest} request
54
+ * @returns {string}
55
+ */
32
56
  export function requestScheme(request) {
33
57
  if (isProxyTrusted(request)) {
34
58
  const forwarded = String(request.headers["x-forwarded-proto"] ?? "")
@@ -41,6 +65,10 @@ export function requestScheme(request) {
41
65
  return request.socket?.encrypted ? "https" : "http";
42
66
  }
43
67
 
68
+ /**
69
+ * @param {ForwardedRequest} request
70
+ * @returns {string | undefined}
71
+ */
44
72
  function requestHost(request) {
45
73
  if (!isProxyTrusted(request)) return request.headers.host;
46
74
  return (
@@ -62,8 +90,12 @@ function requestHost(request) {
62
90
  * proxy wrote it (`DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller
63
91
  * could have, and the socket's address replaces it. The scheme and the host
64
92
  * are read the same way as the CSRF origin check reads them.
93
+ *
94
+ * @param {ForwardedRequest} request
95
+ * @returns {Record<string, string>}
65
96
  */
66
97
  export function forwardedHeaders(request) {
98
+ /** @type {Record<string, string>} */
67
99
  const headers = {
68
100
  "x-forwarded-for": forwardedFor(request),
69
101
  "x-forwarded-proto": requestScheme(request),
@@ -74,6 +106,10 @@ export function forwardedHeaders(request) {
74
106
  return headers;
75
107
  }
76
108
 
109
+ /**
110
+ * @param {ForwardedRequest} request
111
+ * @returns {string}
112
+ */
77
113
  function forwardedFor(request) {
78
114
  const platformIp = platformClientIp(request);
79
115
  if (platformIp) return platformIp;
@@ -92,12 +128,19 @@ function forwardedFor(request) {
92
128
  * address this server resolved, so the backend reads the same address
93
129
  * without counting hops. Always written, never relayed: a value the caller
94
130
  * sent is replaced. None when the variable is unset.
131
+ *
132
+ * @param {ForwardedRequest} request
133
+ * @returns {Record<string, string>}
95
134
  */
96
135
  export function clientIpHeader(request) {
97
136
  const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
98
137
  return name ? { [name]: clientIp(request) } : {};
99
138
  }
100
139
 
140
+ /**
141
+ * @param {ForwardedRequest} request
142
+ * @returns {boolean}
143
+ */
101
144
  export function isSameOrigin(request) {
102
145
  const origin = request.headers.origin;
103
146
  if (!origin) return false;
@@ -111,6 +154,10 @@ export function isSameOrigin(request) {
111
154
  }
112
155
  }
113
156
 
157
+ /**
158
+ * @param {ForwardedRequest} request
159
+ * @returns {string}
160
+ */
114
161
  export function clientIp(request) {
115
162
  const platformIp = platformClientIp(request);
116
163
  if (platformIp) return platformIp;
@@ -136,6 +183,9 @@ const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
136
183
  * from a trusted reverse proxy (the platform's client IP header, or
137
184
  * `DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller could have set
138
185
  * it, so nothing is relayed.
186
+ *
187
+ * @param {ForwardedRequest} request
188
+ * @returns {Record<string, string>}
139
189
  */
140
190
  export function clientCountryHeader(request) {
141
191
  const name = headerName(
@@ -143,9 +193,7 @@ export function clientCountryHeader(request) {
143
193
  );
144
194
  if (!name || !isProxyTrusted(request)) return {};
145
195
  const value = request.headers?.[name];
146
- return typeof value === "string" && value.trim()
147
- ? { [name]: value.trim() }
148
- : {};
196
+ return isString(value) && value.trim() ? { [name]: value.trim() } : {};
149
197
  }
150
198
 
151
199
  /**
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { isObjectLike, isString } from "../type-guards.mjs";
2
3
 
3
4
  /**
4
5
  * Absolute backend API paths only: no scheme, no authority, no query string,
@@ -17,6 +18,7 @@ const DECLARED_ENDPOINTS_FILE = new URL(
17
18
  import.meta.url,
18
19
  );
19
20
 
21
+ /** @type {unknown[] | undefined} */
20
22
  let declaredEndpoints;
21
23
 
22
24
  /**
@@ -28,13 +30,15 @@ let declaredEndpoints;
28
30
  * workspace built from a DMS that predates the declaration simply has no file,
29
31
  * and the list falls back to whatever the environment names.
30
32
  *
31
- * @param file Generated file to read
32
- * @returns The declared paths, unfiltered
33
+ * @param {URL | string} [file] Generated file to read
34
+ * @returns {unknown[]} The declared paths, unfiltered
33
35
  */
34
36
  export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
35
37
  try {
38
+ /** @type {unknown} */
36
39
  const parsed = JSON.parse(readFileSync(file, "utf8"));
37
- return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
40
+ const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
41
+ return Array.isArray(endpoints) ? endpoints : [];
38
42
  } catch {
39
43
  return [];
40
44
  }
@@ -54,8 +58,8 @@ export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
54
58
  * (`DMS_AUTH_ESTABLISH_ENDPOINTS`). The module that owns sign-in reads them as
55
59
  * `event.app.authEstablishEndpoints`.
56
60
  *
57
- * @param declaration Comma-separated absolute backend paths from the environment
58
- * @param declared Paths declared by the backend's frontend modules
61
+ * @param {string} [declaration] Comma-separated absolute backend paths from the environment
62
+ * @param {unknown[]} [declared] Paths declared by the backend's frontend modules
59
63
  * @returns The well-formed backend API paths, without duplicates
60
64
  */
61
65
  export function allowedEstablishEndpoints(
@@ -67,8 +71,8 @@ export function allowedEstablishEndpoints(
67
71
  .map((entry) => entry.trim());
68
72
  return [
69
73
  ...new Set(
70
- [...declared, ...fromEnvironment].filter(
71
- (entry) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
74
+ [...declared, ...fromEnvironment].flatMap((entry) =>
75
+ isString(entry) && BACKEND_API_PATH.test(entry) ? [entry] : [],
72
76
  ),
73
77
  ),
74
78
  ];
@@ -9,6 +9,21 @@ export const SESSION_COOKIE = "dms_session";
9
9
  const REQUEST_SESSION = Symbol("dms-request-session");
10
10
  const MAX_AGE = 60 * 60 * 24 * 30;
11
11
 
12
+ /**
13
+ * The session a module writes: the `DmsSession` of `#dms/server`.
14
+ *
15
+ * @typedef {import("../../server-module.ts").DmsSession} Session
16
+ */
17
+
18
+ /**
19
+ * A request, and the session `setRequestSession` set on it for the rest of the
20
+ * request.
21
+ *
22
+ * @typedef {import("node:http").IncomingMessage & {
23
+ * [REQUEST_SESSION]?: Session | undefined
24
+ * }} SessionRequest
25
+ */
26
+
12
27
  /**
13
28
  * A suffix that keeps this instance's cookies apart from another's on the same
14
29
  * host. Browsers scope cookies by hostname, not port: without it, two DMS
@@ -31,7 +46,11 @@ function cookieScope() {
31
46
  return createHash("sha256").update(canonical).digest("hex").slice(0, 12);
32
47
  }
33
48
 
34
- /** The name `name` takes in this instance's cookie jar. */
49
+ /**
50
+ * The name `name` takes in this instance's cookie jar.
51
+ *
52
+ * @param {string} name
53
+ */
35
54
  export function scopedCookieName(name) {
36
55
  return `${name}_${cookieScope()}`;
37
56
  }
@@ -46,6 +65,10 @@ function key() {
46
65
  return createHash("sha256").update(secret).digest();
47
66
  }
48
67
 
68
+ /**
69
+ * @param {unknown} value A session, or the value of a module's sealed cookie
70
+ * @returns {string}
71
+ */
49
72
  function encode(value) {
50
73
  const iv = randomBytes(12);
51
74
  const cipher = createCipheriv("aes-256-gcm", key(), iv);
@@ -58,6 +81,11 @@ function encode(value) {
58
81
  );
59
82
  }
60
83
 
84
+ /**
85
+ * @param {string} value The cookie's value
86
+ * @returns {any} What was sealed, as the JSON it was written from, `undefined`
87
+ * when it does not open
88
+ */
61
89
  function decode(value) {
62
90
  try {
63
91
  const payload = Buffer.from(value, "base64url");
@@ -68,13 +96,21 @@ function decode(value) {
68
96
  );
69
97
  decipher.setAuthTag(payload.subarray(12, 28));
70
98
  return JSON.parse(
71
- Buffer.concat([decipher.update(payload.subarray(28)), decipher.final()]),
99
+ Buffer.concat([
100
+ decipher.update(payload.subarray(28)),
101
+ decipher.final(),
102
+ ]).toString(),
72
103
  );
73
104
  } catch {
74
105
  return undefined;
75
106
  }
76
107
  }
77
108
 
109
+ /**
110
+ * @param {import("node:http").IncomingMessage} request
111
+ * @param {string} name
112
+ * @returns {string | undefined}
113
+ */
78
114
  function cookieValue(request, name) {
79
115
  const scoped = scopedCookieName(name);
80
116
  const cookies = request.headers.cookie?.split(";") ?? [];
@@ -86,6 +122,9 @@ function cookieValue(request, name) {
86
122
  /**
87
123
  * Cookie attributes. Without a `maxAge` the cookie lasts as long as the
88
124
  * browser session: it is gone once the browser is closed.
125
+ *
126
+ * @param {number | undefined} maxAge
127
+ * @param {string} [path]
89
128
  */
90
129
  function attributes(maxAge, path = "/") {
91
130
  const secure = process.env.DMS_COOKIE_SECURE !== "false" ? "; Secure" : "";
@@ -93,32 +132,56 @@ function attributes(maxAge, path = "/") {
93
132
  return `Path=${path}; HttpOnly; SameSite=Lax${lifetime}${secure}`;
94
133
  }
95
134
 
96
- /** A session outlives the browser only when it is written `persistent: true`. */
135
+ /**
136
+ * A session outlives the browser only when it is written `persistent: true`.
137
+ *
138
+ * @param {Session} session
139
+ */
97
140
  function lifetime(session) {
98
141
  return session.persistent === true ? MAX_AGE : undefined;
99
142
  }
100
143
 
144
+ /**
145
+ * @param {import("node:http").ServerResponse} response
146
+ * @param {string} name
147
+ * @param {string} value
148
+ * @param {string} cookieAttributes
149
+ */
101
150
  function appendCookie(response, name, value, cookieAttributes) {
102
151
  const cookie = `${scopedCookieName(name)}=${value}; ${cookieAttributes}`;
103
152
  const current = response.getHeader("set-cookie");
104
153
  response.setHeader(
105
154
  "set-cookie",
106
155
  current
107
- ? [cookie, ...(Array.isArray(current) ? current : [current])]
156
+ ? [cookie, ...(Array.isArray(current) ? current : [String(current)])]
108
157
  : cookie,
109
158
  );
110
159
  }
111
160
 
161
+ /**
162
+ * @param {SessionRequest} request
163
+ * @returns {Session | undefined} The session set for the request, else the one
164
+ * the cookie seals, `undefined` when there is none
165
+ */
112
166
  export function readSession(request) {
113
167
  if (REQUEST_SESSION in request) return request[REQUEST_SESSION];
114
168
  const value = cookieValue(request, SESSION_COOKIE);
169
+ // oxlint-disable-next-line typescript/no-unsafe-return -- `decode` returns the JSON this server sealed under `SESSION_COOKIE`, untyped because `JSON.parse` is; the AES-GCM tag rejects any cookie it did not write.
115
170
  return value ? decode(value) : undefined;
116
171
  }
117
172
 
173
+ /**
174
+ * @param {SessionRequest} request
175
+ * @param {Session | undefined} session
176
+ */
118
177
  export function setRequestSession(request, session) {
119
178
  request[REQUEST_SESSION] = session;
120
179
  }
121
180
 
181
+ /**
182
+ * @param {import("node:http").ServerResponse} response
183
+ * @param {Session} session
184
+ */
122
185
  export function writeSession(response, session) {
123
186
  appendCookie(
124
187
  response,
@@ -128,19 +191,38 @@ export function writeSession(response, session) {
128
191
  );
129
192
  }
130
193
 
194
+ /** @param {import("node:http").ServerResponse} response */
131
195
  export function clearSession(response) {
132
196
  appendCookie(response, SESSION_COOKIE, "", attributes(0));
133
197
  }
134
198
 
199
+ /**
200
+ * @param {import("node:http").IncomingMessage} request
201
+ * @param {string} name
202
+ * @returns {unknown} What was sealed, as the JSON it was written from,
203
+ * `undefined` when there is none or it does not open
204
+ */
135
205
  export function readSealedCookie(request, name) {
136
206
  const value = cookieValue(request, name);
137
207
  return value ? decode(value) : undefined;
138
208
  }
139
209
 
210
+ /**
211
+ * @param {import("node:http").ServerResponse} response
212
+ * @param {string} name
213
+ * @param {unknown} value
214
+ * @param {number | undefined} maxAge
215
+ * @param {string} [path]
216
+ */
140
217
  export function writeSealedCookie(response, name, value, maxAge, path = "/") {
141
218
  appendCookie(response, name, encode(value), attributes(maxAge, path));
142
219
  }
143
220
 
221
+ /**
222
+ * @param {import("node:http").ServerResponse} response
223
+ * @param {string} name
224
+ * @param {string} [path]
225
+ */
144
226
  export function clearSealedCookie(response, name, path = "/") {
145
227
  appendCookie(response, name, "", attributes(0, path));
146
228
  }
@@ -0,0 +1,46 @@
1
+ import { authRejection } from "./auth-hooks.mjs";
2
+ import { compilePathPattern } from "./path-patterns.mjs";
3
+
4
+ /**
5
+ * @import { AuthProvider, DeclaredModule } from "./module-route-types.mjs"
6
+ */
7
+
8
+ /**
9
+ * The auth hooks: those of the highest-priority module that registers them.
10
+ * A module registering them twice, or after another one, is reported.
11
+ *
12
+ * @param {DeclaredModule[]} modules
13
+ * @param {(message: string) => void} warn
14
+ */
15
+ export function resolveAuth(modules, warn) {
16
+ /** @type {AuthProvider | undefined} */
17
+ let provider;
18
+ for (const module of modules) {
19
+ for (const auth of module.declared.auth) {
20
+ const reason = authRejection(auth);
21
+ if (reason) {
22
+ warn(
23
+ `Frontend module ${module.id} registers auth hooks, which are ignored: ${reason}.`,
24
+ );
25
+ continue;
26
+ }
27
+ if (provider) {
28
+ warn(
29
+ `Frontend module ${module.id} registers auth hooks, which are ignored: ${provider.id} (priority ${provider.priority ?? 0}) provides them.`,
30
+ );
31
+ continue;
32
+ }
33
+ provider = {
34
+ id: module.id,
35
+ priority: module.priority ?? 0,
36
+ signInPath: auth.signInPath,
37
+ signInExempt: auth.signInExempt?.flatMap(
38
+ (pattern) => compilePathPattern(pattern) ?? [],
39
+ ),
40
+ refreshSession: auth.refreshSession?.bind(auth),
41
+ publicSession: auth.publicSession?.bind(auth),
42
+ };
43
+ }
44
+ }
45
+ return provider;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { readSession, setRequestSession } from "./auth/session.mjs";
2
2
  import { createServerEvent } from "./module-event.mjs";
3
- import { compilePathPattern } from "./path-patterns.mjs";
3
+ import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
4
+ import { isFunction, isRecord, isString } from "./type-guards.mjs";
4
5
 
5
6
  const DEFAULT_SIGN_IN_PATH = "/auth";
6
7
  const DEFAULT_ONBOARDING_PATH = "/onboarding";
@@ -12,16 +13,58 @@ const HOOK_ROUTE = Object.freeze({ bodyLimit: 0 });
12
13
  * sign-in page and everything below it, whole segments only (`/auth` exempts
13
14
  * `/auth/callback`, never `/authors`), and the onboarding page. Derived from
14
15
  * the sign-in path in use, or a custom one would redirect to itself.
16
+ *
17
+ * @param {string} signInPath
15
18
  */
16
19
  function defaultExempt(signInPath) {
17
20
  const signIn = signInPath === "/" ? "/" : `${signInPath}/**`;
18
- 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;
19
59
  }
20
60
 
21
61
  /**
22
62
  * The page props `user` and `session` of `session`, from the provider's
23
63
  * `publicSession`. The server adds the `updatedAt` stamp it wrote itself,
24
64
  * which the browser compares to drop a page restored from its cache.
65
+ *
66
+ * @param {NonNullable<import("./module-route-types.mjs").AuthProvider["publicSession"]>} publicSession
67
+ * @param {import("./module-event.mjs").Session | undefined} session
25
68
  */
26
69
  function hookView(publicSession, session) {
27
70
  if (!session) return {};
@@ -40,9 +83,9 @@ function hookView(publicSession, session) {
40
83
  * `dms.server.ts`. Without one, a page carries no session and a refused session
41
84
  * is not renewed.
42
85
  *
43
- * @param provider The resolved auth hooks, `undefined` when no module
86
+ * @param {import("./module-route-types.mjs").AuthProvider | undefined} provider The resolved auth hooks, `undefined` when no module
44
87
  * registers any
45
- * @param context `(id) => ({ module, app })`, a module's event context
88
+ * @param {(id: string) => import("./module-server.mjs").ModuleContext} context `(id) => ({ module, app })`, a module's event context
46
89
  * @returns `{ provider, signInPath, isSignInExempt, signInLocation, view,
47
90
  * refresh }`
48
91
  */
@@ -54,16 +97,21 @@ export function serverAuth(provider, context) {
54
97
  return {
55
98
  provider: provider?.id,
56
99
  signInPath,
57
- isSignInExempt: (pathname) =>
100
+ isSignInExempt: (/** @type {string} */ pathname) =>
58
101
  exempt.some((pattern) => pattern.test(pathname)),
59
- signInLocation: (requestUrl) =>
102
+ signInLocation: (/** @type {string} */ requestUrl) =>
60
103
  `${signInPath}?redirect=${encodeURIComponent(requestUrl)}`,
61
104
  view: publicSession
62
- ? (session) => hookView(publicSession, session)
105
+ ? (
106
+ /** @type {import("./module-event.mjs").Session | undefined} */ session,
107
+ ) => hookView(publicSession, session)
63
108
  : () => ({}),
64
109
  /**
65
110
  * Renews the request's session after the backend refused it. Returns the
66
111
  * new session, which the rest of the request runs with, or `undefined`.
112
+ *
113
+ * @param {import("./request-target.mjs").ServerRequest} request
114
+ * @param {import("node:http").ServerResponse} response
67
115
  */
68
116
  async refresh(request, response) {
69
117
  if (!refreshSession) return undefined;
@@ -1,4 +1,36 @@
1
- import { compilePathPattern } from "./path-patterns.mjs";
1
+ import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
2
+ import { isNonNullObject, isString } from "./type-guards.mjs";
3
+
4
+ /** @typedef {import("../server-module.ts").DmsBackendProxyOptions} ProxyOptions */
5
+ /**
6
+ * A backend prefix the server proxies, compiled from a pattern `proxyRejection`
7
+ * accepted.
8
+ *
9
+ * @typedef {object} BackendProxy
10
+ * @property {import("./path-patterns.mjs").CompiledPathPattern} pattern
11
+ * @property {string} path The pattern as declared
12
+ * @property {string} owner Who declared it
13
+ * @property {NonNullable<ProxyOptions["auth"]>} auth
14
+ * @property {number} [maxBodyBytes]
15
+ */
16
+ /**
17
+ * What `registerBackendProxy` declared, per call.
18
+ *
19
+ * @typedef {object} ProxyDeclaration
20
+ * @property {string} id The declaring frontend module
21
+ * @property {string} pattern
22
+ * @property {ProxyOptions} [options]
23
+ */
24
+ /**
25
+ * What a backend module declared in the manifest, or an operator in
26
+ * `DMS_BACKEND_PROXIES`.
27
+ *
28
+ * @typedef {object} ManifestProxy
29
+ * @property {string} [module]
30
+ * @property {string} [path]
31
+ * @property {ProxyOptions["auth"]} [auth]
32
+ * @property {number} [maxBodyBytes]
33
+ */
2
34
 
3
35
  /** How the proxy authenticates a request to the backend. */
4
36
  export const PROXY_AUTH_MODES = Object.freeze(["session", "none"]);
@@ -6,8 +38,10 @@ const DEFAULT_AUTH = "session";
6
38
  /** The renderer's own paths, never a backend's. */
7
39
  const RENDERER_PREFIXES = ["/api/_dms", "/@", "/assets", "/node_modules"];
8
40
 
41
+ /** @param {string} pattern */
9
42
  function literalPrefix(pattern) {
10
43
  const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
44
+ /** @type {string[]} */
11
45
  const literal = [];
12
46
  for (const segment of segments) {
13
47
  if (segment === "*" || segment === "**") break;
@@ -16,10 +50,16 @@ function literalPrefix(pattern) {
16
50
  return `/${literal.join("/")}`;
17
51
  }
18
52
 
19
- /** Why a proxy declaration is ignored, or `undefined`. */
53
+ /**
54
+ * Why a proxy declaration is ignored, or `undefined`.
55
+ *
56
+ * @param {string | undefined} pattern
57
+ * @param {ProxyOptions | undefined} options
58
+ */
20
59
  export function proxyRejection(pattern, options) {
21
60
  const compiled = compilePathPattern(pattern);
22
- 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}`;
23
63
  const prefix = literalPrefix(pattern);
24
64
  if (prefix === "/")
25
65
  return "its first segment is not literal: it would take every page";
@@ -29,7 +69,7 @@ export function proxyRejection(pattern, options) {
29
69
  )
30
70
  )
31
71
  return `${RENDERER_PREFIXES.join(", ")} are the renderer's own paths`;
32
- if (options !== undefined && (typeof options !== "object" || !options))
72
+ if (options !== undefined && !isNonNullObject(options))
33
73
  return "its options are not an object";
34
74
  if (options?.auth !== undefined && !PROXY_AUTH_MODES.includes(options.auth))
35
75
  return `auth is not one of ${PROXY_AUTH_MODES.map((mode) => `"${mode}"`).join(", ")}`;
@@ -41,10 +81,16 @@ export function proxyRejection(pattern, options) {
41
81
  return undefined;
42
82
  }
43
83
 
44
- function compileProxy(pattern, options, owner) {
84
+ /**
85
+ * @param {import("./path-patterns.mjs").CompiledPathPattern} compiled A pattern `proxyRejection` accepted
86
+ * @param {ProxyOptions | undefined} options
87
+ * @param {string} owner Who declared it
88
+ * @returns {BackendProxy}
89
+ */
90
+ function compileProxy(compiled, options, owner) {
45
91
  return {
46
- pattern: compilePathPattern(pattern),
47
- path: pattern,
92
+ pattern: compiled,
93
+ path: compiled.pattern,
48
94
  owner,
49
95
  auth: options?.auth ?? DEFAULT_AUTH,
50
96
  maxBodyBytes: options?.maxBodyBytes,
@@ -60,26 +106,35 @@ function compileProxy(pattern, options, owner) {
60
106
  * (`backendProxies`, next to its `publicEndpoints`). A path no prefix covers is
61
107
  * never sent to the backend.
62
108
  *
63
- * @param declared `{ id, pattern, options }` per `registerBackendProxy` call,
109
+ * @param {readonly ProxyDeclaration[]} declared `{ id, pattern, options }` per `registerBackendProxy` call,
64
110
  * from the highest-priority module down
65
- * @param manifest `{ module, path, auth?, maxBodyBytes? }` per backend
111
+ * @param {readonly (ManifestProxy | null | undefined)[]} manifest `{ module, path, auth?, maxBodyBytes? }` per backend
66
112
  * module declaration
67
- * @param warn Reports a declaration problem
68
- * @returns The proxies, the most specific pattern first: a request reaches
113
+ * @param {(message: string) => void} warn Reports a declaration problem
114
+ * @returns {BackendProxy[]} The proxies, the most specific pattern first: a request reaches
69
115
  * the first that matches
70
116
  */
71
117
  export function resolveBackendProxies(declared, manifest, warn) {
118
+ /** @type {Map<string | undefined, BackendProxy>} */
72
119
  const kept = new Map();
120
+ /**
121
+ * @param {string | undefined} pattern
122
+ * @param {ProxyOptions | undefined} options
123
+ * @param {string} owner
124
+ */
73
125
  const add = (pattern, options, owner) => {
74
- const reason = proxyRejection(pattern, options);
126
+ const compiled = compilePathPattern(pattern);
75
127
  const current = kept.get(pattern);
76
- if (reason || current) {
128
+ const reason =
129
+ proxyRejection(pattern, options) ??
130
+ (current && `${current.owner} declares it`);
131
+ if (!compiled || reason) {
77
132
  warn(
78
- `${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}.`,
79
134
  );
80
135
  return;
81
136
  }
82
- kept.set(pattern, compileProxy(pattern, options, owner));
137
+ kept.set(pattern, compileProxy(compiled, options, owner));
83
138
  };
84
139
  for (const { id, pattern, options } of declared)
85
140
  add(pattern, options, `Frontend module ${id}`);
@@ -94,7 +149,12 @@ export function resolveBackendProxies(declared, manifest, warn) {
94
149
  );
95
150
  }
96
151
 
97
- /** The backend proxy a path reaches, or `undefined`. */
152
+ /**
153
+ * The backend proxy a path reaches, or `undefined`.
154
+ *
155
+ * @param {readonly BackendProxy[]} proxies
156
+ * @param {string} pathname
157
+ */
98
158
  export function matchBackendProxy(proxies, pathname) {
99
159
  return proxies.find((proxy) => proxy.pattern.test(pathname));
100
160
  }
@@ -103,8 +163,8 @@ export function matchBackendProxy(proxies, pathname) {
103
163
  * The lines the server prints at startup: each backend prefix, who declared
104
164
  * it and how it authenticates, then who owns the rest.
105
165
  *
106
- * @param proxies What `resolveBackendProxies` returned
107
- * @param routeCount The number of module routes
166
+ * @param {readonly BackendProxy[]} proxies What `resolveBackendProxies` returned
167
+ * @param {number} routeCount The number of module routes
108
168
  */
109
169
  export function routingReport(proxies, routeCount) {
110
170
  const width = Math.max(0, ...proxies.map((proxy) => proxy.path.length));