@antelopejs/dms-frontend 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +4 -4
  2. package/dist/commands/build-action.d.ts +8 -5
  3. package/dist/commands/build-action.js +13 -41
  4. package/dist/commands/build.js +9 -15
  5. package/dist/commands/dev-action.js +5 -1
  6. package/dist/commands/start-action.d.ts +2 -0
  7. package/dist/commands/start-action.js +36 -49
  8. package/dist/commands/start.js +8 -6
  9. package/dist/config.d.ts +2 -0
  10. package/dist/config.js +6 -0
  11. package/dist/derived-outputs.js +2 -2
  12. package/dist/fs-sync.d.ts +1 -0
  13. package/dist/fs-sync.js +6 -0
  14. package/dist/help.js +8 -4
  15. package/dist/index.js +4 -6
  16. package/dist/layer-watch.js +4 -0
  17. package/dist/layers.d.ts +1 -3
  18. package/dist/layers.js +1 -95
  19. package/dist/manifest.d.ts +8 -8
  20. package/dist/manifest.js +15 -39
  21. package/dist/materialize.js +6 -0
  22. package/dist/module-set.d.ts +14 -12
  23. package/dist/module-set.js +39 -31
  24. package/dist/private-values.d.ts +2 -2
  25. package/dist/private-values.js +1 -6
  26. package/dist/project-modules.d.ts +46 -0
  27. package/dist/project-modules.js +233 -0
  28. package/dist/workspace-setup.d.ts +3 -7
  29. package/dist/workspace-setup.js +41 -62
  30. package/dist/workspace.d.ts +2 -4
  31. package/dist/workspace.js +1 -1
  32. package/package.json +5 -7
  33. package/skills/dms-frontend/SKILL.md +2 -2
  34. package/templates/vue/client-chunking.mjs +61 -0
  35. package/templates/vue/email-renderer.ts +6 -1
  36. package/templates/vue/frontend-build-loader.ts +1 -1
  37. package/templates/vue/frontend-module.ts +24 -0
  38. package/templates/vue/main.ts +11 -2
  39. package/templates/vue/package.json +2 -2
  40. package/templates/vue/prepare-types.mjs +2 -7
  41. package/templates/vue/public-options.ts +19 -0
  42. package/templates/vue/server/auth/backend.mjs +20 -3
  43. package/templates/vue/server/auth/client-ip.mjs +64 -22
  44. package/templates/vue/server/auth/session.mjs +4 -11
  45. package/templates/vue/server/backend-proxies.mjs +13 -2
  46. package/templates/vue/server/backend-response.mjs +2 -13
  47. package/templates/vue/server/cli-report.mjs +42 -18
  48. package/templates/vue/server/client-manifest.mjs +22 -58
  49. package/templates/vue/server/dev-private-files.mjs +1 -1
  50. package/templates/vue/server/dev-styles.mjs +16 -5
  51. package/templates/vue/server/homepage.mjs +4 -1
  52. package/templates/vue/server/module-event.mjs +23 -8
  53. package/templates/vue/server/module-server.mjs +4 -4
  54. package/templates/vue/server/module-testing.mjs +5 -3
  55. package/templates/vue/server/proxy-compression.mjs +15 -8
  56. package/templates/vue/server/proxy-headers.mjs +12 -1
  57. package/templates/vue/server/proxy-redirects.mjs +66 -0
  58. package/templates/vue/server/proxy-transfers.mjs +84 -0
  59. package/templates/vue/server/public-endpoints.mjs +2 -2
  60. package/templates/vue/server/public-options.mjs +49 -0
  61. package/templates/vue/server/request-routing.mjs +20 -0
  62. package/templates/vue/server/web-body.mjs +24 -0
  63. package/templates/vue/server-module.ts +19 -7
  64. package/templates/vue/server.mjs +59 -49
  65. package/templates/vue/ssr-renderer.ts +19 -1
  66. package/templates/vue/vite.config.ts +72 -46
  67. package/templates/vue/vite.email.config.ts +6 -3
  68. package/templates/vue/vite.server.config.ts +5 -2
  69. package/dist/commands/snapshot-action.d.ts +0 -6
  70. package/dist/commands/snapshot-action.js +0 -53
  71. package/dist/commands/snapshot.d.ts +0 -2
  72. package/dist/commands/snapshot.js +0 -55
  73. package/dist/snapshot.d.ts +0 -25
  74. package/dist/snapshot.js +0 -90
  75. package/templates/vue/server/backend-wake.mjs +0 -129
