@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.
- package/README.md +376 -18
- package/dist/app-configs.d.ts +9 -0
- package/dist/app-configs.js +52 -0
- package/dist/asset-roots.d.ts +7 -0
- package/dist/asset-roots.js +34 -0
- package/dist/commands/build-action.js +9 -0
- package/dist/commands/dev-action.js +88 -19
- package/dist/commands/start-action.js +2 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +8 -3
- package/dist/derived-outputs.d.ts +2 -0
- package/dist/derived-outputs.js +38 -31
- package/dist/help.js +10 -3
- package/dist/layer-watch.js +123 -131
- package/dist/layers.js +2 -0
- package/dist/manifest-secrets.js +5 -5
- package/dist/manifest.js +7 -1
- package/dist/materialize.js +21 -1
- package/dist/module-set.d.ts +16 -0
- package/dist/module-set.js +84 -0
- package/dist/server-process.d.ts +1 -0
- package/dist/server-process.js +28 -2
- package/dist/verify-source-runner.js +12 -0
- package/dist/workspace.d.ts +9 -0
- package/package.json +2 -1
- package/templates/vue/app-runtime.ts +58 -16
- package/templates/vue/client-server-guard.mjs +73 -0
- package/templates/vue/email-renderer.ts +17 -4
- package/templates/vue/email-runtime.ts +1 -1
- package/templates/vue/frontend-module.ts +1817 -259
- package/templates/vue/globals.d.ts +1 -1
- package/templates/vue/icon-hydration.ts +106 -1
- package/templates/vue/index.html +0 -1
- package/templates/vue/layer-app-config.ts +92 -0
- package/templates/vue/main.ts +10 -2
- package/templates/vue/package.json +4 -1
- package/templates/vue/server/auth/backend.mjs +13 -19
- package/templates/vue/server/auth/client-ip.mjs +27 -0
- package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
- package/templates/vue/server/auth/session.mjs +6 -97
- package/templates/vue/server/auth-hooks.mjs +84 -0
- package/templates/vue/server/backend-proxies.mjs +110 -0
- package/templates/vue/server/dev-private-files.mjs +99 -0
- package/templates/vue/server/email.mjs +11 -33
- package/templates/vue/server/favicon.mjs +20 -0
- package/templates/vue/server/inertia.mjs +48 -3
- package/templates/vue/server/listen-host.mjs +19 -0
- package/templates/vue/server/module-event.mjs +265 -0
- package/templates/vue/server/module-routes.mjs +513 -0
- package/templates/vue/server/module-server.mjs +225 -0
- package/templates/vue/server/module-testing.mjs +65 -0
- package/templates/vue/server/page-props.mjs +151 -0
- package/templates/vue/server/path-patterns.mjs +38 -0
- package/templates/vue/server/public-endpoints.mjs +69 -11
- package/templates/vue/server/request-routing.mjs +47 -0
- package/templates/vue/server/route-rules.mjs +129 -0
- package/templates/vue/server-module.ts +294 -0
- package/templates/vue/server.mjs +177 -161
- package/templates/vue/ssr-renderer.ts +60 -19
- package/templates/vue/tsconfig.json +2 -1
- package/templates/vue/ui-app-config.ts +18 -6
- package/templates/vue/ui-inertia-page.mjs +37 -0
- package/templates/vue/vite.config.ts +31 -10
- package/templates/vue/vite.email.config.ts +1 -1
- package/templates/vue/vite.server.config.ts +57 -0
- package/templates/vue/server/auth/oauth.mjs +0 -233
- package/templates/vue/server/auth/routes.mjs +0 -444
- package/templates/vue/server/render-token.mjs +0 -81
- 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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|