@antelopejs/dms-frontend 0.9.3 → 0.10.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 +18 -24
- package/dist/commands/build-action.js +3 -2
- package/dist/commands/dev-action.js +2 -1
- package/dist/commands/prepare-action.js +1 -1
- package/dist/commands/start-action.js +3 -24
- package/dist/commands/workspace-task.js +2 -1
- package/dist/derived-outputs.js +3 -3
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- package/dist/index.js +2 -1
- package/dist/internal/built-workspace.d.ts +4 -0
- package/dist/internal/built-workspace.js +43 -0
- package/dist/internal/ensure.d.ts +1 -0
- package/dist/internal/ensure.js +8 -0
- package/dist/internal/locale-catalogs.d.ts +8 -3
- package/dist/internal/locale-catalogs.js +16 -6
- package/dist/internal/manifest-decoder.d.ts +7 -0
- package/dist/internal/manifest-decoder.js +81 -0
- package/dist/internal/own-release.d.ts +5 -0
- package/dist/internal/own-release.js +20 -0
- package/dist/internal/plain-object.d.ts +1 -0
- package/dist/internal/plain-object.js +6 -0
- package/dist/internal/text-list.d.ts +1 -0
- package/dist/internal/text-list.js +10 -0
- package/dist/internal/vite-manifest.d.ts +6 -0
- package/dist/internal/vite-manifest.js +24 -0
- package/dist/layer-watch.js +25 -24
- package/dist/layers.d.ts +1 -1
- package/dist/layers.js +3 -1
- package/dist/locale-files.d.ts +2 -1
- package/dist/locale-files.js +4 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +44 -31
- package/dist/materialize.js +6 -2
- package/dist/module-set.js +2 -1
- package/dist/node-options.d.ts +1 -0
- package/dist/node-options.js +8 -0
- package/dist/output.d.ts +15 -9
- package/dist/output.js +30 -18
- package/dist/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.js +13 -8
- package/dist/renderer-range.js +5 -3
- package/dist/server-process.js +1 -1
- package/dist/verify-source-build.js +10 -2
- package/dist/verify-source-client-bundle.d.ts +1 -0
- package/dist/verify-source-client-bundle.js +30 -0
- package/dist/verify-source-runner.js +44 -60
- package/dist/verify-source-sources.js +49 -11
- package/dist/workspace-setup.js +1 -1
- package/package.json +6 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +2 -9
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +22 -0
- package/templates/vue/frontend-module.ts +1 -0
- package/templates/vue/frontend-runtime/module-registration.ts +2 -2
- package/templates/vue/frontend-runtime/state.ts +2 -2
- package/templates/vue/frontend-runtime/types.ts +8 -2
- package/templates/vue/head-order.mjs +7 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/server/auth/backend.mjs +41 -4
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
- package/templates/vue/server/auth/session.mjs +81 -2
- package/templates/vue/server/auth-hooks.mjs +15 -5
- package/templates/vue/server/backend-proxies.mjs +65 -9
- package/templates/vue/server/backend-request.mjs +61 -0
- package/templates/vue/server/backend-response.mjs +23 -6
- package/templates/vue/server/built-files.mjs +7 -0
- package/templates/vue/server/cli-report.mjs +53 -11
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +18 -1
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email.mjs +9 -3
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +211 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +80 -7
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +61 -2
- package/templates/vue/server/module-event.mjs +98 -17
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +138 -31
- package/templates/vue/server/module-server.mjs +98 -27
- package/templates/vue/server/module-testing.mjs +14 -5
- package/templates/vue/server/page-props.mjs +92 -2
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +13 -1
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +11 -1
- package/templates/vue/server/proxy-redirects.mjs +20 -5
- package/templates/vue/server/proxy-request.mjs +57 -0
- package/templates/vue/server/proxy-transfers.mjs +33 -5
- package/templates/vue/server/public-endpoints.mjs +138 -23
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +23 -0
- package/templates/vue/server/request-routing.mjs +26 -9
- package/templates/vue/server/request-target.mjs +9 -3
- package/templates/vue/server/route-rules.mjs +39 -15
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +24 -1
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +32 -5
- package/templates/vue/server/web-body.mjs +3 -0
- package/templates/vue/server-module.ts +10 -1
- package/templates/vue/server.mjs +132 -148
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +2 -2
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-request-state.mjs +2 -1
|
@@ -2,10 +2,16 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
|
|
3
3
|
import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
|
|
4
4
|
import { createServerModules, handleModuleRoute } from "./module-server.mjs";
|
|
5
|
+
import { failureStatus } from "./request-failure.mjs";
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* What the frontend server answers an API request whose handler failed.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} error
|
|
11
|
+
* @param {import("node:http").ServerResponse} response
|
|
12
|
+
*/
|
|
7
13
|
function writeFailure(error, response) {
|
|
8
|
-
const status =
|
|
14
|
+
const status = failureStatus(error);
|
|
9
15
|
const body =
|
|
10
16
|
error instanceof UpstreamError || error instanceof RequestBodyError
|
|
11
17
|
? { message: error.message }
|
|
@@ -26,9 +32,9 @@ function writeFailure(error, response) {
|
|
|
26
32
|
* `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the environment, as in
|
|
27
33
|
* production.
|
|
28
34
|
*
|
|
29
|
-
* @param declarations `{ id, priority?, setup }` per module, `setup` being
|
|
35
|
+
* @param {readonly import("./module-server.mjs").ServerModuleDeclaration[]} declarations `{ id, priority?, setup }` per module, `setup` being
|
|
30
36
|
* the default export of its `dms.server.ts`
|
|
31
|
-
* @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
|
|
37
|
+
* @param {Omit<import("./module-server.mjs").Deployment, "warn" | "authEstablishEndpoints"> & { authEstablishEndpoints?: readonly string[] }} [deployment] `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
|
|
32
38
|
* publicEndpoints?, backendProxies? }`, options by module id, and the
|
|
33
39
|
* manifest's backend proxies (`{ module, path, auth? }`), which decide
|
|
34
40
|
* the backend redirects relayed as frontend paths. The private options are
|
|
@@ -37,6 +43,7 @@ function writeFailure(error, response) {
|
|
|
37
43
|
* @returns `{ url, modules, warnings, close() }`
|
|
38
44
|
*/
|
|
39
45
|
export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
46
|
+
/** @type {string[]} */
|
|
40
47
|
const warnings = [];
|
|
41
48
|
const endpoints = deployment.authEstablishEndpoints ?? [];
|
|
42
49
|
const modules = createServerModules(declarations, {
|
|
@@ -50,7 +57,9 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
|
50
57
|
.then((answered) => {
|
|
51
58
|
if (!answered) response.writeHead(404).end();
|
|
52
59
|
})
|
|
53
|
-
.catch(
|
|
60
|
+
.catch(
|
|
61
|
+
/** @param {unknown} error */ (error) => writeFailure(error, response),
|
|
62
|
+
);
|
|
54
63
|
});
|
|
55
64
|
await new Promise((settle) => server.listen(0, "127.0.0.1", settle));
|
|
56
65
|
const { port } = server.address();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readSession } from "./auth/session.mjs";
|
|
3
|
+
import { GENERIC_ERROR_MESSAGE, UpstreamError } from "./auth/backend.mjs";
|
|
3
4
|
import { BackendResponseError } from "./backend-response.mjs";
|
|
4
5
|
import { heldOnceProps, requestedProps } from "./inertia.mjs";
|
|
6
|
+
import { isObjectLike, isRecord } from "./type-guards.mjs";
|
|
5
7
|
|
|
6
8
|
export const SHARED_PROP = "shared";
|
|
7
9
|
/**
|
|
@@ -15,10 +17,58 @@ export const SHARED_PROP_TTL_MS = 5 * 60 * 1000;
|
|
|
15
17
|
const SITE_LAYOUT_PATH = "/dms/sitelayout";
|
|
16
18
|
const NOT_FOUND = 404;
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The props of a page, each only when the request asks for it.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* path?: string,
|
|
25
|
+
* user?: unknown,
|
|
26
|
+
* session?: unknown,
|
|
27
|
+
* page?: unknown,
|
|
28
|
+
* shared?: unknown
|
|
29
|
+
* }} PageProps
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a page request reads of the backend: its JSON for a path (any value;
|
|
34
|
+
* `loadPage` refuses a page answer that is not an object), and whether the
|
|
35
|
+
* frontend itself serves a path.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {{
|
|
38
|
+
* fetchJson: (path: string) => Promise<unknown>,
|
|
39
|
+
* isFrontendPage: (pathname: string) => Promise<boolean>
|
|
40
|
+
* }} PageBackend
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a page request reads of the auth hooks: where sign-in is, and what a
|
|
45
|
+
* session shows a page.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {{
|
|
48
|
+
* signInPath: string,
|
|
49
|
+
* view: (session: unknown) => { user?: unknown, session?: unknown }
|
|
50
|
+
* }} PageAuth
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The route the backend described for the page of `props`, as the backend
|
|
55
|
+
* sent it: any JSON value, `undefined` when the page has none.
|
|
56
|
+
*
|
|
57
|
+
* @param {PageProps} props
|
|
58
|
+
* @returns {unknown}
|
|
59
|
+
*/
|
|
60
|
+
export function pageRoute(props) {
|
|
61
|
+
return isObjectLike(props.page) ? props.page.route : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
/**
|
|
19
65
|
* The once-prop key of the site layout: one per account and workspace. A
|
|
20
66
|
* browser holding the layout of another account or workspace, after a sign-in,
|
|
21
67
|
* a sign-out or a switch, holds another key, so it is sent the right one.
|
|
68
|
+
*
|
|
69
|
+
* @param {unknown} session The sealed session: any JSON value, since only its
|
|
70
|
+
* `accountId` and `activeTenantId` are read, as empty when it holds none
|
|
71
|
+
* @returns {string}
|
|
22
72
|
*/
|
|
23
73
|
export function sharedOnceKey(session) {
|
|
24
74
|
const identity = `${session?.accountId ?? ""}\n${session?.activeTenantId ?? ""}`;
|
|
@@ -30,6 +80,10 @@ export function sharedOnceKey(session) {
|
|
|
30
80
|
* Whether the browser holds this session's layout and no other: a browser
|
|
31
81
|
* that holds another session's key too (a switch back and forth without a
|
|
32
82
|
* full visit) may show that one under `shared`, so it is sent this one.
|
|
83
|
+
*
|
|
84
|
+
* @param {Set<string>} held
|
|
85
|
+
* @param {string} sharedKey
|
|
86
|
+
* @returns {boolean}
|
|
33
87
|
*/
|
|
34
88
|
function holdsOnlyThisLayout(held, sharedKey) {
|
|
35
89
|
if (!held.has(sharedKey)) return false;
|
|
@@ -50,9 +104,14 @@ const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
|
|
|
50
104
|
* on), so the layout is resolved for the query the browser shows and the
|
|
51
105
|
* browser does not ask it again. One value per name, the last, as the
|
|
52
106
|
* browser's own page layout request sends it.
|
|
107
|
+
*
|
|
108
|
+
* @param {URL} url
|
|
109
|
+
* @param {boolean} withShared
|
|
110
|
+
* @returns {string}
|
|
53
111
|
*/
|
|
54
112
|
function pagePath(url, withShared) {
|
|
55
113
|
const query = new URLSearchParams({ path: url.pathname });
|
|
114
|
+
/** @type {Map<string, string>} */
|
|
56
115
|
const pageQuery = new Map();
|
|
57
116
|
for (const [name, value] of url.searchParams) {
|
|
58
117
|
if (!PAGE_REQUEST_PARAMETERS.has(name)) pageQuery.set(name, value);
|
|
@@ -62,7 +121,12 @@ function pagePath(url, withShared) {
|
|
|
62
121
|
return `/dms/page?${query}`;
|
|
63
122
|
}
|
|
64
123
|
|
|
65
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* A page only the frontend has: a custom page, with no backend data.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} pathname
|
|
128
|
+
* @param {string} signInPath
|
|
129
|
+
*/
|
|
66
130
|
function frontendOnlyPage(pathname, signInPath) {
|
|
67
131
|
return {
|
|
68
132
|
componentName: pathname,
|
|
@@ -76,6 +140,13 @@ function frontendOnlyPage(pathname, signInPath) {
|
|
|
76
140
|
};
|
|
77
141
|
}
|
|
78
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @param {URL} url
|
|
145
|
+
* @param {boolean} withShared
|
|
146
|
+
* @param {PageBackend} backend
|
|
147
|
+
* @param {string} signInPath
|
|
148
|
+
* @returns {Promise<{ page: PageProps["page"], shared: unknown }>}
|
|
149
|
+
*/
|
|
79
150
|
async function loadPage(
|
|
80
151
|
url,
|
|
81
152
|
withShared,
|
|
@@ -84,7 +155,10 @@ async function loadPage(
|
|
|
84
155
|
) {
|
|
85
156
|
const { pathname } = url;
|
|
86
157
|
try {
|
|
87
|
-
const
|
|
158
|
+
const answer = await fetchJson(pagePath(url, withShared));
|
|
159
|
+
if (!isRecord(answer) || Array.isArray(answer))
|
|
160
|
+
throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
|
|
161
|
+
const { shared, ...page } = answer;
|
|
88
162
|
return { page, shared };
|
|
89
163
|
} catch (error) {
|
|
90
164
|
if (!(error instanceof BackendResponseError) || error.status !== NOT_FOUND)
|
|
@@ -101,6 +175,12 @@ async function loadPage(
|
|
|
101
175
|
* The props of the same page at another query of its path (`url`): the page
|
|
102
176
|
* is read again, since its layout reads the query, and the rest is kept,
|
|
103
177
|
* the site layout included.
|
|
178
|
+
*
|
|
179
|
+
* @param {PageProps} props
|
|
180
|
+
* @param {URL} url
|
|
181
|
+
* @param {PageBackend} backend
|
|
182
|
+
* @param {PageAuth} auth
|
|
183
|
+
* @returns {Promise<PageProps>}
|
|
104
184
|
*/
|
|
105
185
|
export async function propsForQuery(props, url, backend, auth) {
|
|
106
186
|
const { page } = await loadPage(url, false, backend, auth.signInPath);
|
|
@@ -125,6 +205,14 @@ export async function propsForQuery(props, url, backend, auth) {
|
|
|
125
205
|
* The shared layout is declared only when the browser ends up with this
|
|
126
206
|
* session's layout, sent now or already held: declaring it otherwise would let
|
|
127
207
|
* a layout of another session pass for this one.
|
|
208
|
+
*
|
|
209
|
+
* @param {import("node:http").IncomingMessage} request
|
|
210
|
+
* @param {PageBackend} backend
|
|
211
|
+
* @param {PageAuth} auth
|
|
212
|
+
* @returns {Promise<{
|
|
213
|
+
* props: PageProps,
|
|
214
|
+
* onceProps: import("./inertia.mjs").OnceProps | undefined
|
|
215
|
+
* }>}
|
|
128
216
|
*/
|
|
129
217
|
export async function loadPageProps(request, backend, auth) {
|
|
130
218
|
const url = new URL(request.url, "http://frontend.local");
|
|
@@ -135,10 +223,12 @@ export async function loadPageProps(request, backend, auth) {
|
|
|
135
223
|
const sharedKey = sharedOnceKey(sealed);
|
|
136
224
|
const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
|
|
137
225
|
const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
|
|
226
|
+
/** @type {PageProps} */
|
|
138
227
|
const props = {};
|
|
139
228
|
if (wants("path")) props.path = pathname;
|
|
140
229
|
if (wants("user") && (user || partial)) props.user = user ?? null;
|
|
141
230
|
if (wants("session") && (session || partial)) props.session = session ?? null;
|
|
231
|
+
/** @type {unknown} */
|
|
142
232
|
let shared;
|
|
143
233
|
if (wants("page")) {
|
|
144
234
|
const loaded = await loadPage(url, withShared, backend, auth.signInPath);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readSession } from "./auth/session.mjs";
|
|
2
|
+
import { HOMEPAGE } from "./homepage.mjs";
|
|
3
|
+
import { redirectFrontendVisit } from "./inertia.mjs";
|
|
4
|
+
import { pageRoute } from "./page-props.mjs";
|
|
5
|
+
import { isObjectLike } from "./type-guards.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {import("node:http").IncomingMessage} request
|
|
9
|
+
* @param {import("node:http").ServerResponse} response
|
|
10
|
+
* @param {string} pathname
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function redirectToHomepage(request, response, pathname) {
|
|
14
|
+
if (pathname !== "/" || HOMEPAGE === "/") return false;
|
|
15
|
+
redirectFrontendVisit(request, response, HOMEPAGE);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {import("node:http").IncomingMessage} request
|
|
21
|
+
* @param {import("node:http").ServerResponse} response
|
|
22
|
+
* @param {import("./page-props.mjs").PageProps} props
|
|
23
|
+
* @param {import("./module-server.mjs").ServerModules["serverAuth"]} auth
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function redirectSignedOutPrivatePage(request, response, props, auth) {
|
|
27
|
+
const route = pageRoute(props);
|
|
28
|
+
if (
|
|
29
|
+
!isObjectLike(route) ||
|
|
30
|
+
route.publicAccess === true ||
|
|
31
|
+
route.hasAccess !== false ||
|
|
32
|
+
readSession(request)
|
|
33
|
+
)
|
|
34
|
+
return false;
|
|
35
|
+
redirectFrontendVisit(request, response, auth.signInLocation(request.url));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { isString } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} CompiledPathPattern
|
|
7
|
+
* @property {string} pattern The pattern as declared
|
|
8
|
+
* @property {number} specificity Orders patterns from the least to the most specific
|
|
9
|
+
* @property {(pathname: string) => boolean} test Whether the pattern covers `pathname`
|
|
10
|
+
*/
|
|
11
|
+
|
|
3
12
|
/**
|
|
4
13
|
* A pattern made usable, or `undefined` when it is malformed.
|
|
5
14
|
*
|
|
@@ -9,9 +18,12 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
|
9
18
|
* Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
|
|
10
19
|
* page itself, `/x/*` one segment below it, `/x/**` the page and everything
|
|
11
20
|
* below it. Nothing else: no prefix match inside a segment, no query string.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} pattern
|
|
23
|
+
* @returns {CompiledPathPattern | undefined}
|
|
12
24
|
*/
|
|
13
25
|
export function compilePathPattern(pattern) {
|
|
14
|
-
if (
|
|
26
|
+
if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
|
|
15
27
|
const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
16
28
|
const deep = segments.at(-1) === "**";
|
|
17
29
|
const literal = deep ? segments.slice(0, -1) : segments;
|
|
@@ -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
|
-
/**
|
|
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,
|
|
@@ -1,6 +1,10 @@
|
|
|
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) {
|
|
5
9
|
try {
|
|
6
10
|
return new URL(url).origin;
|
|
@@ -9,7 +13,13 @@ function originOf(url) {
|
|
|
9
13
|
}
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Whether `url` is on the backend's origin.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} url
|
|
20
|
+
* @param {string} [backendBaseUrl]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
13
23
|
export function isBackendUrl(
|
|
14
24
|
url,
|
|
15
25
|
backendBaseUrl = process.env.DMS_API_BASE_URL,
|
|
@@ -34,16 +44,18 @@ export function isBackendUrl(
|
|
|
34
44
|
*
|
|
35
45
|
* `//host` would name another host, so `/.` keeps the location a path.
|
|
36
46
|
*
|
|
37
|
-
* @param location The backend's `Location`
|
|
38
|
-
* @param fetchedUrl The backend URL the answer came from
|
|
39
|
-
* @param
|
|
40
|
-
* sends that path to the backend
|
|
47
|
+
* @param {string} location The backend's `Location`
|
|
48
|
+
* @param {string | URL} fetchedUrl The backend URL the answer came from
|
|
49
|
+
* @param {(pathname: string) => boolean} [isProxiedPath] Whether the frontend
|
|
50
|
+
* server sends that path to the backend
|
|
51
|
+
* @returns {string}
|
|
41
52
|
*/
|
|
42
53
|
export function relayedLocation(
|
|
43
54
|
location,
|
|
44
55
|
fetchedUrl,
|
|
45
56
|
isProxiedPath = () => false,
|
|
46
57
|
) {
|
|
58
|
+
/** @type {URL} */
|
|
47
59
|
let target;
|
|
48
60
|
try {
|
|
49
61
|
target = new URL(location, fetchedUrl);
|
|
@@ -60,6 +72,9 @@ export function relayedLocation(
|
|
|
60
72
|
* The cookies of a `Response` a module route returns. A fetch that followed a
|
|
61
73
|
* redirect to another site would set that site's cookies on the frontend's
|
|
62
74
|
* origin: those are dropped.
|
|
75
|
+
*
|
|
76
|
+
* @param {Response} answer
|
|
77
|
+
* @returns {string[]}
|
|
63
78
|
*/
|
|
64
79
|
export function relayableCookies(answer) {
|
|
65
80
|
if (answer.redirected && !isBackendUrl(answer.url)) return [];
|
|
@@ -0,0 +1,57 @@
|
|
|
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("node:http").IncomingMessage} 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
|
+
const init = {
|
|
40
|
+
signal: abandoned.signal,
|
|
41
|
+
method: request.method,
|
|
42
|
+
headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
|
|
43
|
+
duplex: "half",
|
|
44
|
+
redirect: "manual",
|
|
45
|
+
};
|
|
46
|
+
const upstream = await sendWithinLimit(
|
|
47
|
+
request,
|
|
48
|
+
response,
|
|
49
|
+
backendProxy.maxBodyBytes,
|
|
50
|
+
(body) => fetch(backendUrl(request.url), { ...init, body }),
|
|
51
|
+
);
|
|
52
|
+
if (!upstream) return true;
|
|
53
|
+
await relayAnswer(request, response, upstream, {
|
|
54
|
+
fetched: true,
|
|
55
|
+
isProxiedPath,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -26,6 +26,11 @@ export const SERVER_OPTIONS = Object.freeze({
|
|
|
26
26
|
|
|
27
27
|
class BodyTooLargeError extends Error {}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @param {import("node:http").IncomingMessage} request
|
|
31
|
+
* @param {number} maxBodyBytes
|
|
32
|
+
* @returns {AsyncGenerator<Buffer>}
|
|
33
|
+
*/
|
|
29
34
|
async function* limitedChunks(request, maxBodyBytes) {
|
|
30
35
|
let received = 0;
|
|
31
36
|
for await (const chunk of request.iterator(BODY_ITERATION)) {
|
|
@@ -35,6 +40,10 @@ async function* limitedChunks(request, maxBodyBytes) {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {import("node:http").IncomingMessage} request
|
|
45
|
+
* @param {number | undefined} maxBodyBytes
|
|
46
|
+
*/
|
|
38
47
|
function requestBody(request, maxBodyBytes) {
|
|
39
48
|
if (BODILESS_METHODS.has(request.method)) return undefined;
|
|
40
49
|
return maxBodyBytes === undefined
|
|
@@ -42,6 +51,11 @@ function requestBody(request, maxBodyBytes) {
|
|
|
42
51
|
: limitedChunks(request, maxBodyBytes);
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* @param {import("node:http").IncomingMessage} request
|
|
56
|
+
* @param {number | undefined} maxBodyBytes
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
45
59
|
function declaresTooLargeBody(request, maxBodyBytes) {
|
|
46
60
|
return (
|
|
47
61
|
maxBodyBytes !== undefined &&
|
|
@@ -49,6 +63,10 @@ function declaresTooLargeBody(request, maxBodyBytes) {
|
|
|
49
63
|
);
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} error
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
52
70
|
function isBodyTooLarge(error) {
|
|
53
71
|
return (
|
|
54
72
|
error instanceof BodyTooLargeError ||
|
|
@@ -65,6 +83,9 @@ function isBodyTooLarge(error) {
|
|
|
65
83
|
* the browser's upload failing without a reason. The rest of the body is read
|
|
66
84
|
* and dropped instead (RFC 9112, section 9.6, "tear-down"), for `LINGER_MS`
|
|
67
85
|
* at most; the response ends with it and the connection then closes cleanly.
|
|
86
|
+
*
|
|
87
|
+
* @param {import("node:http").IncomingMessage} request
|
|
88
|
+
* @param {import("node:http").ServerResponse} response
|
|
68
89
|
*/
|
|
69
90
|
function refuseTooLargeBody(request, response) {
|
|
70
91
|
response.writeHead(PAYLOAD_TOO_LARGE, {
|
|
@@ -96,11 +117,18 @@ function refuseTooLargeBody(request, response) {
|
|
|
96
117
|
* before a byte is passed on, a chunked body is cut off as soon as it goes
|
|
97
118
|
* over.
|
|
98
119
|
*
|
|
99
|
-
* @param
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* @
|
|
103
|
-
*
|
|
120
|
+
* @param {import("node:http").IncomingMessage} request
|
|
121
|
+
* @param {import("node:http").ServerResponse} response
|
|
122
|
+
* @param {number | undefined} maxBodyBytes
|
|
123
|
+
* @param {(
|
|
124
|
+
* body: import("node:http").IncomingMessage | AsyncGenerator<Buffer> | undefined
|
|
125
|
+
* ) => Promise<Response>} send The backend request with the body to stream:
|
|
126
|
+
* none for a GET or a HEAD, the request itself, or its bytes cut off past
|
|
127
|
+
* the limit
|
|
128
|
+
* @returns {Promise<Response | import("node:http").ServerResponse | undefined>}
|
|
129
|
+
* The backend's answer, or once the body went over and `response` got the
|
|
130
|
+
* 413: `undefined`, or `response` itself when the request had been read
|
|
131
|
+
* already (`response.end()`'s own return)
|
|
104
132
|
*/
|
|
105
133
|
export async function sendWithinLimit(request, response, maxBodyBytes, send) {
|
|
106
134
|
if (declaresTooLargeBody(request, maxBodyBytes))
|