@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.
- package/README.md +4 -4
- package/dist/commands/build-action.d.ts +8 -5
- package/dist/commands/build-action.js +16 -41
- package/dist/commands/build.js +9 -15
- package/dist/commands/dev-action.d.ts +2 -0
- package/dist/commands/dev-action.js +14 -3
- package/dist/commands/dev.js +3 -1
- package/dist/commands/start-action.d.ts +2 -0
- package/dist/commands/start-action.js +65 -47
- package/dist/commands/start.js +8 -6
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/derived-outputs.d.ts +1 -5
- package/dist/derived-outputs.js +48 -35
- package/dist/dev-modules.d.ts +5 -0
- package/dist/dev-modules.js +88 -0
- package/dist/entry-weight.d.ts +24 -0
- package/dist/entry-weight.js +86 -0
- package/dist/fs-sync.d.ts +1 -0
- package/dist/fs-sync.js +6 -0
- package/dist/help.js +12 -4
- package/dist/index.js +4 -6
- package/dist/layer-watch.js +7 -2
- package/dist/layers.d.ts +2 -3
- package/dist/layers.js +1 -95
- package/dist/locale-files.d.ts +6 -0
- package/dist/locale-files.js +75 -0
- package/dist/manifest.d.ts +8 -8
- package/dist/manifest.js +17 -39
- package/dist/materialize.js +6 -0
- package/dist/module-set.d.ts +14 -12
- package/dist/module-set.js +39 -31
- package/dist/private-values.d.ts +3 -2
- package/dist/private-values.js +16 -5
- package/dist/project-modules-child.d.ts +13 -0
- package/dist/project-modules-child.js +26 -0
- package/dist/project-modules.d.ts +47 -0
- package/dist/project-modules.js +268 -0
- package/dist/workspace-setup.d.ts +3 -7
- package/dist/workspace-setup.js +79 -62
- package/dist/workspace.d.ts +8 -4
- package/dist/workspace.js +1 -1
- package/package.json +13 -8
- package/skills/dms-frontend/SKILL.md +2 -2
- package/templates/vue/app-runtime.ts +8 -1
- package/templates/vue/client-chunking.mjs +61 -0
- package/templates/vue/email-renderer.ts +6 -1
- package/templates/vue/entry-weight.mjs +132 -0
- package/templates/vue/frontend-build-loader.ts +1 -1
- package/templates/vue/frontend-module.ts +168 -1
- package/templates/vue/gitignore +1 -0
- package/templates/vue/main.ts +32 -2
- package/templates/vue/network-error.ts +3 -1
- package/templates/vue/package.json +3 -3
- package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
- package/templates/vue/pnpm-workspace.yaml +5 -0
- package/templates/vue/prepare-types.mjs +2 -7
- package/templates/vue/public-options.ts +19 -0
- package/templates/vue/server/auth/backend.mjs +20 -3
- package/templates/vue/server/auth/client-ip.mjs +64 -22
- package/templates/vue/server/auth/session.mjs +4 -11
- package/templates/vue/server/backend-proxies.mjs +13 -2
- package/templates/vue/server/backend-response.mjs +2 -13
- package/templates/vue/server/cli-report.mjs +42 -18
- package/templates/vue/server/client-manifest.mjs +34 -58
- package/templates/vue/server/dev-private-files.mjs +1 -1
- package/templates/vue/server/dev-styles.mjs +16 -5
- package/templates/vue/server/homepage.mjs +4 -1
- package/templates/vue/server/in-place-render.mjs +110 -0
- package/templates/vue/server/module-event.mjs +23 -8
- package/templates/vue/server/module-server.mjs +4 -4
- package/templates/vue/server/module-testing.mjs +5 -3
- package/templates/vue/server/page-props.mjs +10 -0
- package/templates/vue/server/proxy-compression.mjs +15 -8
- package/templates/vue/server/proxy-headers.mjs +12 -1
- package/templates/vue/server/proxy-redirects.mjs +66 -0
- package/templates/vue/server/proxy-transfers.mjs +110 -0
- package/templates/vue/server/public-endpoints.mjs +2 -2
- package/templates/vue/server/public-options.mjs +49 -0
- package/templates/vue/server/request-routing.mjs +20 -0
- package/templates/vue/server/web-body.mjs +24 -0
- package/templates/vue/server-module.ts +19 -7
- package/templates/vue/server.mjs +98 -78
- package/templates/vue/sfc-global-types.mjs +119 -0
- package/templates/vue/ssr-renderer.ts +27 -1
- package/templates/vue/ui-kbd.mjs +45 -0
- package/templates/vue/vite.config.ts +104 -47
- package/templates/vue/vite.email.config.ts +6 -3
- package/templates/vue/vite.server.config.ts +5 -2
- package/dist/commands/snapshot-action.d.ts +0 -6
- package/dist/commands/snapshot-action.js +0 -53
- package/dist/commands/snapshot.d.ts +0 -2
- package/dist/commands/snapshot.js +0 -55
- package/dist/snapshot.d.ts +0 -25
- package/dist/snapshot.js +0 -90
- 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
|
|
235
|
+
.concat(relayableCookies(answer));
|
|
228
236
|
answer.headers.forEach((value, name) => {
|
|
229
|
-
if (name
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
/**
|
|
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 = {
|
|
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
|
-
|
|
99
|
-
|
|
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
|
+
}
|