@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
@@ -20,11 +20,22 @@ const COMPRESSIBLE_TYPE =
20
20
  /^(?:text\/(?!event-stream)[\w.+-]+|application\/(?:[\w.-]+\+)?(?:json|xml|javascript|ecmascript)|image\/svg\+xml)\s*(?:;|$)/i;
21
21
  const BODILESS_STATUSES = new Set([204, 205, 304]);
22
22
 
23
+ /**
24
+ * What the proxy relays of a backend answer: a fetched `Response`, or the
25
+ * same fields without its content coding.
26
+ *
27
+ * @typedef {Pick<Response, "status" | "headers" | "body" | "url">} ProxiedAnswer
28
+ */
29
+
23
30
  /**
24
31
  * Whether the backend answer is one to compress on its way to the client.
25
32
  *
26
33
  * A response already encoded, or a range of the bytes, is not compressed:
27
34
  * compressing would change what the encoding or the range describes.
35
+ *
36
+ * @param {import("node:http").IncomingMessage} request
37
+ * @param {ProxiedAnswer} upstream
38
+ * @returns {boolean}
28
39
  */
29
40
  function isCompressible(request, upstream) {
30
41
  if (request.method === "HEAD" || !upstream.body) return false;
@@ -48,6 +59,9 @@ function isCompressible(request, upstream) {
48
59
  *
49
60
  * Streaming: an answer of unknown length (chunked JSON, streamed text) reaches
50
61
  * the client part by part, as the backend writes it.
62
+ *
63
+ * @param {import("node:http").IncomingMessage} request
64
+ * @param {ProxiedAnswer} upstream
51
65
  */
52
66
  function proxyEncoder(request, upstream) {
53
67
  return isCompressible(request, upstream)
@@ -64,6 +78,14 @@ function proxyEncoder(request, upstream) {
64
78
  *
65
79
  * A decoded partial representation cannot retain the encoded byte offsets, and
66
80
  * a complete 200 response is not manufactured from a partial 206 either.
81
+ *
82
+ * @param {import("node:http").IncomingMessage} request
83
+ * @param {import("node:http").ServerResponse} response
84
+ * @param {ProxiedAnswer} upstream
85
+ * @param {{
86
+ * fetched?: boolean,
87
+ * isProxiedPath?: (pathname: string) => boolean
88
+ * }} [options]
67
89
  */
68
90
  export async function relayAnswer(
69
91
  request,
@@ -15,7 +15,11 @@ const HOP_BY_HOP_HEADERS = [
15
15
  "upgrade",
16
16
  ];
17
17
 
18
- /** `Accept-Encoding` added to the backend's own `Vary`, once. */
18
+ /**
19
+ * `Accept-Encoding` added to the backend's own `Vary`, once.
20
+ *
21
+ * @param {string | undefined} vary
22
+ */
19
23
  function varyWithEncoding(vary) {
20
24
  const names = (vary ?? "")
21
25
  .split(",")
@@ -32,6 +36,12 @@ function varyWithEncoding(vary) {
32
36
  * known, and a strong validator no longer names these bytes. A `Location` on
33
37
  * the backend's origin becomes the same path on the frontend's when
34
38
  * `isProxiedPath` says the frontend proxies it (see `relayedLocation`).
39
+ *
40
+ * @param {import("node:http").ServerResponse} response
41
+ * @param {Pick<Response, "status" | "headers" | "url">} upstream
42
+ * @param {string | undefined} encoding
43
+ * @param {boolean} [varies]
44
+ * @param {(pathname: string) => boolean} [isProxiedPath]
35
45
  */
36
46
  export function writeProxyHeaders(
37
47
  response,
@@ -47,7 +57,7 @@ export function writeProxyHeaders(
47
57
  for (const name of [...HOP_BY_HOP_HEADERS, ...named]) delete headers[name];
48
58
  delete headers["set-cookie"];
49
59
  const cookies = upstream.headers.getSetCookie?.() ?? [];
50
- if (cookies.length) headers["set-cookie"] = cookies;
60
+ if (cookies.length) Object.assign(headers, { "set-cookie": cookies });
51
61
  if (headers.location && upstream.url)
52
62
  headers.location = relayedLocation(
53
63
  headers.location,
@@ -1,7 +1,12 @@
1
1
  /** The statuses that send the client to their `Location`. */
2
2
  export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
3
3
 
4
+ /**
5
+ * @param {string | URL | undefined} url
6
+ * @returns {string | undefined}
7
+ */
4
8
  function originOf(url) {
9
+ if (url === undefined) return undefined;
5
10
  try {
6
11
  return new URL(url).origin;
7
12
  } catch {
@@ -9,7 +14,13 @@ function originOf(url) {
9
14
  }
10
15
  }
11
16
 
12
- /** Whether `url` is on the backend's origin. */
17
+ /**
18
+ * Whether `url` is on the backend's origin.
19
+ *
20
+ * @param {string} url
21
+ * @param {string} [backendBaseUrl]
22
+ * @returns {boolean}
23
+ */
13
24
  export function isBackendUrl(
14
25
  url,
15
26
  backendBaseUrl = process.env.DMS_API_BASE_URL,
@@ -34,16 +45,18 @@ export function isBackendUrl(
34
45
  *
35
46
  * `//host` would name another host, so `/.` keeps the location a path.
36
47
  *
37
- * @param location The backend's `Location`
38
- * @param fetchedUrl The backend URL the answer came from
39
- * @param isProxiedPath `(pathname) => boolean`, whether the frontend server
40
- * sends that path to the backend
48
+ * @param {string} location The backend's `Location`
49
+ * @param {string | URL} fetchedUrl The backend URL the answer came from
50
+ * @param {(pathname: string) => boolean} [isProxiedPath] Whether the frontend
51
+ * server sends that path to the backend
52
+ * @returns {string}
41
53
  */
42
54
  export function relayedLocation(
43
55
  location,
44
56
  fetchedUrl,
45
57
  isProxiedPath = () => false,
46
58
  ) {
59
+ /** @type {URL} */
47
60
  let target;
48
61
  try {
49
62
  target = new URL(location, fetchedUrl);
@@ -60,6 +73,9 @@ export function relayedLocation(
60
73
  * The cookies of a `Response` a module route returns. A fetch that followed a
61
74
  * redirect to another site would set that site's cookies on the frontend's
62
75
  * origin: those are dropped.
76
+ *
77
+ * @param {Response} answer
78
+ * @returns {string[]}
63
79
  */
64
80
  export function relayableCookies(answer) {
65
81
  if (answer.redirected && !isBackendUrl(answer.url)) return [];
@@ -0,0 +1,58 @@
1
+ import { backendHeaders } from "./backend-request.mjs";
2
+ import { PLAIN_ANSWER_HEADERS, relayAnswer } from "./proxy-compression.mjs";
3
+ import { sendWithinLimit } from "./proxy-transfers.mjs";
4
+ import { proxyAccess, refuseCrossOrigin } from "./public-endpoints.mjs";
5
+ import { backendUrl } from "./request-target.mjs";
6
+
7
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
8
+
9
+ /**
10
+ * Sends a request to the backend through the proxy it reached.
11
+ * `isProxiedPath` says which backend paths a relayed `Location` may name on
12
+ * the frontend's origin.
13
+ *
14
+ * A client that left while the request was routed closed the response already,
15
+ * and would never abort the request below.
16
+ *
17
+ * Cancelling the body alone misses a client that leaves before the backend
18
+ * answers: its stream, an SSE feed above all, then stayed open for good.
19
+ *
20
+ * A backend redirect is the browser's to follow: Media sends a file's link to
21
+ * the storage's signed URL, on the public origin the server itself may not
22
+ * reach, which turned every download into a 502.
23
+ *
24
+ * @param {import("./request-target.mjs").ServerRequest} request
25
+ * @param {import("node:http").ServerResponse} response
26
+ * @param {import("./backend-proxies.mjs").BackendProxy} backendProxy
27
+ * @param {(path: string) => boolean} isProxiedPath
28
+ * @returns {Promise<true | undefined>}
29
+ */
30
+ export async function proxy(request, response, backendProxy, isProxiedPath) {
31
+ const access = proxyAccess(request, SAFE_METHODS, backendProxy);
32
+ if (access.isRefused) {
33
+ refuseCrossOrigin(response);
34
+ return true;
35
+ }
36
+ if (response.closed) return;
37
+ const abandoned = new AbortController();
38
+ response.once("close", () => abandoned.abort());
39
+ /** @type {RequestInit} */
40
+ const init = {
41
+ signal: abandoned.signal,
42
+ method: request.method,
43
+ headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
44
+ duplex: "half",
45
+ redirect: "manual",
46
+ };
47
+ const upstream = await sendWithinLimit(
48
+ request,
49
+ response,
50
+ backendProxy.maxBodyBytes,
51
+ (body) => fetch(backendUrl(request.url), { ...init, body }),
52
+ );
53
+ if (!upstream) return true;
54
+ await relayAnswer(request, response, upstream, {
55
+ fetched: true,
56
+ isProxiedPath,
57
+ });
58
+ }
@@ -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
  /**
@@ -26,6 +28,11 @@ export const SERVER_OPTIONS = Object.freeze({
26
28
 
27
29
  class BodyTooLargeError extends Error {}
28
30
 
31
+ /**
32
+ * @param {import("node:http").IncomingMessage} request
33
+ * @param {number} maxBodyBytes
34
+ * @returns {AsyncGenerator<Buffer>}
35
+ */
29
36
  async function* limitedChunks(request, maxBodyBytes) {
30
37
  let received = 0;
31
38
  for await (const chunk of request.iterator(BODY_ITERATION)) {
@@ -35,6 +42,10 @@ async function* limitedChunks(request, maxBodyBytes) {
35
42
  }
36
43
  }
37
44
 
45
+ /**
46
+ * @param {import("./request-target.mjs").ServerRequest} request
47
+ * @param {number | undefined} maxBodyBytes
48
+ */
38
49
  function requestBody(request, maxBodyBytes) {
39
50
  if (BODILESS_METHODS.has(request.method)) return undefined;
40
51
  return maxBodyBytes === undefined
@@ -42,6 +53,11 @@ function requestBody(request, maxBodyBytes) {
42
53
  : limitedChunks(request, maxBodyBytes);
43
54
  }
44
55
 
56
+ /**
57
+ * @param {import("node:http").IncomingMessage} request
58
+ * @param {number | undefined} maxBodyBytes
59
+ * @returns {boolean}
60
+ */
45
61
  function declaresTooLargeBody(request, maxBodyBytes) {
46
62
  return (
47
63
  maxBodyBytes !== undefined &&
@@ -49,10 +65,14 @@ function declaresTooLargeBody(request, maxBodyBytes) {
49
65
  );
50
66
  }
51
67
 
68
+ /**
69
+ * @param {unknown} error
70
+ * @returns {boolean}
71
+ */
52
72
  function isBodyTooLarge(error) {
53
73
  return (
54
74
  error instanceof BodyTooLargeError ||
55
- error?.cause instanceof BodyTooLargeError
75
+ (isObjectLike(error) && error.cause instanceof BodyTooLargeError)
56
76
  );
57
77
  }
58
78
 
@@ -65,6 +85,9 @@ function isBodyTooLarge(error) {
65
85
  * the browser's upload failing without a reason. The rest of the body is read
66
86
  * and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
67
87
  * at most; the response ends with it and the connection then closes cleanly.
88
+ *
89
+ * @param {import("node:http").IncomingMessage} request
90
+ * @param {import("node:http").ServerResponse} response
68
91
  */
69
92
  function refuseTooLargeBody(request, response) {
70
93
  response.writeHead(PAYLOAD_TOO_LARGE, {
@@ -72,7 +95,10 @@ function refuseTooLargeBody(request, response) {
72
95
  connection: "close",
73
96
  });
74
97
  response.write(TOO_LARGE_BODY);
75
- if (request.complete) return response.end();
98
+ if (request.complete) {
99
+ response.end();
100
+ return;
101
+ }
76
102
  const close = () => {
77
103
  clearTimeout(timer);
78
104
  response.end();
@@ -96,19 +122,27 @@ function refuseTooLargeBody(request, response) {
96
122
  * before a byte is passed on, a chunked body is cut off as soon as it goes
97
123
  * over.
98
124
  *
99
- * @param send `(body) => Promise<Response>`, the backend request with the
100
- * body to stream: none for a GET or a HEAD, the request itself, or its
101
- * bytes cut off past the limit
102
- * @returns The backend's answer, or `undefined` once the body went over and
103
- * `response` got the 413
125
+ * @param {import("./request-target.mjs").ServerRequest} request
126
+ * @param {import("node:http").ServerResponse} response
127
+ * @param {number | undefined} maxBodyBytes
128
+ * @param {(
129
+ * body: import("node:http").IncomingMessage | AsyncGenerator<Buffer> | undefined
130
+ * ) => Promise<Response>} send The backend request with the body to stream:
131
+ * none for a GET or a HEAD, the request itself, or its bytes cut off past
132
+ * the limit
133
+ * @returns {Promise<Response | undefined>} The backend's answer, or
134
+ * `undefined` once the body went over and `response` got the 413
104
135
  */
105
136
  export async function sendWithinLimit(request, response, maxBodyBytes, send) {
106
- if (declaresTooLargeBody(request, maxBodyBytes))
107
- return refuseTooLargeBody(request, response);
137
+ if (declaresTooLargeBody(request, maxBodyBytes)) {
138
+ refuseTooLargeBody(request, response);
139
+ return undefined;
140
+ }
108
141
  try {
109
142
  return await send(requestBody(request, maxBodyBytes));
110
143
  } catch (error) {
111
144
  if (!isBodyTooLarge(error)) throw error;
112
- return refuseTooLargeBody(request, response);
145
+ refuseTooLargeBody(request, response);
146
+ return undefined;
113
147
  }
114
148
  }
@@ -1,6 +1,27 @@
1
1
  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
+ import { PROXY_AUTH_MODES } from "./backend-proxies.mjs";
5
+ import { isNumber, isObjectLike, isRecord, isString } from "./type-guards.mjs";
6
+
7
+ /**
8
+ * A public endpoint, compiled from a declaration that passed every check.
9
+ *
10
+ * @typedef {object} PublicEndpoint
11
+ * @property {string} path
12
+ * @property {string[]} methods
13
+ * @property {string[]} headers
14
+ * @property {RegExp} matcher
15
+ */
16
+
17
+ /**
18
+ * A declaration as the manifest or the environment holds it, before any check.
19
+ *
20
+ * @typedef {object} PublicEndpointDeclaration
21
+ * @property {unknown} [path]
22
+ * @property {unknown} [methods]
23
+ * @property {unknown} [headers]
24
+ */
4
25
 
5
26
  /**
6
27
  * Written at the workspace root by the loader, from the endpoints each
@@ -41,39 +62,101 @@ const HEADER_NAME = /^x-[a-z0-9-]+$/;
41
62
  const RESERVED_HEADER =
42
63
  /^x-(?:forwarded-|real-ip$|dms-|inertia|http-method|method-override|original-|rewrite-)/;
43
64
 
65
+ /** @type {unknown[] | undefined} */
44
66
  let declaredEndpoints;
67
+ /** @type {import("./backend-proxies.mjs").ManifestProxy[] | undefined} */
45
68
  let declaredProxies;
46
69
 
47
70
  /**
48
71
  * The public endpoints the modules of this deployment declared, unchecked.
49
72
  *
50
- * @param file Generated file to read
51
- * @returns The declared entries, or none when the file is absent or broken
73
+ * @param {URL | string} [file] Generated file to read
74
+ * @returns {unknown[]} The declared entries, or none when the file is absent or broken
52
75
  */
53
76
  export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
54
77
  try {
78
+ /** @type {unknown} */
55
79
  const parsed = JSON.parse(readFileSync(file, "utf8"));
56
- return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
80
+ const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
81
+ return Array.isArray(endpoints) ? endpoints : [];
57
82
  } catch {
58
83
  return [];
59
84
  }
60
85
  }
61
86
 
87
+ /**
88
+ * Whether `entry` names a path and, when it says so, an auth mode and a body
89
+ * limit of the right type. The path pattern and the limit's range are judged
90
+ * when the proxies are resolved.
91
+ *
92
+ * @param {unknown} entry
93
+ * @returns {entry is { path: string, auth?: "session" | "none", maxBodyBytes?: number, module?: unknown }}
94
+ */
95
+ function isProxyDeclaration(entry) {
96
+ return (
97
+ isRecord(entry) &&
98
+ !Array.isArray(entry) &&
99
+ isString(entry.path) &&
100
+ (entry.auth === undefined ||
101
+ PROXY_AUTH_MODES.some((mode) => mode === entry.auth)) &&
102
+ (entry.maxBodyBytes === undefined || isNumber(entry.maxBodyBytes))
103
+ );
104
+ }
105
+
106
+ /**
107
+ * @param {unknown[]} entries
108
+ * @param {string} source Where they were declared, for the warning
109
+ * @param {(entry: unknown) => boolean} isUsable
110
+ */
111
+ function warnUnusableProxies(entries, source, isUsable) {
112
+ const unusable = entries.filter((entry) => !isUsable(entry)).length;
113
+ if (unusable)
114
+ console.warn(
115
+ `Ignoring ${unusable} ${unusable === 1 ? "entry" : "entries"} of ${source}: not a backend proxy declaration (a path, an auth of session or none, a body limit in bytes)`,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * @param {unknown} entry
121
+ * @returns {entry is import("./backend-proxies.mjs").ManifestProxy}
122
+ */
123
+ function isDeclaredProxy(entry) {
124
+ return (
125
+ isRecord(entry) &&
126
+ isProxyDeclaration(entry) &&
127
+ (entry.module === undefined || isString(entry.module))
128
+ );
129
+ }
130
+
62
131
  /**
63
132
  * The backend proxies the backend modules declared in the manifest, as
64
- * `{ module, path, auth?, maxBodyBytes? }`, unchecked.
133
+ * `{ module, path, auth?, maxBodyBytes? }`. An entry that is not one is
134
+ * reported and left out.
65
135
  *
66
136
  * A missing or broken file declares no proxy.
67
137
  *
68
- * @param file Generated file to read
138
+ * @param {URL | string} [file] Generated file to read
139
+ * @returns {import("./backend-proxies.mjs").ManifestProxy[]}
69
140
  */
70
141
  export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
71
142
  if (file === DECLARED_ENDPOINTS_FILE && declaredProxies)
72
143
  return declaredProxies;
144
+ /** @type {import("./backend-proxies.mjs").ManifestProxy[]} */
73
145
  let proxies = [];
74
146
  try {
147
+ /** @type {unknown} */
75
148
  const parsed = JSON.parse(readFileSync(file, "utf8"));
76
- if (Array.isArray(parsed?.proxies)) proxies = parsed.proxies;
149
+ const listed = isObjectLike(parsed) ? parsed.proxies : undefined;
150
+ if (Array.isArray(listed)) {
151
+ /** @type {unknown[]} */
152
+ const entries = listed;
153
+ warnUnusableProxies(
154
+ entries,
155
+ "the generated manifest file",
156
+ isDeclaredProxy,
157
+ );
158
+ proxies = entries.filter(isDeclaredProxy);
159
+ }
77
160
  } catch {}
78
161
  if (file === DECLARED_ENDPOINTS_FILE) declaredProxies = proxies;
79
162
  return proxies;
@@ -82,29 +165,40 @@ export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
82
165
  /**
83
166
  * The backend proxies `DMS_BACKEND_PROXIES` adds: a JSON array of
84
167
  * `{ path, auth?, maxBodyBytes? }`, as the manifest's `backendProxies`, for an
85
- * operator. A value that is not a JSON array is reported and ignored.
168
+ * operator. A value that is not a JSON array is reported and ignored, and so
169
+ * is each entry that is not a declaration.
86
170
  *
87
- * @param declaration The variable's value
171
+ * @param {string} [declaration] The variable's value
172
+ * @returns {import("./backend-proxies.mjs").ManifestProxy[]}
88
173
  */
89
174
  export function environmentBackendProxies(
90
175
  declaration = process.env.DMS_BACKEND_PROXIES,
91
176
  ) {
92
177
  if (!declaration?.trim()) return [];
93
178
  try {
179
+ /** @type {unknown} */
94
180
  const parsed = JSON.parse(declaration);
95
- if (Array.isArray(parsed))
96
- return parsed.map((entry) => ({
97
- ...entry,
98
- module: "DMS_BACKEND_PROXIES",
99
- }));
181
+ if (Array.isArray(parsed)) {
182
+ /** @type {unknown[]} */
183
+ const entries = parsed;
184
+ warnUnusableProxies(entries, "DMS_BACKEND_PROXIES", isProxyDeclaration);
185
+ return entries
186
+ .filter(isProxyDeclaration)
187
+ .map((entry) => ({ ...entry, module: "DMS_BACKEND_PROXIES" }));
188
+ }
100
189
  } catch {}
101
190
  console.warn("Ignoring DMS_BACKEND_PROXIES: not a JSON array");
102
191
  return [];
103
192
  }
104
193
 
194
+ /**
195
+ * @param {string | undefined} declaration
196
+ * @returns {unknown[]}
197
+ */
105
198
  function environmentEndpoints(declaration) {
106
199
  if (!declaration?.trim()) return [];
107
200
  try {
201
+ /** @type {unknown} */
108
202
  const parsed = JSON.parse(declaration);
109
203
  return Array.isArray(parsed) ? parsed : [];
110
204
  } catch {
@@ -113,6 +207,7 @@ function environmentEndpoints(declaration) {
113
207
  }
114
208
  }
115
209
 
210
+ /** @param {unknown} methods */
116
211
  function normalizedMethods(methods) {
117
212
  if (methods === undefined) return DEFAULT_METHODS;
118
213
  if (!Array.isArray(methods) || methods.length === 0) return undefined;
@@ -122,6 +217,7 @@ function normalizedMethods(methods) {
122
217
  : undefined;
123
218
  }
124
219
 
220
+ /** @param {unknown} headers */
125
221
  function normalizedHeaders(headers) {
126
222
  if (headers === undefined) return [];
127
223
  if (!Array.isArray(headers)) return undefined;
@@ -133,6 +229,7 @@ function normalizedHeaders(headers) {
133
229
  : undefined;
134
230
  }
135
231
 
232
+ /** @param {string} path */
136
233
  function pathMatcher(path) {
137
234
  const pattern = path
138
235
  .split("/")
@@ -148,11 +245,13 @@ function pathMatcher(path) {
148
245
  /**
149
246
  * A declaration made usable, or `undefined` when any part of it is malformed:
150
247
  * a bad entry is dropped whole, never widened to what it might have meant.
248
+ *
249
+ * @param {unknown} entry
250
+ * @returns {PublicEndpoint | undefined}
151
251
  */
152
252
  export function compilePublicEndpoint(entry) {
153
- if (typeof entry !== "object" || entry === null) return undefined;
154
- if (typeof entry.path !== "string" || !PUBLIC_PATH.test(entry.path))
155
- return undefined;
253
+ if (!isObjectLike(entry)) return undefined;
254
+ if (!isString(entry.path) || !PUBLIC_PATH.test(entry.path)) return undefined;
156
255
  const methods = normalizedMethods(entry.methods);
157
256
  const headers = normalizedHeaders(entry.headers);
158
257
  if (!methods || !headers) return undefined;
@@ -168,6 +267,10 @@ export function compilePublicEndpoint(entry) {
168
267
  * The backend endpoints this deployment serves to anyone: what the backend's
169
268
  * modules declared, plus what `DMS_PUBLIC_ENDPOINTS` adds (a JSON array of the
170
269
  * same `{ path, methods?, headers? }` entries).
270
+ *
271
+ * @param {string} [declaration]
272
+ * @param {unknown[]} [declared]
273
+ * @returns {PublicEndpoint[]}
171
274
  */
172
275
  export function allowedPublicEndpoints(
173
276
  declaration = process.env.DMS_PUBLIC_ENDPOINTS,
@@ -175,7 +278,9 @@ export function allowedPublicEndpoints(
175
278
  ) {
176
279
  return [...declared, ...environmentEndpoints(declaration)].flatMap(
177
280
  (entry) => {
178
- const endpoint = compilePublicEndpoint(entry);
281
+ const endpoint = isRecord(entry)
282
+ ? compilePublicEndpoint(entry)
283
+ : undefined;
179
284
  if (!endpoint)
180
285
  console.warn(
181
286
  `Ignoring malformed public endpoint ${JSON.stringify(entry)}: expected ` +
@@ -189,6 +294,8 @@ export function allowedPublicEndpoints(
189
294
  /**
190
295
  * Compiled once per value of the environment variable, so a malformed entry
191
296
  * is reported once rather than on every request.
297
+ *
298
+ * @type {{ declaration: string | undefined, endpoints: PublicEndpoint[] } | undefined}
192
299
  */
193
300
  let compiled;
194
301
 
@@ -202,9 +309,9 @@ function currentPublicEndpoints() {
202
309
  /**
203
310
  * The public endpoint a request reaches, if any.
204
311
  *
205
- * @param method The request's method
206
- * @param pathname The request's normalized path, without its query string
207
- * @param endpoints The endpoints to match, this deployment's by default
312
+ * @param {string} method The request's method
313
+ * @param {string} pathname The request's normalized path, without its query string
314
+ * @param {PublicEndpoint[]} [endpoints] The endpoints to match, this deployment's by default
208
315
  */
209
316
  export function matchPublicEndpoint(
210
317
  method,
@@ -226,23 +333,23 @@ export function matchPublicEndpoint(
226
333
  * Origin. Under `auth: "session"`, a state-changing request needs one unless
227
334
  * it reaches a public endpoint, which is why the proxy never sends a public
228
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.
229
338
  *
230
- * @param request The incoming request
231
- * @param safeMethods Methods that change nothing and need no Origin
232
- * @param proxy The backend proxy the request reached
339
+ * @param {import("./request-target.mjs").ServerRequest} request The incoming request
340
+ * @param {ReadonlySet<string>} safeMethods Methods that change nothing and need no Origin
341
+ * @param {import("./backend-proxies.mjs").BackendProxy} proxy The backend proxy the request reached
233
342
  * @returns `{ anonymous, headers, isRefused }`
234
343
  */
235
344
  export function proxyAccess(request, safeMethods, proxy) {
236
- if (proxy.auth === "none")
237
- return { anonymous: true, headers: [], isRefused: false };
238
345
  const publicEndpoint = matchPublicEndpoint(
239
346
  request.method,
240
347
  new URL(request.url, "http://frontend.local").pathname,
241
348
  );
242
- if (publicEndpoint)
349
+ if (publicEndpoint || proxy.auth === "none")
243
350
  return {
244
351
  anonymous: true,
245
- headers: publicEndpoint.headers,
352
+ headers: publicEndpoint?.headers ?? [],
246
353
  isRefused: false,
247
354
  };
248
355
  return {
@@ -252,7 +359,11 @@ export function proxyAccess(request, safeMethods, proxy) {
252
359
  };
253
360
  }
254
361
 
255
- /** Answers the 403 of a request `proxyAccess` refused. */
362
+ /**
363
+ * Answers the 403 of a request `proxyAccess` refused.
364
+ *
365
+ * @param {import("node:http").ServerResponse} response
366
+ */
256
367
  export function refuseCrossOrigin(response) {
257
368
  json(response, 403, CROSS_ORIGIN_ERROR);
258
369
  }
@@ -1,15 +1,25 @@
1
+ import { isRecord } from "./type-guards.mjs";
2
+
1
3
  const VARIABLE = "DMS_PUBLIC_OPTIONS";
2
4
  export const PUBLIC_OPTIONS_ELEMENT_ID = "dms-public-options";
3
5
 
6
+ /**
7
+ * The registry the workspace was materialized with.
8
+ *
9
+ * @typedef {object} FrontendRegistry
10
+ * @property {{ id: string, options?: unknown }[]} modules The modules, highest priority first
11
+ */
12
+
13
+ /**
14
+ * @param {string | undefined} raw
15
+ * @returns {Record<string, unknown> | undefined}
16
+ */
4
17
  function parse(raw) {
5
18
  if (!raw) return undefined;
6
19
  try {
20
+ /** @type {unknown} */
7
21
  const parsed = JSON.parse(raw);
8
- return typeof parsed === "object" &&
9
- parsed !== null &&
10
- !Array.isArray(parsed)
11
- ? parsed
12
- : undefined;
22
+ return isRecord(parsed) && !Array.isArray(parsed) ? parsed : undefined;
13
23
  } catch {
14
24
  console.warn(`${VARIABLE} is not a JSON object of options by module id`);
15
25
  return undefined;
@@ -27,7 +37,11 @@ function parse(raw) {
27
37
  */
28
38
  export const RUNTIME_PUBLIC_OPTIONS = parse(process.env[VARIABLE]);
29
39
 
30
- /** The options of each module of the registry, the runtime ones first. */
40
+ /**
41
+ * The options of each module of the registry, the runtime ones first.
42
+ *
43
+ * @param {FrontendRegistry} registry
44
+ */
31
45
  export function publicOptionsOf(registry) {
32
46
  return Object.fromEntries(
33
47
  registry.modules.map((module) => [