@@ -4,7 +4,6 @@
4
4
  // limit, `no-store`). See `server-module.ts` for the types a module reads.
5
5
 
6
6
  import { Readable } from "node:stream";
7
- import { pipeline } from "node:stream/promises";
8
7
  import { backend, json, RequestBodyError } from "./auth/backend.mjs";
9
8
  import {
10
9
  clientIp,
@@ -23,7 +22,13 @@ import {
23
22
  writeSession,
24
23
  } from "./auth/session.mjs";
25
24
  import { renderEmailTemplate } from "./email.mjs";
25
+ import {
26
+ isBackendUrl,
27
+ relayableCookies,
28
+ relayedLocation,
29
+ } from "./proxy-redirects.mjs";
26
30
  import { backendUrl } from "./request-target.mjs";
31
+ import { pipeWebBody } from "./web-body.mjs";
27
32
 
28
33
  const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
29
34
  const COOKIE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
@@ -218,15 +223,24 @@ function asResponse(result) {
218
223
 
219
224
  /**
220
225
  * Writes a handler's answer. The cookies the event wrote are already on the
221
- * Node response; those of a returned `Response` are added to them.
226
+ * Node response; those of a returned `Response` are added to them. A backend
227
+ * answer the handler relays goes out as the proxy relays one: its `Location`
228
+ * on the frontend's origin when the frontend proxies its path, no cookie of
229
+ * another site its fetch reached.
222
230
  */
223
- async function writeServerResponse(request, response, result) {
231
+ async function writeServerResponse(request, response, result, isProxiedPath) {
224
232
  const answer = asResponse(result);
225
233
  const cookies = [response.getHeader("set-cookie") ?? []]
226
234
  .flat()
227
- .concat(answer.headers.getSetCookie());
235
+ .concat(relayableCookies(answer));
228
236
  answer.headers.forEach((value, name) => {
229
- if (name !== "set-cookie") response.setHeader(name, value);
237
+ if (name === "set-cookie") return;
238
+ response.setHeader(
239
+ name,
240
+ name === "location" && isBackendUrl(answer.url)
241
+ ? relayedLocation(value, answer.url, isProxiedPath)
242
+ : value,
243
+ );
230
244
  });
231
245
  if (cookies.length) response.setHeader("set-cookie", cookies);
232
246
  if (!answer.headers.has("cache-control"))
@@ -237,7 +251,7 @@ async function writeServerResponse(request, response, result) {
237
251
  response.end();
238
252
  return;
239
253
  }
240
- await pipeline(Readable.fromWeb(answer.body), response);
254
+ await pipeWebBody(answer.body, response);
241
255
  }
242
256
 
243
257
  /**
@@ -247,7 +261,8 @@ async function writeServerResponse(request, response, result) {
247
261
  * through reaches the server's error handling with the cookies written so
248
262
  * far.
249
263
  *
250
- * @param context See `createServerEvent`
264
+ * @param context See `createServerEvent`, plus `isProxiedPath(pathname)`:
265
+ * whether the frontend sends that path to the backend
251
266
  */
252
267
  export async function answerModuleRoute(context) {
253
268
  const { request, response, route } = context;
@@ -261,5 +276,5 @@ export async function answerModuleRoute(context) {
261
276
  }
262
277
  const event = createServerEvent(context);
263
278
  const result = await route.handler(event);
264
- await writeServerResponse(request, response, result);
279
+ await writeServerResponse(request, response, result, context.isProxiedPath);
265
280
  }
@@ -20,7 +20,9 @@ import {
20
20
  environmentBackendProxies,
21
21
  readDeclaredBackendProxies,
22
22
  } from "./public-endpoints.mjs";
23
+ import { reachesBackend } from "./request-routing.mjs";
23
24
 
25
+ import { publicOptionsOf } from "./public-options.mjs";
24
26
  const WORKSPACE_ROOT = new URL("../", import.meta.url);
25
27
  const SOURCE_LOADER = "/server-modules.generated.ts";
26
28
  const BUILT_LOADER = new URL(
@@ -148,10 +150,7 @@ function readPrivateOptions(env = process.env) {
148
150
  }
149
151
 
150
152
  function readPublicOptions() {
151
- const registry = readJsonFile(REGISTRY_FILE, { modules: [] });
152
- return Object.fromEntries(
153
- registry.modules.map((module) => [module.id, module.options]),
154
- );
153
+ return publicOptionsOf(readJsonFile(REGISTRY_FILE, { modules: [] }));
155
154
  }
156
155
 
157
156
  async function loadDeclarations(devServer) {
@@ -281,6 +280,7 @@ export async function handleModuleRoute(request, response, pathname, modules) {
281
280
  route: reached.route,
282
281
  params: reached.params,
283
282
  view: modules.serverAuth.view,
283
+ isProxiedPath: (path) => reachesBackend(path, modules),
284
284
  ...modules.context(reached.route.id),
285
285
  });
286
286
  return true;
@@ -29,9 +29,11 @@ function writeFailure(error, response) {
29
29
  * @param declarations `{ id, priority?, setup }` per module, `setup` being
30
30
  * the default export of its `dms.server.ts`
31
31
  * @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
32
- * publicEndpoints? }`, options by module id. The private options are used
33
- * as given, as the server uses the manifest's: no environment variable
34
- * replaces a secret they hold
32
+ * publicEndpoints?, backendProxies? }`, options by module id, and the
33
+ * manifest's backend proxies (`{ module, path, auth? }`), which decide
34
+ * the backend redirects relayed as frontend paths. The private options are
35
+ * used as given, as the server uses the manifest's: no environment
36
+ * variable replaces a secret they hold
35
37
  * @returns `{ url, modules, warnings, close() }`
36
38
  */
37
39
  export async function serveModuleRoutes(declarations, deployment = {}) {
@@ -1,7 +1,6 @@
1
- import { Readable } from "node:stream";
2
- import { pipeline } from "node:stream/promises";
3
1
  import { acceptedEncoder, MINIMUM_COMPRESSION_BYTES } from "./content.mjs";
4
2
  import { writeProxyHeaders } from "./proxy-headers.mjs";
3
+ import { pipeWebBody } from "./web-body.mjs";
5
4
 
6
5
  /**
7
6
  * Asks the backend for its answer uncompressed. fetch decodes a compressed
@@ -50,12 +49,15 @@ function proxyEncoder(request, upstream) {
50
49
  : undefined;
51
50
  }
52
51
 
53
- /** Relays a backend answer to the client, compressed when it should be. */
52
+ /**
53
+ * Relays a backend answer to the client, compressed when it should be.
54
+ * `isProxiedPath` decides which backend `Location` becomes a frontend path.
55
+ */
54
56
  export async function relayAnswer(
55
57
  request,
56
58
  response,
57
59
  upstream,
58
- { fetched = false } = {},
60
+ { fetched = false, isProxiedPath } = {},
59
61
  ) {
60
62
  // Only native fetch bodies are decoded. Synthetic/opaque responses may
61
63
  // still contain the encoded bytes, and unknown coding chains stay opaque.
@@ -85,7 +87,12 @@ export async function relayAnswer(
85
87
  headers.delete("content-length");
86
88
  const etag = headers.get("etag");
87
89
  if (etag && !etag.startsWith("W/")) headers.set("etag", `W/${etag}`);
88
- upstream = { status: upstream.status, headers, body: upstream.body };
90
+ upstream = {
91
+ status: upstream.status,
92
+ headers,
93
+ body: upstream.body,
94
+ url: upstream.url,
95
+ };
89
96
  }
90
97
  const encoder = proxyEncoder(request, upstream);
91
98
  writeProxyHeaders(
@@ -93,9 +100,9 @@ export async function relayAnswer(
93
100
  upstream,
94
101
  encoder?.encoding,
95
102
  isCompressible(request, upstream),
103
+ isProxiedPath,
96
104
  );
97
105
  if (!upstream.body || request.method === "HEAD") return response.end();
98
- const body = Readable.fromWeb(upstream.body);
99
- if (encoder) await pipeline(body, encoder.stream, response);
100
- else await pipeline(body, response);
106
+ if (encoder) await pipeWebBody(upstream.body, encoder.stream, response);
107
+ else await pipeWebBody(upstream.body, response);
101
108
  }
@@ -1,3 +1,5 @@
1
+ import { relayedLocation } from "./proxy-redirects.mjs";
2
+
1
3
  // Hop-by-hop headers describe the backend connection, not the client's one.
2
4
  // Forwarded, an explicit `connection: keep-alive` overrode the client's own
3
5
  // `Connection: close`, so Node kept the socket open until its idle timeout.
@@ -25,13 +27,16 @@ function varyWithEncoding(vary) {
25
27
  /**
26
28
  * Writes the backend's status and end-to-end headers on `response`. With an
27
29
  * `encoding`, the proxy compresses the body itself: its length is no longer
28
- * known, and a strong validator no longer names these bytes.
30
+ * known, and a strong validator no longer names these bytes. A `Location` on
31
+ * the backend's origin becomes the same path on the frontend's when
32
+ * `isProxiedPath` says the frontend proxies it (see `relayedLocation`).
29
33
  */
30
34
  export function writeProxyHeaders(
31
35
  response,
32
36
  upstream,
33
37
  encoding,
34
38
  varies = false,
39
+ isProxiedPath,
35
40
  ) {
36
41
  const headers = Object.fromEntries(upstream.headers);
37
42
  const named = (headers.connection ?? "")
@@ -41,6 +46,12 @@ export function writeProxyHeaders(
41
46
  delete headers["set-cookie"];
42
47
  const cookies = upstream.headers.getSetCookie?.() ?? [];
43
48
  if (cookies.length) headers["set-cookie"] = cookies;
49
+ if (headers.location && upstream.url)
50
+ headers.location = relayedLocation(
51
+ headers.location,
52
+ upstream.url,
53
+ isProxiedPath,
54
+ );
44
55
  if (encoding || varies) headers.vary = varyWithEncoding(headers.vary);
45
56
  if (encoding) {
46
57
  delete headers["content-length"];
@@ -0,0 +1,66 @@
1
+ // A backend redirect is the browser's to follow. Followed server-side, a
2
+ // redirect to another site served that site's page, and set its cookies, on
3
+ // the frontend's origin, and a redirect on the backend lost its Location.
4
+ // The proxy fetches with `redirect: "manual"` and relays the redirect instead.
5
+
6
+ /** The statuses that send the client to their `Location`. */
7
+ export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
8
+
9
+ function originOf(url) {
10
+ try {
11
+ return new URL(url).origin;
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ }
16
+
17
+ /** Whether `url` is on the backend's origin. */
18
+ export function isBackendUrl(
19
+ url,
20
+ backendBaseUrl = process.env.DMS_API_BASE_URL,
21
+ ) {
22
+ const origin = originOf(url);
23
+ return origin !== undefined && origin === originOf(backendBaseUrl);
24
+ }
25
+
26
+ /**
27
+ * The `Location` the client gets for a backend answer fetched from
28
+ * `fetchedUrl`. The proxy keeps the path, so a location on the backend's
29
+ * origin becomes the same path on the frontend's when the frontend sends that
30
+ * path to the backend: the backend's origin is often unreachable from the
31
+ * browser. A backend path the frontend does not proxy (a route outside every
32
+ * declared backend proxy) stays an absolute backend URL, which the browser
33
+ * follows to the backend. Another site's location is kept.
34
+ *
35
+ * @param location The backend's `Location`
36
+ * @param fetchedUrl The backend URL the answer came from
37
+ * @param isProxiedPath `(pathname) => boolean`, whether the frontend server
38
+ * sends that path to the backend
39
+ */
40
+ export function relayedLocation(
41
+ location,
42
+ fetchedUrl,
43
+ isProxiedPath = () => false,
44
+ ) {
45
+ let target;
46
+ try {
47
+ target = new URL(location, fetchedUrl);
48
+ } catch {
49
+ return location;
50
+ }
51
+ if (target.origin !== originOf(fetchedUrl)) return location;
52
+ if (!isProxiedPath(target.pathname)) return target.href;
53
+ const path = `${target.pathname}${target.search}${target.hash}`;
54
+ // `//host` would name another host: `/.` keeps it a path.
55
+ return path.startsWith("//") ? `/.${path}` : path;
56
+ }
57
+
58
+ /**
59
+ * The cookies of a `Response` a module route returns. A fetch that followed a
60
+ * redirect to another site would set that site's cookies on the frontend's
61
+ * origin: those are dropped.
62
+ */
63
+ export function relayableCookies(answer) {
64
+ if (answer.redirected && !isBackendUrl(answer.url)) return [];
65
+ return answer.headers.getSetCookie();
66
+ }
@@ -0,0 +1,84 @@
1
+ // Large transfers through the proxy, a file upload above all. The request
2
+ // body is streamed to the backend, never buffered, and held to the
3
+ // `maxBodyBytes` of the proxy it reached: a declared Content-Length over the
4
+ // limit is refused before a byte is read, a chunked body is cut off as soon as
5
+ // it goes over.
6
+
7
+ const PAYLOAD_TOO_LARGE = 413;
8
+ const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
9
+ // Reading on after the refusal would take the whole body the limit exists to
10
+ // refuse, so the request stream is left as it is and the connection closed.
11
+ const BODY_ITERATION = { destroyOnReturn: false };
12
+
13
+ const BODILESS_METHODS = new Set(["GET", "HEAD"]);
14
+ const REQUEST_TIMEOUT_MS = 60 * 60 * 1000;
15
+
16
+ /**
17
+ * The frontend server's options. Node's default request timeout (5 minutes to
18
+ * receive a whole request) cut off a large upload on a slow link: an hour lets
19
+ * 500 MiB through at about 150 KB/s. Headers keep Node's own minute.
20
+ */
21
+ export const SERVER_OPTIONS = Object.freeze({
22
+ requestTimeout: REQUEST_TIMEOUT_MS,
23
+ });
24
+
25
+ class BodyTooLargeError extends Error {}
26
+
27
+ async function* limitedChunks(request, maxBodyBytes) {
28
+ let received = 0;
29
+ for await (const chunk of request.iterator(BODY_ITERATION)) {
30
+ received += chunk.byteLength;
31
+ if (received > maxBodyBytes) throw new BodyTooLargeError();
32
+ yield chunk;
33
+ }
34
+ }
35
+
36
+ function requestBody(request, maxBodyBytes) {
37
+ if (BODILESS_METHODS.has(request.method)) return undefined;
38
+ return maxBodyBytes === undefined
39
+ ? request
40
+ : limitedChunks(request, maxBodyBytes);
41
+ }
42
+
43
+ function declaresTooLargeBody(request, maxBodyBytes) {
44
+ return (
45
+ maxBodyBytes !== undefined &&
46
+ Number(request.headers["content-length"] ?? 0) > maxBodyBytes
47
+ );
48
+ }
49
+
50
+ function isBodyTooLarge(error) {
51
+ return (
52
+ error instanceof BodyTooLargeError ||
53
+ error?.cause instanceof BodyTooLargeError
54
+ );
55
+ }
56
+
57
+ /** Answers 413 and closes the connection, the rest of the body unread. */
58
+ function refuseTooLargeBody(response) {
59
+ response.writeHead(PAYLOAD_TOO_LARGE, {
60
+ "content-type": "application/json",
61
+ connection: "close",
62
+ });
63
+ response.end(TOO_LARGE_BODY);
64
+ }
65
+
66
+ /**
67
+ * Sends a proxied request, its body held to `maxBodyBytes`.
68
+ *
69
+ * @param send `(body) => Promise<Response>`, the backend request with the
70
+ * body to stream: none for a GET or a HEAD, the request itself, or its
71
+ * bytes cut off past the limit
72
+ * @returns The backend's answer, or `undefined` once the body went over and
73
+ * `response` got the 413
74
+ */
75
+ export async function sendWithinLimit(request, response, maxBodyBytes, send) {
76
+ if (declaresTooLargeBody(request, maxBodyBytes))
77
+ return refuseTooLargeBody(response);
78
+ try {
79
+ return await send(requestBody(request, maxBodyBytes));
80
+ } catch (error) {
81
+ if (!isBodyTooLarge(error)) throw error;
82
+ return refuseTooLargeBody(response);
83
+ }
84
+ }
@@ -54,7 +54,7 @@ export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
54
54
 
55
55
  /**
56
56
  * The backend proxies the backend modules declared in the manifest, as
57
- * `{ module, path, auth? }`, unchecked.
57
+ * `{ module, path, auth?, maxBodyBytes? }`, unchecked.
58
58
  *
59
59
  * @param file Generated file to read
60
60
  */
@@ -74,7 +74,7 @@ export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
74
74
 
75
75
  /**
76
76
  * The backend proxies `DMS_BACKEND_PROXIES` adds: a JSON array of
77
- * `{ path, auth? }`, as the manifest's `backendProxies`, for an operator.
77
+ * `{ path, auth?, maxBodyBytes? }`, as the manifest's `backendProxies`, for an operator.
78
78
  *
79
79
  * @param declaration The variable's value
80
80
  */
@@ -0,0 +1,49 @@
1
+ // The modules' public options the running server serves: computed by
2
+ // `ajs dms start` from the project's configuration and handed over in
3
+ // `DMS_PUBLIC_OPTIONS`, a JSON object of options by module id. A build never
4
+ // holds them, so a deployment's variables reach the dashboard without a
5
+ // rebuild. Unset (`ajs dms dev`), the registry's options stand.
6
+
7
+ const VARIABLE = "DMS_PUBLIC_OPTIONS";
8
+ export const PUBLIC_OPTIONS_ELEMENT_ID = "dms-public-options";
9
+
10
+ function parse(raw) {
11
+ if (!raw) return undefined;
12
+ try {
13
+ const parsed = JSON.parse(raw);
14
+ return typeof parsed === "object" &&
15
+ parsed !== null &&
16
+ !Array.isArray(parsed)
17
+ ? parsed
18
+ : undefined;
19
+ } catch {
20
+ console.warn(`${VARIABLE} is not a JSON object of options by module id`);
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ /** The options by module id the server runs with, undefined when unset. */
26
+ export const RUNTIME_PUBLIC_OPTIONS = parse(process.env[VARIABLE]);
27
+
28
+ /** The options of each module of the registry, the runtime ones first. */
29
+ export function publicOptionsOf(registry) {
30
+ return Object.fromEntries(
31
+ registry.modules.map((module) => [
32
+ module.id,
33
+ RUNTIME_PUBLIC_OPTIONS?.[module.id] ?? module.options,
34
+ ]),
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The script a page document carries the runtime options in, for the
40
+ * browser's setup of the modules; nothing when the build's options stand.
41
+ */
42
+ export function publicOptionsScript() {
43
+ if (!RUNTIME_PUBLIC_OPTIONS) return "";
44
+ const value = JSON.stringify(RUNTIME_PUBLIC_OPTIONS).replaceAll(
45
+ "<",
46
+ "\\u003c",
47
+ );
48
+ return `<script id="${PUBLIC_OPTIONS_ELEMENT_ID}" type="application/json">${value}</script>`;
49
+ }
@@ -39,6 +39,26 @@ export function requestOwner(method, pathname, modules, hasRendererFile) {
39
39
  return PAGE_METHODS.has(method) ? { kind: "page" } : { kind: "not-found" };
40
40
  }
41
41
 
42
+ /**
43
+ * Whether the server sends a request for `pathname` to the backend: a backend
44
+ * `Location` on such a path can become a frontend path (see
45
+ * `proxy-redirects.mjs`).
46
+ *
47
+ * @param pathname A normalized path
48
+ * @param modules What `loadServerModules` returned: `routes` and `proxies`
49
+ * @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
50
+ * file at that path
51
+ */
52
+ export function reachesBackend(
53
+ pathname,
54
+ modules,
55
+ hasRendererFile = () => false,
56
+ ) {
57
+ return (
58
+ requestOwner("GET", pathname, modules, hasRendererFile).kind === "backend"
59
+ );
60
+ }
61
+
42
62
  /** The `x-dms-route-owner` value of an owner, for the development server. */
43
63
  export function ownerLabel(owner) {
44
64
  if (owner.kind === "module") return `module:${owner.id}`;
@@ -0,0 +1,24 @@
1
+ // Writes a Web-standard body, a backend answer above all, to a Node response.
2
+ //
3
+ // Lives apart from the proxy and the module routes, which both send one.
4
+
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+
8
+ /**
9
+ * Sends `body` through `streams` to the response, the last of them. A
10
+ * response the client already left is not piped: `pipeline` refuses it
11
+ * before it listens to the body, which then stayed unread with no `error`
12
+ * listener, and its next failure (the backend closing, or undici's body
13
+ * timeout on an event stream gone quiet) ended the whole server. The body is
14
+ * cancelled instead, which closes its backend connection too.
15
+ */
16
+ export async function pipeWebBody(body, ...streams) {
17
+ const response = streams.at(-1);
18
+ if (response.closed || response.destroyed) {
19
+ for (const stream of streams.slice(0, -1)) stream.destroy();
20
+ await body.cancel().catch(() => {});
21
+ return;
22
+ }
23
+ await pipeline(Readable.fromWeb(body), ...streams);
24
+ }
@@ -29,8 +29,8 @@ export interface DmsSession {
29
29
  refreshToken?: string;
30
30
  accountId?: string;
31
31
  activeTenantId?: string;
32
- /** False when the session ends with the browser. */
33
- persistent?: boolean;
32
+ /** True when the session outlives the browser (30 days), false when it ends with it. */
33
+ persistent: boolean;
34
34
  /** Stamped by the frontend server on every write. */
35
35
  updatedAt?: number;
36
36
  [key: string]: unknown;
@@ -84,8 +84,8 @@ export interface DmsServerEvent {
84
84
  */
85
85
  view(): DmsPublicSession;
86
86
  /**
87
- * Writes the session cookie, stamped with `updatedAt`; it ends with the
88
- * browser unless `persistent` is true or unset.
87
+ * Writes the session cookie, stamped with `updatedAt`. It lasts 30 days
88
+ * when `persistent` is true and ends with the browser when it is false.
89
89
  */
90
90
  write(session: DmsSession): void;
91
91
  clear(): void;
@@ -112,16 +112,21 @@ export interface DmsServerEvent {
112
112
  /**
113
113
  * Calls the backend, JSON in and out. A refusal throws an error carrying
114
114
  * the status and the backend's public `error.*` message, which the server
115
- * answers as is when the handler lets it through.
115
+ * answers as is when the handler lets it through. A redirect is not
116
+ * followed: it throws a 502.
116
117
  */
117
118
  backend<T = unknown>(path: string, init?: DmsBackendRequest): Promise<T>;
118
119
  /**
119
120
  * The backend URL of an absolute path, for a call `backend` cannot make (a
120
121
  * raw response, no relayed header): always on the backend's origin,
121
- * whatever the path holds.
122
+ * whatever the path holds. Fetch it with `redirect: "manual"` to relay a
123
+ * backend redirect: the browser then follows it.
122
124
  */
123
125
  backendUrl(path: string): URL;
124
- /** The client's address, behind `DMS_TRUSTED_PROXY_HOPS` proxies. */
126
+ /**
127
+ * The client's address, from the `DMS_CLIENT_IP_HEADER` header or behind
128
+ * `DMS_TRUSTED_PROXY_HOPS` proxies.
129
+ */
125
130
  clientIp(): string;
126
131
  /** This module, and only this one. */
127
132
  module: {
@@ -273,6 +278,13 @@ export interface DmsBackendProxyOptions {
273
278
  * needed.
274
279
  */
275
280
  auth?: "session" | "none";
281
+ /**
282
+ * The largest request body, in bytes, the server streams to the backend.
283
+ * A larger declared `Content-Length` gets a `413` before any byte is read;
284
+ * a chunked body is cut off with a `413` as soon as it goes over. No limit
285
+ * by default: the backend applies its own.
286
+ */
287
+ maxBodyBytes?: number;
276
288
  }
277
289
 
278
290
  export type DmsServerSetup = (server: DmsServer) => void;