@antelopejs/dms-frontend 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +376 -18
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +8 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +84 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-runner.js +12 -0
  26. package/dist/workspace.d.ts +9 -0
  27. package/package.json +2 -1
  28. package/templates/vue/app-runtime.ts +58 -16
  29. package/templates/vue/client-server-guard.mjs +73 -0
  30. package/templates/vue/email-renderer.ts +17 -4
  31. package/templates/vue/email-runtime.ts +1 -1
  32. package/templates/vue/frontend-module.ts +1817 -259
  33. package/templates/vue/globals.d.ts +1 -1
  34. package/templates/vue/icon-hydration.ts +106 -1
  35. package/templates/vue/index.html +0 -1
  36. package/templates/vue/layer-app-config.ts +92 -0
  37. package/templates/vue/main.ts +10 -2
  38. package/templates/vue/package.json +4 -1
  39. package/templates/vue/server/auth/backend.mjs +13 -19
  40. package/templates/vue/server/auth/client-ip.mjs +27 -0
  41. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  42. package/templates/vue/server/auth/session.mjs +6 -97
  43. package/templates/vue/server/auth-hooks.mjs +84 -0
  44. package/templates/vue/server/backend-proxies.mjs +110 -0
  45. package/templates/vue/server/dev-private-files.mjs +99 -0
  46. package/templates/vue/server/email.mjs +11 -33
  47. package/templates/vue/server/favicon.mjs +20 -0
  48. package/templates/vue/server/inertia.mjs +48 -3
  49. package/templates/vue/server/listen-host.mjs +19 -0
  50. package/templates/vue/server/module-event.mjs +265 -0
  51. package/templates/vue/server/module-routes.mjs +513 -0
  52. package/templates/vue/server/module-server.mjs +225 -0
  53. package/templates/vue/server/module-testing.mjs +65 -0
  54. package/templates/vue/server/page-props.mjs +151 -0
  55. package/templates/vue/server/path-patterns.mjs +38 -0
  56. package/templates/vue/server/public-endpoints.mjs +69 -11
  57. package/templates/vue/server/request-routing.mjs +47 -0
  58. package/templates/vue/server/route-rules.mjs +129 -0
  59. package/templates/vue/server-module.ts +294 -0
  60. package/templates/vue/server.mjs +177 -161
  61. package/templates/vue/ssr-renderer.ts +60 -19
  62. package/templates/vue/tsconfig.json +2 -1
  63. package/templates/vue/ui-app-config.ts +18 -6
  64. package/templates/vue/ui-inertia-page.mjs +37 -0
  65. package/templates/vue/vite.config.ts +31 -10
  66. package/templates/vue/vite.email.config.ts +1 -1
  67. package/templates/vue/vite.server.config.ts +57 -0
  68. package/templates/vue/server/auth/oauth.mjs +0 -233
  69. package/templates/vue/server/auth/routes.mjs +0 -444
  70. package/templates/vue/server/render-token.mjs +0 -81
  71. package/templates/vue/server/tester.mjs +0 -227
