@antelopejs/dms-frontend 0.7.1 → 0.9.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 (98) 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 +16 -41
  4. package/dist/commands/build.js +9 -15
  5. package/dist/commands/dev-action.d.ts +2 -0
  6. package/dist/commands/dev-action.js +14 -3
  7. package/dist/commands/dev.js +3 -1
  8. package/dist/commands/start-action.d.ts +2 -0
  9. package/dist/commands/start-action.js +65 -47
  10. package/dist/commands/start.js +8 -6
  11. package/dist/common.d.ts +1 -0
  12. package/dist/common.js +1 -0
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +9 -0
  15. package/dist/derived-outputs.d.ts +1 -5
  16. package/dist/derived-outputs.js +48 -35
  17. package/dist/dev-modules.d.ts +5 -0
  18. package/dist/dev-modules.js +88 -0
  19. package/dist/entry-weight.d.ts +24 -0
  20. package/dist/entry-weight.js +86 -0
  21. package/dist/fs-sync.d.ts +1 -0
  22. package/dist/fs-sync.js +6 -0
  23. package/dist/help.js +12 -4
  24. package/dist/index.js +4 -6
  25. package/dist/layer-watch.js +7 -2
  26. package/dist/layers.d.ts +2 -3
  27. package/dist/layers.js +1 -95
  28. package/dist/locale-files.d.ts +6 -0
  29. package/dist/locale-files.js +75 -0
  30. package/dist/manifest.d.ts +8 -8
  31. package/dist/manifest.js +17 -39
  32. package/dist/materialize.js +6 -0
  33. package/dist/module-set.d.ts +14 -12
  34. package/dist/module-set.js +39 -31
  35. package/dist/private-values.d.ts +3 -2
  36. package/dist/private-values.js +16 -5
  37. package/dist/project-modules-child.d.ts +13 -0
  38. package/dist/project-modules-child.js +26 -0
  39. package/dist/project-modules.d.ts +47 -0
  40. package/dist/project-modules.js +268 -0
  41. package/dist/workspace-setup.d.ts +3 -7
  42. package/dist/workspace-setup.js +79 -62
  43. package/dist/workspace.d.ts +8 -4
  44. package/dist/workspace.js +1 -1
  45. package/package.json +13 -8
  46. package/skills/dms-frontend/SKILL.md +2 -2
  47. package/templates/vue/app-runtime.ts +8 -1
  48. package/templates/vue/client-chunking.mjs +61 -0
  49. package/templates/vue/email-renderer.ts +6 -1
  50. package/templates/vue/entry-weight.mjs +132 -0
  51. package/templates/vue/frontend-build-loader.ts +1 -1
  52. package/templates/vue/frontend-module.ts +168 -1
  53. package/templates/vue/gitignore +1 -0
  54. package/templates/vue/main.ts +32 -2
  55. package/templates/vue/network-error.ts +3 -1
  56. package/templates/vue/package.json +3 -3
  57. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  58. package/templates/vue/pnpm-workspace.yaml +5 -0
  59. package/templates/vue/prepare-types.mjs +2 -7
  60. package/templates/vue/public-options.ts +19 -0
  61. package/templates/vue/server/auth/backend.mjs +20 -3
  62. package/templates/vue/server/auth/client-ip.mjs +64 -22
  63. package/templates/vue/server/auth/session.mjs +4 -11
  64. package/templates/vue/server/backend-proxies.mjs +13 -2
  65. package/templates/vue/server/backend-response.mjs +2 -13
  66. package/templates/vue/server/cli-report.mjs +42 -18
  67. package/templates/vue/server/client-manifest.mjs +34 -58
  68. package/templates/vue/server/dev-private-files.mjs +1 -1
  69. package/templates/vue/server/dev-styles.mjs +16 -5
  70. package/templates/vue/server/homepage.mjs +4 -1
  71. package/templates/vue/server/in-place-render.mjs +110 -0
  72. package/templates/vue/server/module-event.mjs +23 -8
  73. package/templates/vue/server/module-server.mjs +4 -4
  74. package/templates/vue/server/module-testing.mjs +5 -3
  75. package/templates/vue/server/page-props.mjs +10 -0
  76. package/templates/vue/server/proxy-compression.mjs +15 -8
  77. package/templates/vue/server/proxy-headers.mjs +12 -1
  78. package/templates/vue/server/proxy-redirects.mjs +66 -0
  79. package/templates/vue/server/proxy-transfers.mjs +110 -0
  80. package/templates/vue/server/public-endpoints.mjs +2 -2
  81. package/templates/vue/server/public-options.mjs +49 -0
  82. package/templates/vue/server/request-routing.mjs +20 -0
  83. package/templates/vue/server/web-body.mjs +24 -0
  84. package/templates/vue/server-module.ts +19 -7
  85. package/templates/vue/server.mjs +98 -78
  86. package/templates/vue/sfc-global-types.mjs +119 -0
  87. package/templates/vue/ssr-renderer.ts +27 -1
  88. package/templates/vue/ui-kbd.mjs +45 -0
  89. package/templates/vue/vite.config.ts +104 -47
  90. package/templates/vue/vite.email.config.ts +6 -3
  91. package/templates/vue/vite.server.config.ts +5 -2
  92. package/dist/commands/snapshot-action.d.ts +0 -6
  93. package/dist/commands/snapshot-action.js +0 -53
  94. package/dist/commands/snapshot.d.ts +0 -2
  95. package/dist/commands/snapshot.js +0 -55
  96. package/dist/snapshot.d.ts +0 -25
  97. package/dist/snapshot.js +0 -90
  98. package/templates/vue/server/backend-wake.mjs +0 -129
@@ -0,0 +1,110 @@
1
+ // A page that completes its query as it renders, rendered at that query in
2
+ // the same request rather than answered with a redirect.
3
+ //
4
+ // Lives apart from server.mjs so the file stays under the size the linter
5
+ // allows.
6
+
7
+ import { ofetch } from "ofetch";
8
+
9
+ /**
10
+ * How many times one request renders a page in place of a same-path redirect
11
+ * before it answers the redirect: a page that keeps changing its query.
12
+ */
13
+ const IN_PLACE_RENDERS = 2;
14
+
15
+ /**
16
+ * The key of a backend read a later render of the same request may reuse: a
17
+ * plain JSON `GET`, without hooks that would expect to see the response.
18
+ */
19
+ function reusableReadKey(path, options, headers) {
20
+ const method = (options.method ?? "GET").toUpperCase();
21
+ if (method !== "GET" || options.body !== undefined) return undefined;
22
+ if (options.responseType && options.responseType !== "json") return undefined;
23
+ if (Object.keys(options).some((name) => /^on[A-Z]/.test(name)))
24
+ return undefined;
25
+ return JSON.stringify([
26
+ path,
27
+ options.query ?? options.params ?? null,
28
+ [...headers],
29
+ ]);
30
+ }
31
+
32
+ /**
33
+ * The backend reads of a request's server renders, sent with the request's
34
+ * `backendHeaders()` and the read's own. After `reuseCompletedReads()`, a later
35
+ * render of the same request is given a copy of each read a previous render
36
+ * completed instead of reading the backend again.
37
+ */
38
+ export function serverReads(backendHeaders) {
39
+ const completed = new Map();
40
+ let reuse = false;
41
+ const read = (path, options = {}) => {
42
+ const headers = new Headers(backendHeaders());
43
+ new Headers(options.headers).forEach((value, name) => {
44
+ headers.set(name, value);
45
+ });
46
+ const key = reusableReadKey(path, options, headers);
47
+ if (reuse && key && completed.has(key))
48
+ return Promise.resolve(structuredClone(completed.get(key)));
49
+ const reading = ofetch(path, {
50
+ ...options,
51
+ baseURL: process.env.DMS_API_BASE_URL,
52
+ headers,
53
+ });
54
+ if (!key) return reading;
55
+ // A copy: the render that read it may still change the object it got. A
56
+ // body that cannot be copied is read again.
57
+ return reading.then((data) => {
58
+ try {
59
+ completed.set(key, structuredClone(data));
60
+ } catch {}
61
+ return data;
62
+ });
63
+ };
64
+ return Object.assign(read, {
65
+ reuseCompletedReads: () => {
66
+ reuse = true;
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Whether a document request renders `location` in place of redirecting to
73
+ * it: a `GET` whose page only completes its query (`?workspace=`), on the
74
+ * same path. Any other redirect keeps its own response.
75
+ */
76
+ function rendersInPlace(request, from, location) {
77
+ if (request.method !== "GET" || location.includes("#")) return false;
78
+ const current = new URL(from, "http://frontend.local");
79
+ const target = new URL(location, current);
80
+ return (
81
+ target.origin === current.origin && target.pathname === current.pathname
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Renders a page document with `render(page)`. A page that completes its
87
+ * query as it renders (`router.replace` to the same path) is rendered again at
88
+ * that query, with the props `propsAt(props, url)` gives, reusing the reads
89
+ * the first render made (`fetch`), instead of being answered with a redirect
90
+ * the browser would follow with a whole new request. The page served then
91
+ * names the URL the document shows, which the browser puts in the address bar.
92
+ */
93
+ export async function renderInPlace(request, page, fetch, render, propsAt) {
94
+ let rendered = await render(page);
95
+ let served = page;
96
+ for (
97
+ let pass = 0;
98
+ rendered.redirect &&
99
+ pass < IN_PLACE_RENDERS &&
100
+ rendersInPlace(request, served.url, rendered.redirect);
101
+ pass++
102
+ ) {
103
+ const url = new URL(rendered.redirect, "http://frontend.local");
104
+ const props = await propsAt(served.props, url);
105
+ served = { ...served, url: `${url.pathname}${url.search}`, props };
106
+ fetch.reuseCompletedReads();
107
+ rendered = await render(served);
108
+ }
109
+ return { rendered, served };
110
+ }
@@ -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 = {}) {
@@ -100,6 +100,16 @@ async function loadPage(
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * The props of the same page at another query of its path (`url`): the page
105
+ * is read again, since its layout reads the query, and the rest is kept,
106
+ * the site layout included.
107
+ */
108
+ export async function propsForQuery(props, url, backend, auth) {
109
+ const { page } = await loadPage(url, false, backend, auth.signInPath);
110
+ return { ...props, page };
111
+ }
112
+
103
113
  /**
104
114
  * The props of the page a request visits, with the once props to declare.
105
115
  *
@@ -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,110 @@
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 passed on, a chunked body is cut off as
5
+ // soon as it goes over.
6
+
7
+ const PAYLOAD_TOO_LARGE = 413;
8
+ const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
9
+ // The refused body is never passed on: the iteration stops where it went
10
+ // over, and the request stream stays open for the lingering close below.
11
+ const BODY_ITERATION = { destroyOnReturn: false };
12
+ // How long a refused body is read and dropped before the connection closes
13
+ // anyway: long enough for a client to finish sending what was in flight,
14
+ // short enough to bound what a refused client can make the server read.
15
+ const LINGER_MS = 5_000;
16
+
17
+ const BODILESS_METHODS = new Set(["GET", "HEAD"]);
18
+ const REQUEST_TIMEOUT_MS = 60 * 60 * 1000;
19
+
20
+ /**
21
+ * The frontend server's options. Node's default request timeout (5 minutes to
22
+ * receive a whole request) cut off a large upload on a slow link: an hour lets
23
+ * 500 MiB through at about 150 KB/s. Headers keep Node's own minute.
24
+ */
25
+ export const SERVER_OPTIONS = Object.freeze({
26
+ requestTimeout: REQUEST_TIMEOUT_MS,
27
+ });
28
+
29
+ class BodyTooLargeError extends Error {}
30
+
31
+ async function* limitedChunks(request, maxBodyBytes) {
32
+ let received = 0;
33
+ for await (const chunk of request.iterator(BODY_ITERATION)) {
34
+ received += chunk.byteLength;
35
+ if (received > maxBodyBytes) throw new BodyTooLargeError();
36
+ yield chunk;
37
+ }
38
+ }
39
+
40
+ function requestBody(request, maxBodyBytes) {
41
+ if (BODILESS_METHODS.has(request.method)) return undefined;
42
+ return maxBodyBytes === undefined
43
+ ? request
44
+ : limitedChunks(request, maxBodyBytes);
45
+ }
46
+
47
+ function declaresTooLargeBody(request, maxBodyBytes) {
48
+ return (
49
+ maxBodyBytes !== undefined &&
50
+ Number(request.headers["content-length"] ?? 0) > maxBodyBytes
51
+ );
52
+ }
53
+
54
+ function isBodyTooLarge(error) {
55
+ return (
56
+ error instanceof BodyTooLargeError ||
57
+ error?.cause instanceof BodyTooLargeError
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Answers 413 and closes the connection once the client stopped sending.
63
+ *
64
+ * Closing a socket with unread bytes makes the kernel reset the connection,
65
+ * and a client stack that drops what it had not read yet when the reset
66
+ * arrives (macOS, BSD, Windows) reports a network error instead of the 413,
67
+ * the browser's upload failing without a reason. The rest of the body is read
68
+ * and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
69
+ * at most; the response ends with it and the connection then closes cleanly.
70
+ */
71
+ function refuseTooLargeBody(request, response) {
72
+ response.writeHead(PAYLOAD_TOO_LARGE, {
73
+ "content-type": "application/json",
74
+ connection: "close",
75
+ });
76
+ response.write(TOO_LARGE_BODY);
77
+ if (request.complete) return response.end();
78
+ const close = () => {
79
+ clearTimeout(timer);
80
+ response.end();
81
+ };
82
+ const timer = setTimeout(() => {
83
+ response.end();
84
+ request.socket.destroy();
85
+ }, LINGER_MS);
86
+ timer.unref();
87
+ request.once("end", close);
88
+ request.once("error", close);
89
+ request.resume();
90
+ }
91
+
92
+ /**
93
+ * Sends a proxied request, its body held to `maxBodyBytes`.
94
+ *
95
+ * @param send `(body) => Promise<Response>`, the backend request with the
96
+ * body to stream: none for a GET or a HEAD, the request itself, or its
97
+ * bytes cut off past the limit
98
+ * @returns The backend's answer, or `undefined` once the body went over and
99
+ * `response` got the 413
100
+ */
101
+ export async function sendWithinLimit(request, response, maxBodyBytes, send) {
102
+ if (declaresTooLargeBody(request, maxBodyBytes))
103
+ return refuseTooLargeBody(request, response);
104
+ try {
105
+ return await send(requestBody(request, maxBodyBytes));
106
+ } catch (error) {
107
+ if (!isBodyTooLarge(error)) throw error;
108
+ return refuseTooLargeBody(request, response);
109
+ }
110
+ }
@@ -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
+ }