@@ -0,0 +1,225 @@
1
+ // Loads what the frontend modules declare in their `dms.server.ts` and hands
2
+ // a request to the module route it reaches. In development the declarations
3
+ // come from Vite (`ssrLoadModule`), so an edited entry or handler answers on
4
+ // the next request; in production from the bundle `ajs dms build` makes
5
+ // (`dist/server-modules`). A workspace built before the feature has none.
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
10
+ import { serverAuth } from "./auth-hooks.mjs";
11
+ import { answerModuleRoute } from "./module-event.mjs";
12
+ import {
13
+ matchModuleRoute,
14
+ mayHaveModuleRoute,
15
+ moduleRouteMethods,
16
+ resolveServerDeclarations,
17
+ } from "./module-routes.mjs";
18
+ import {
19
+ allowedPublicEndpoints,
20
+ environmentBackendProxies,
21
+ readDeclaredBackendProxies,
22
+ } from "./public-endpoints.mjs";
23
+
24
+ const WORKSPACE_ROOT = new URL("../", import.meta.url);
25
+ const SOURCE_LOADER = "/server-modules.generated.ts";
26
+ const BUILT_LOADER = new URL(
27
+ "dist/server-modules/server-modules.generated.js",
28
+ WORKSPACE_ROOT,
29
+ );
30
+ const REGISTRY_FILE = new URL(
31
+ "generated-frontend-modules.json",
32
+ WORKSPACE_ROOT,
33
+ );
34
+ const SERVER_OPTIONS_FILE = new URL(
35
+ "generated-server-options.json",
36
+ WORKSPACE_ROOT,
37
+ );
38
+
39
+ let builtDeclarations;
40
+ let lastDeclarations = [];
41
+ let lastLoadError;
42
+ const resolved = new WeakMap();
43
+
44
+ function readJsonFile(url, fallback) {
45
+ try {
46
+ return JSON.parse(readFileSync(url, "utf8"));
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ /** Sets `value` at `path` when the object holding it exists but lacks it. */
53
+ function fillPath(target, path, value) {
54
+ let node = target;
55
+ for (const key of path.slice(0, -1)) {
56
+ if (typeof node[key] !== "object" || node[key] === null) return;
57
+ node = node[key];
58
+ }
59
+ const key = path.at(-1);
60
+ if (typeof node[key] !== "string" || node[key] === "") node[key] = value;
61
+ }
62
+
63
+ /**
64
+ * Each module's private options, the secrets the manifest publishes first:
65
+ * the environment variable of a secret (`DMS_HTML_RENDER_SECRET`,
66
+ * `DMS_OAUTH_RELAY_SECRET`) only fills in a section that carries none.
67
+ *
68
+ * @param modules The private options by module id, as the manifest published
69
+ * them
70
+ * @param environment The variables by the path of the secret they stand for
71
+ * @param env The process environment
72
+ */
73
+ export function privateOptionsWithFallbacks(modules, environment, env) {
74
+ for (const options of Object.values(modules))
75
+ for (const [name, path] of Object.entries(environment))
76
+ if (env[name]) fillPath(options, path, env[name]);
77
+ return modules;
78
+ }
79
+
80
+ function readPrivateOptions(env = process.env) {
81
+ const { modules = {}, environment = {} } = readJsonFile(
82
+ SERVER_OPTIONS_FILE,
83
+ {},
84
+ );
85
+ return privateOptionsWithFallbacks(modules, environment, env);
86
+ }
87
+
88
+ function readPublicOptions() {
89
+ const registry = readJsonFile(REGISTRY_FILE, { modules: [] });
90
+ return Object.fromEntries(
91
+ registry.modules.map((module) => [module.id, module.options]),
92
+ );
93
+ }
94
+
95
+ async function loadDeclarations(devServer) {
96
+ if (devServer)
97
+ return (await devServer.ssrLoadModule(SOURCE_LOADER)).serverModules;
98
+ if (!existsSync(fileURLToPath(BUILT_LOADER))) return [];
99
+ builtDeclarations ??= import(BUILT_LOADER.href).then(
100
+ (loaded) => loaded.serverModules,
101
+ );
102
+ return builtDeclarations;
103
+ }
104
+
105
+ /**
106
+ * The declarations to serve. A module entry that fails to load in development
107
+ * (a syntax error being typed) is reported once, and the last routes that
108
+ * loaded keep answering meanwhile.
109
+ */
110
+ async function currentDeclarations(devServer) {
111
+ try {
112
+ lastDeclarations = (await loadDeclarations(devServer)) ?? [];
113
+ lastLoadError = undefined;
114
+ } catch (error) {
115
+ if (!devServer) throw error;
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ if (message !== lastLoadError)
118
+ console.error("DMS server modules failed to load", error);
119
+ lastLoadError = message;
120
+ }
121
+ return lastDeclarations;
122
+ }
123
+
124
+ /**
125
+ * A set of server modules, resolved for a deployment.
126
+ *
127
+ * @param declarations `{ id, priority, setup }` per module with a dms.server.ts
128
+ * @param deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
129
+ * publicEndpoints, backendProxies, warn }`: each module's options by id,
130
+ * the session-opening endpoints as a function (the environment can change
131
+ * them), the public endpoints, the backend proxies the manifest declares
132
+ * (`{ module, path, auth? }`), and where declaration problems are reported
133
+ * @returns `{ routes, auth, context(id), serverAuth }`: `context` is what a
134
+ * handler of that module reads besides its request, `serverAuth` the
135
+ * session logic the module owning sign-in provides (see `auth-hooks.mjs`)
136
+ */
137
+ export function createServerModules(declarations, deployment) {
138
+ const resolution = resolveServerDeclarations(
139
+ declarations,
140
+ deployment.warn,
141
+ deployment.publicEndpoints ?? [],
142
+ deployment.backendProxies ?? [],
143
+ );
144
+ const context = (id) => ({
145
+ module: {
146
+ id,
147
+ options: deployment.publicOptions?.[id],
148
+ privateOptions: deployment.privateOptions?.[id],
149
+ },
150
+ app: {
151
+ authEstablishEndpoints: deployment.authEstablishEndpoints?.() ?? [],
152
+ },
153
+ // The module whose sealed cookies are the `dms_*` ones.
154
+ authOwner: resolution.auth?.id,
155
+ });
156
+ return {
157
+ ...resolution,
158
+ context,
159
+ serverAuth: serverAuth(resolution.auth, context),
160
+ };
161
+ }
162
+
163
+ /** Resolved once per set of declarations: in development, once per edit. */
164
+ function serverModules(declarations) {
165
+ let modules = resolved.get(declarations);
166
+ if (!modules) {
167
+ modules = createServerModules(declarations, {
168
+ publicOptions: readPublicOptions(),
169
+ privateOptions: readPrivateOptions(),
170
+ authEstablishEndpoints: () => allowedEstablishEndpoints(),
171
+ publicEndpoints: allowedPublicEndpoints(),
172
+ backendProxies: [
173
+ ...readDeclaredBackendProxies(),
174
+ ...environmentBackendProxies(),
175
+ ],
176
+ warn: (message) => console.warn(message),
177
+ });
178
+ resolved.set(declarations, modules);
179
+ }
180
+ return modules;
181
+ }
182
+
183
+ /**
184
+ * The server modules of this deployment, resolved.
185
+ *
186
+ * @param devServer Vite in development, `undefined` in production
187
+ */
188
+ export async function loadServerModules(devServer) {
189
+ return serverModules(await currentDeclarations(devServer));
190
+ }
191
+
192
+ /**
193
+ * Answers `request` with the module route it reaches, if any. A path a
194
+ * module route serves is the module's for every method: another method is
195
+ * answered `405`.
196
+ *
197
+ * @param request The Node request
198
+ * @param response The Node response
199
+ * @param pathname The request's normalized path
200
+ * @param modules What `loadServerModules` or `createServerModules` returned
201
+ * @returns Whether a module route answered
202
+ */
203
+ export async function handleModuleRoute(request, response, pathname, modules) {
204
+ if (!mayHaveModuleRoute(pathname)) return false;
205
+ const reached = matchModuleRoute(modules.routes, request.method, pathname);
206
+ if (!reached) {
207
+ const allowed = moduleRouteMethods(modules.routes, pathname);
208
+ if (!allowed.length) return false;
209
+ response.writeHead(405, {
210
+ allow: allowed.join(", "),
211
+ "content-type": "application/json",
212
+ });
213
+ response.end(JSON.stringify({ message: "Method not allowed" }));
214
+ return true;
215
+ }
216
+ await answerModuleRoute({
217
+ request,
218
+ response,
219
+ route: reached.route,
220
+ params: reached.params,
221
+ view: modules.serverAuth.view,
222
+ ...modules.context(reached.route.id),
223
+ });
224
+ return true;
225
+ }
@@ -0,0 +1,65 @@
1
+ // Serves a frontend module's `dms.server.ts` routes the way the frontend
2
+ // server does, for the module's own tests: same router, same event, same
3
+ // cookie jar and the same defaults. Imported from
4
+ // `@antelopejs/dms-frontend/templates/vue/server/module-testing.mjs`; the
5
+ // server reads `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the
6
+ // environment, as in production.
7
+
8
+ import { createServer } from "node:http";
9
+ import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
10
+ import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
11
+ import { createServerModules, handleModuleRoute } from "./module-server.mjs";
12
+
13
+ /** What the frontend server answers an API request whose handler failed. */
14
+ function writeFailure(error, response) {
15
+ const status = Number.isInteger(error?.status) ? error.status : 502;
16
+ const body =
17
+ error instanceof UpstreamError || error instanceof RequestBodyError
18
+ ? { message: error.message }
19
+ : UNEXPECTED_ERROR_BODY;
20
+ if (!response.headersSent)
21
+ response.writeHead(status, { "content-type": "application/json" });
22
+ response.end(JSON.stringify(body));
23
+ }
24
+
25
+ /**
26
+ * Starts an HTTP server on a free local port answering the modules' routes,
27
+ * and `404` to anything else.
28
+ *
29
+ * @param declarations `{ id, priority?, setup }` per module, `setup` being
30
+ * the default export of its `dms.server.ts`
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
35
+ * @returns `{ url, modules, warnings, close() }`
36
+ */
37
+ export async function serveModuleRoutes(declarations, deployment = {}) {
38
+ const warnings = [];
39
+ const endpoints = deployment.authEstablishEndpoints ?? [];
40
+ const modules = createServerModules(declarations, {
41
+ ...deployment,
42
+ authEstablishEndpoints: () => endpoints,
43
+ warn: (message) => warnings.push(message),
44
+ });
45
+ const server = createServer((request, response) => {
46
+ const { pathname } = new URL(request.url, "http://frontend.local");
47
+ handleModuleRoute(request, response, pathname, modules)
48
+ .then((answered) => {
49
+ if (!answered) response.writeHead(404).end();
50
+ })
51
+ .catch((error) => writeFailure(error, response));
52
+ });
53
+ await new Promise((settle) => server.listen(0, "127.0.0.1", settle));
54
+ const { port } = server.address();
55
+ return {
56
+ url: `http://127.0.0.1:${port}`,
57
+ modules,
58
+ warnings,
59
+ close: () =>
60
+ new Promise((settle) => {
61
+ server.closeAllConnections();
62
+ server.close(() => settle());
63
+ }),
64
+ };
65
+ }
@@ -0,0 +1,151 @@
1
+ // The props of a DMS page visit, and which of them the browser already has.
2
+ //
3
+ // Lives apart from server.mjs so the file stays under the size the linter
4
+ // allows.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { readSession } from "./auth/session.mjs";
8
+ import { BackendResponseError } from "./backend-response.mjs";
9
+ import { heldOnceProps, requestedProps } from "./inertia.mjs";
10
+
11
+ export const SHARED_PROP = "shared";
12
+ /**
13
+ * How long a browser reuses the site layout it was sent before a visit asks
14
+ * for it again. The layout is the whole menu with each entry's access, the
15
+ * bulk of every page object; a change the backend announces (`menu-changed`)
16
+ * reloads it sooner, and the backend checks access on every request whatever
17
+ * the menu shows.
18
+ */
19
+ export const SHARED_PROP_TTL_MS = 5 * 60 * 1000;
20
+ const SITE_LAYOUT_PATH = "/dms/sitelayout";
21
+ const NOT_FOUND = 404;
22
+
23
+ /**
24
+ * The once-prop key of the site layout: one per account and workspace. A
25
+ * browser holding the layout of another account or workspace, after a sign-in,
26
+ * a sign-out or a switch, holds another key, so it is sent the right one.
27
+ */
28
+ export function sharedOnceKey(session) {
29
+ const identity = `${session?.accountId ?? ""}\n${session?.activeTenantId ?? ""}`;
30
+ const digest = createHash("sha256").update(identity).digest("base64url");
31
+ return `${SHARED_PROP}:${digest.slice(0, 16)}`;
32
+ }
33
+
34
+ /**
35
+ * Whether the browser holds this session's layout and no other: a browser
36
+ * that holds another session's key too (a switch back and forth without a
37
+ * full visit) may show that one under `shared`, so it is sent this one.
38
+ */
39
+ function holdsOnlyThisLayout(held, sharedKey) {
40
+ if (!held.has(sharedKey)) return false;
41
+ return ![...held].some(
42
+ (key) => key !== sharedKey && key.startsWith(`${SHARED_PROP}:`),
43
+ );
44
+ }
45
+
46
+ // The parameters of the page request itself, which a page query of the same
47
+ // name cannot override.
48
+ const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
49
+
50
+ /**
51
+ * The backend request of a page, with the query of the visit: a header
52
+ * button's availability reads it (`?workspace=` names the record a page acts
53
+ * on), so the layout is resolved for the query the browser shows and the
54
+ * browser does not ask it again. One value per name, the last, as the
55
+ * browser's own page layout request sends it.
56
+ */
57
+ function pagePath(url, withShared) {
58
+ const query = new URLSearchParams({ path: url.pathname });
59
+ const pageQuery = new Map();
60
+ for (const [name, value] of url.searchParams) {
61
+ if (!PAGE_REQUEST_PARAMETERS.has(name)) pageQuery.set(name, value);
62
+ }
63
+ for (const [name, value] of pageQuery) query.set(name, value);
64
+ if (!withShared) query.set(SHARED_PROP, "false");
65
+ return `/dms/page?${query}`;
66
+ }
67
+
68
+ /** A page only the frontend has: a custom page, with no backend data. */
69
+ function frontendOnlyPage(pathname, signInPath) {
70
+ return {
71
+ componentName: pathname,
72
+ isFrontendOnly: true,
73
+ route: {
74
+ fullId: pathname.slice(1).replaceAll("/", "."),
75
+ fullSlug: pathname,
76
+ hasAccess: pathname.startsWith(`${signInPath}/`),
77
+ },
78
+ layout: { layout: { componentName: "DefaultLayout" } },
79
+ };
80
+ }
81
+
82
+ async function loadPage(
83
+ url,
84
+ withShared,
85
+ { fetchJson, isFrontendPage },
86
+ signInPath,
87
+ ) {
88
+ const { pathname } = url;
89
+ try {
90
+ const { shared, ...page } = await fetchJson(pagePath(url, withShared));
91
+ return { page, shared };
92
+ } catch (error) {
93
+ if (!(error instanceof BackendResponseError) || error.status !== NOT_FOUND)
94
+ throw error;
95
+ if (!(await isFrontendPage(pathname))) throw error;
96
+ return {
97
+ page: frontendOnlyPage(pathname, signInPath),
98
+ shared: undefined,
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The props of the page a request visits, with the once props to declare.
105
+ *
106
+ * The site layout (`shared`) is a once prop: a visit sends it when the browser
107
+ * does not hold it for this account and workspace, or when a partial reload
108
+ * asks for it, and otherwise asks the backend for the page alone. A partial
109
+ * reload gets the props it names, and the layout too when the browser's copy
110
+ * is not this session's.
111
+ */
112
+ export async function loadPageProps(request, backend, auth) {
113
+ const url = new URL(request.url, "http://frontend.local");
114
+ const { pathname } = url;
115
+ const sealed = readSession(request);
116
+ const { user, session } = auth.view(sealed);
117
+ const { partial, wants } = requestedProps(request);
118
+ // The sealed session names the account and workspace, whatever the module
119
+ // owning sign-in shows a page of it.
120
+ const sharedKey = sharedOnceKey(sealed);
121
+ const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
122
+ const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
123
+ const props = {};
124
+ if (wants("path")) props.path = pathname;
125
+ // A partial reload merges what it gets into the page on screen: a session
126
+ // that ended has to be sent as one, not left out.
127
+ if (wants("user") && (user || partial)) props.user = user ?? null;
128
+ if (wants("session") && (session || partial)) props.session = session ?? null;
129
+ let shared;
130
+ if (wants("page")) {
131
+ const loaded = await loadPage(url, withShared, backend, auth.signInPath);
132
+ props.page = loaded.page;
133
+ shared = loaded.shared;
134
+ } else if (withShared) {
135
+ shared = await backend.fetchJson(SITE_LAYOUT_PATH);
136
+ }
137
+ if (shared) props[SHARED_PROP] = shared;
138
+ // Declared only when the browser ends up with this session's layout: sent
139
+ // now, or already held. Declaring it otherwise would let a layout of
140
+ // another session pass for this one.
141
+ const onceProps =
142
+ shared || holdsShared
143
+ ? {
144
+ [sharedKey]: {
145
+ prop: SHARED_PROP,
146
+ expiresAt: shared ? Date.now() + SHARED_PROP_TTL_MS : null,
147
+ },
148
+ }
149
+ : undefined;
150
+ return { props, onceProps };
151
+ }
@@ -0,0 +1,38 @@
1
+ // Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
2
+ // page itself, `/x/*` one segment below it, `/x/**` the page and everything
3
+ // below it. Nothing else: no prefix match inside a segment, no query string.
4
+
5
+ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
6
+
7
+ /**
8
+ * A pattern made usable, or `undefined` when it is malformed.
9
+ *
10
+ * `specificity` orders matching patterns from the least to the most specific:
11
+ * more literal segments first, then a single-segment `*` over `**`.
12
+ */
13
+ export function compilePathPattern(pattern) {
14
+ if (typeof pattern !== "string" || !pattern.startsWith("/")) return undefined;
15
+ const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
16
+ const deep = segments.at(-1) === "**";
17
+ const literal = deep ? segments.slice(0, -1) : segments;
18
+ if (
19
+ literal.some(
20
+ (segment) =>
21
+ segment !== "*" &&
22
+ (!SEGMENT.test(segment) || segment === "." || segment === ".."),
23
+ )
24
+ )
25
+ return undefined;
26
+ const body = literal
27
+ .map((segment) =>
28
+ segment === "*" ? "/[^/]+" : `/${segment.replaceAll(".", "\\.")}`,
29
+ )
30
+ .join("");
31
+ const matcher = new RegExp(`^${body}${deep ? "(?:/.*)?" : ""}/?$`);
32
+ const literals = literal.filter((segment) => segment !== "*").length;
33
+ return {
34
+ pattern,
35
+ specificity: literals * 4 + literal.length * 2 + (deep ? 0 : 1),
36
+ test: (pathname) => matcher.test(pathname === "" ? "/" : pathname),
37
+ };
38
+ }
@@ -35,6 +35,7 @@ const RESERVED_HEADER =
35
35
  /^x-(?:forwarded-|real-ip$|dms-|inertia|http-method|method-override|original-|rewrite-)/;
36
36
 
37
37
  let declaredEndpoints;
38
+ let declaredProxies;
38
39
 
39
40
  /**
40
41
  * The public endpoints the modules of this deployment declared, unchecked.
@@ -51,6 +52,50 @@ export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
51
52
  }
52
53
  }
53
54
 
55
+ /**
56
+ * The backend proxies the backend modules declared in the manifest, as
57
+ * `{ module, path, auth? }`, unchecked.
58
+ *
59
+ * @param file Generated file to read
60
+ */
61
+ export function readDeclaredBackendProxies(file = DECLARED_ENDPOINTS_FILE) {
62
+ if (file === DECLARED_ENDPOINTS_FILE && declaredProxies)
63
+ return declaredProxies;
64
+ let proxies = [];
65
+ try {
66
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
67
+ if (Array.isArray(parsed?.proxies)) proxies = parsed.proxies;
68
+ } catch {
69
+ // No file, or a broken one: no proxy declared.
70
+ }
71
+ if (file === DECLARED_ENDPOINTS_FILE) declaredProxies = proxies;
72
+ return proxies;
73
+ }
74
+
75
+ /**
76
+ * The backend proxies `DMS_BACKEND_PROXIES` adds: a JSON array of
77
+ * `{ path, auth? }`, as the manifest's `backendProxies`, for an operator.
78
+ *
79
+ * @param declaration The variable's value
80
+ */
81
+ export function environmentBackendProxies(
82
+ declaration = process.env.DMS_BACKEND_PROXIES,
83
+ ) {
84
+ if (!declaration?.trim()) return [];
85
+ try {
86
+ const parsed = JSON.parse(declaration);
87
+ if (Array.isArray(parsed))
88
+ return parsed.map((entry) => ({
89
+ ...entry,
90
+ module: "DMS_BACKEND_PROXIES",
91
+ }));
92
+ } catch {
93
+ // Reported below.
94
+ }
95
+ console.warn("Ignoring DMS_BACKEND_PROXIES: not a JSON array");
96
+ return [];
97
+ }
98
+
54
99
  function environmentEndpoints(declaration) {
55
100
  if (!declaration?.trim()) return [];
56
101
  try {
@@ -165,25 +210,38 @@ export function matchPublicEndpoint(
165
210
  }
166
211
 
167
212
  /**
168
- * How the proxy treats a request: the public endpoint it reaches, if any, and
169
- * whether it is refused for lacking a same-site Origin. A state-changing
170
- * request needs one, unless it reaches a public endpoint, which is why the
171
- * proxy never sends a public endpoint the session: a cross-site form posting
172
- * to it acts as nobody.
213
+ * How the proxy treats a request to a backend proxy: whether it sends the
214
+ * session, the extra headers it relays, and whether it refuses the request
215
+ * for lacking a same-site Origin.
216
+ *
217
+ * A proxy with `auth: "none"` never sends the session, so it needs no
218
+ * Origin. Under `auth: "session"`, a state-changing request needs one unless
219
+ * it reaches a public endpoint, which is why the proxy never sends a public
220
+ * endpoint the session: a cross-site form posting to it acts as nobody.
173
221
  *
174
222
  * @param request The incoming request
175
223
  * @param safeMethods Methods that change nothing and need no Origin
224
+ * @param proxy The backend proxy the request reached
225
+ * @returns `{ anonymous, headers, isRefused }`
176
226
  */
177
- export function proxyAccess(request, safeMethods) {
227
+ export function proxyAccess(request, safeMethods, proxy) {
228
+ if (proxy.auth === "none")
229
+ return { anonymous: true, headers: [], isRefused: false };
178
230
  const publicEndpoint = matchPublicEndpoint(
179
231
  request.method,
180
232
  new URL(request.url, "http://frontend.local").pathname,
181
233
  );
182
- const isRefused =
183
- !publicEndpoint &&
184
- !safeMethods.has(request.method) &&
185
- !isSameOrigin(request);
186
- return { publicEndpoint, isRefused };
234
+ if (publicEndpoint)
235
+ return {
236
+ anonymous: true,
237
+ headers: publicEndpoint.headers,
238
+ isRefused: false,
239
+ };
240
+ return {
241
+ anonymous: false,
242
+ headers: [],
243
+ isRefused: !safeMethods.has(request.method) && !isSameOrigin(request),
244
+ };
187
245
  }
188
246
 
189
247
  /** Answers the 403 of a request `proxyAccess` refused. */
@@ -0,0 +1,47 @@
1
+ // Who answers a request: one owner per path, whoever asks and whatever the
2
+ // method, in this order:
3
+ //
4
+ // 1. the renderer: its icon endpoint, Vite's URLs and the built assets, and
5
+ // any file it has (a development source, a built asset);
6
+ // 2. the module routes of the modules' `dms.server.ts`;
7
+ // 3. the backend proxies the modules declare (`registerBackendProxy`, the
8
+ // manifest's `backendProxies`);
9
+ // 4. the pages, for everything else: a method other than `GET` and `HEAD` on a
10
+ // page path is a `404`.
11
+
12
+ import { matchBackendProxy } from "./backend-proxies.mjs";
13
+ import { isRendererFilePath, moduleRouteMethods } from "./module-routes.mjs";
14
+
15
+ const PAGE_METHODS = new Set(["GET", "HEAD"]);
16
+
17
+ /**
18
+ * The owner of a request.
19
+ *
20
+ * @param method The request's method
21
+ * @param pathname The request's normalized path
22
+ * @param modules What `loadServerModules` returned: `routes` and `proxies`
23
+ * @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
24
+ * file at that path
25
+ * @returns `{ kind: "renderer" }`, `{ kind: "module", id }`,
26
+ * `{ kind: "backend", proxy }`, `{ kind: "page" }` or
27
+ * `{ kind: "not-found" }`
28
+ */
29
+ export function requestOwner(method, pathname, modules, hasRendererFile) {
30
+ if (isRendererFilePath(pathname) || hasRendererFile(pathname))
31
+ return { kind: "renderer" };
32
+ const route = modules.routes.find((candidate) =>
33
+ candidate.matcher.test(pathname),
34
+ );
35
+ if (route && moduleRouteMethods(modules.routes, pathname).length)
36
+ return { kind: "module", id: route.id };
37
+ const proxy = matchBackendProxy(modules.proxies ?? [], pathname);
38
+ if (proxy) return { kind: "backend", proxy };
39
+ return PAGE_METHODS.has(method) ? { kind: "page" } : { kind: "not-found" };
40
+ }
41
+
42
+ /** The `x-dms-route-owner` value of an owner, for the development server. */
43
+ export function ownerLabel(owner) {
44
+ if (owner.kind === "module") return `module:${owner.id}`;
45
+ if (owner.kind === "backend") return `backend:${owner.proxy.path}`;
46
+ return owner.kind;
47
+ }