@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,110 @@
1
+ // The backend prefixes the frontend server proxies. The renderer hardcodes
2
+ // none: a frontend module declares them in its `dms.server.ts`
3
+ // (`registerBackendProxy`), and a backend module in the frontend manifest
4
+ // (`backendProxies`, next to its `publicEndpoints`). A path no prefix covers
5
+ // is never sent to the backend.
6
+
7
+ import { compilePathPattern } from "./path-patterns.mjs";
8
+
9
+ /** How the proxy authenticates a request to the backend. */
10
+ export const PROXY_AUTH_MODES = Object.freeze(["session", "none"]);
11
+ const DEFAULT_AUTH = "session";
12
+ /** The renderer's own paths, never a backend's. */
13
+ const RENDERER_PREFIXES = ["/api/_dms", "/@", "/assets", "/node_modules"];
14
+
15
+ function literalPrefix(pattern) {
16
+ const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
17
+ const literal = [];
18
+ for (const segment of segments) {
19
+ if (segment === "*" || segment === "**") break;
20
+ literal.push(segment);
21
+ }
22
+ return `/${literal.join("/")}`;
23
+ }
24
+
25
+ /** Why a proxy declaration is ignored, or `undefined`. */
26
+ export function proxyRejection(pattern, options) {
27
+ const compiled = compilePathPattern(pattern);
28
+ if (!compiled) return "the pattern is not /x, /x/* or /x/**";
29
+ const prefix = literalPrefix(pattern);
30
+ if (prefix === "/")
31
+ return "its first segment is not literal: it would take every page";
32
+ if (
33
+ RENDERER_PREFIXES.some(
34
+ (reserved) => prefix === reserved || prefix.startsWith(`${reserved}/`),
35
+ )
36
+ )
37
+ return `${RENDERER_PREFIXES.join(", ")} are the renderer's own paths`;
38
+ if (options !== undefined && (typeof options !== "object" || !options))
39
+ return "its options are not an object";
40
+ if (options?.auth !== undefined && !PROXY_AUTH_MODES.includes(options.auth))
41
+ return `auth is not one of ${PROXY_AUTH_MODES.map((mode) => `"${mode}"`).join(", ")}`;
42
+ return undefined;
43
+ }
44
+
45
+ function compileProxy(pattern, options, owner) {
46
+ return {
47
+ pattern: compilePathPattern(pattern),
48
+ path: pattern,
49
+ owner,
50
+ auth: options?.auth ?? DEFAULT_AUTH,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The deployment's backend proxies, one per pattern.
56
+ *
57
+ * @param declared `{ id, pattern, options }` per `registerBackendProxy` call,
58
+ * from the highest-priority module down
59
+ * @param manifest `{ module, path, auth? }` per backend module declaration
60
+ * @param warn Reports a declaration problem
61
+ * @returns The proxies, the most specific pattern first: a request reaches
62
+ * the first that matches
63
+ */
64
+ export function resolveBackendProxies(declared, manifest, warn) {
65
+ const kept = new Map();
66
+ const add = (pattern, options, owner) => {
67
+ const reason = proxyRejection(pattern, options);
68
+ const current = kept.get(pattern);
69
+ if (reason || current) {
70
+ warn(
71
+ `${owner} declares the backend proxy "${pattern}", which is ignored: ${reason ?? `${current.owner} declares it`}.`,
72
+ );
73
+ return;
74
+ }
75
+ kept.set(pattern, compileProxy(pattern, options, owner));
76
+ };
77
+ for (const { id, pattern, options } of declared)
78
+ add(pattern, options, `Frontend module ${id}`);
79
+ for (const entry of manifest)
80
+ add(entry?.path, { auth: entry?.auth }, `Backend module ${entry?.module}`);
81
+ return [...kept.values()].sort(
82
+ (left, right) => right.pattern.specificity - left.pattern.specificity,
83
+ );
84
+ }
85
+
86
+ /** The backend proxy a path reaches, or `undefined`. */
87
+ export function matchBackendProxy(proxies, pathname) {
88
+ return proxies.find((proxy) => proxy.pattern.test(pathname));
89
+ }
90
+
91
+ /**
92
+ * The lines the server prints at startup: each backend prefix, who declared
93
+ * it and how it authenticates, then who owns the rest.
94
+ *
95
+ * @param proxies What `resolveBackendProxies` returned
96
+ * @param routeCount The number of module routes
97
+ */
98
+ export function routingReport(proxies, routeCount) {
99
+ const width = Math.max(0, ...proxies.map((proxy) => proxy.path.length));
100
+ const lines = proxies.map(
101
+ (proxy) =>
102
+ ` ${proxy.path.padEnd(width)} -> backend (${proxy.auth}), declared by ${proxy.owner.replace(/^\w+ module /, "")}`,
103
+ );
104
+ return [
105
+ `Routing: renderer files, then ${routeCount} module route${routeCount === 1 ? "" : "s"}, then the backend proxies below, then the pages`,
106
+ ...(lines.length
107
+ ? lines
108
+ : [" (no backend proxy declared: no request reaches the backend)"]),
109
+ ];
110
+ }
@@ -0,0 +1,99 @@
1
+ // The files of the workspace the development server never hands to a browser.
2
+ //
3
+ // Vite's root is the workspace root, where the generated server options (the
4
+ // modules' private options: the backend's secrets) and the cached manifest are
5
+ // written, and the modules' server code is materialized under it. Vite serves
6
+ // any file under its root, as is, `?raw`, `?import` or `?url`. Its
7
+ // `server.fs.deny` list covers most of these, but not every query combination
8
+ // (`?import&raw` gets through in Vite 7.2), so a middleware ahead of Vite's own
9
+ // answers 403 by path, whatever the query, and a load guard refuses them to
10
+ // any client request that reaches the transform pipeline another way.
11
+ // `ssrLoadModule`, which loads the server modules, is left alone.
12
+
13
+ import { relative, resolve, sep } from "node:path";
14
+
15
+ const PRIVATE_FILE_NAMES = new Set([
16
+ "generated-server-options.json",
17
+ ".manifest-cache.json",
18
+ ]);
19
+ const SERVER_ENTRY = /^dms\.server\./;
20
+ const MODULES_DIRECTORY = "frontend-modules";
21
+ const FORBIDDEN_STATUS = 403;
22
+
23
+ /**
24
+ * Vite's `server.fs.deny` for the development server: Vite's own defaults,
25
+ * which a `deny` list replaces, and the private files.
26
+ */
27
+ export const DEV_FS_DENY = [
28
+ ".env",
29
+ ".env.*",
30
+ "*.{crt,pem}",
31
+ "**/.git/**",
32
+ ...PRIVATE_FILE_NAMES,
33
+ "dms.server.*",
34
+ `**/${MODULES_DIRECTORY}/**/server/**`,
35
+ ];
36
+
37
+ /**
38
+ * Whether `file`, an absolute path, is private: a generated secret-bearing
39
+ * file, a module's server entry, or a file under a `server/` directory of a
40
+ * module materialized under `root`. Compared without case, as a
41
+ * case-insensitive file system would serve them.
42
+ */
43
+ export function isPrivateFile(file, root) {
44
+ const name = (resolve(file).split(/[\\/]/).at(-1) ?? "").toLowerCase();
45
+ if (PRIVATE_FILE_NAMES.has(name) || SERVER_ENTRY.test(name)) return true;
46
+ const segments = relative(root, file).toLowerCase().split(/[\\/]/);
47
+ return (
48
+ segments[0] === MODULES_DIRECTORY &&
49
+ segments.slice(1, -1).includes("server")
50
+ );
51
+ }
52
+
53
+ function stripQuery(id) {
54
+ const query = id.search(/[?#]/);
55
+ return query === -1 ? id : id.slice(0, query);
56
+ }
57
+
58
+ /** The file a request URL names under `root`, `undefined` when it names none. */
59
+ function requestedFile(url, root) {
60
+ let pathname;
61
+ try {
62
+ pathname = decodeURIComponent(stripQuery(url ?? ""));
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ if (pathname.startsWith("/@fs/")) return resolve(pathname.slice(4));
67
+ if (pathname.startsWith("/@id/")) pathname = pathname.slice(4);
68
+ return resolve(root, `.${sep}${pathname.replace(/^[\\/]+/, "")}`);
69
+ }
70
+
71
+ /** Answers 403 to private files, and fails a client load of one. */
72
+ export function denyPrivateFiles() {
73
+ let root = process.cwd();
74
+ return {
75
+ name: "dms-deny-private-files",
76
+ apply: "serve",
77
+ enforce: "pre",
78
+ configResolved(config) {
79
+ root = config.root;
80
+ },
81
+ configureServer(server) {
82
+ // Registered directly, not returned: it runs before Vite's middlewares.
83
+ server.middlewares.use((request, response, next) => {
84
+ const file = requestedFile(request.url, root);
85
+ if (!file || !isPrivateFile(file, root)) return next();
86
+ response.statusCode = FORBIDDEN_STATUS;
87
+ response.setHeader("content-type", "text/plain; charset=utf-8");
88
+ response.end("Forbidden");
89
+ });
90
+ },
91
+ load(id, options) {
92
+ if (options?.ssr || id.startsWith("\0")) return null;
93
+ if (!isPrivateFile(stripQuery(id), root)) return null;
94
+ this.error(
95
+ `${stripQuery(id)} is private to the server and never served to the browser.`,
96
+ );
97
+ },
98
+ };
99
+ }
@@ -1,10 +1,7 @@
1
1
  import { fork } from "node:child_process";
2
2
  import { constants, setPriority } from "node:os";
3
3
  import { fileURLToPath } from "node:url";
4
- import { body } from "./auth/backend.mjs";
5
- import { validRenderToken } from "./render-token.mjs";
6
4
 
7
- const JSON_TYPE = "application/json";
8
5
  const EMAIL_RENDERER_URL = new URL(
9
6
  "../dist/server/email-renderer.js",
10
7
  import.meta.url,
@@ -78,34 +75,15 @@ async function loadEmailRenderer() {
78
75
  return import(url.href);
79
76
  }
80
77
 
81
- /** Renders an email template for an authenticated backend request. */
82
- export async function handleEmailRender(request, response) {
83
- if (!validRenderToken(request.headers["x-dms-service-token"])) {
84
- response.writeHead(401, { "content-type": JSON_TYPE });
85
- return response.end(JSON.stringify({ message: "Invalid service token" }));
86
- }
87
- const input = await body(request);
88
- if (
89
- typeof input.templateName !== "string" ||
90
- input.templateName.trim() === "" ||
91
- typeof input.props !== "object"
92
- ) {
93
- response.writeHead(400, { "content-type": JSON_TYPE });
94
- return response.end(JSON.stringify({ message: "Invalid request body" }));
95
- }
96
- try {
97
- const { renderEmail } = await loadEmailRenderer();
98
- const html = await renderEmail(input.templateName, input.props ?? {}, {
99
- locale: request.headers["x-content-language"],
100
- });
101
- response.writeHead(200, { "content-type": JSON_TYPE });
102
- response.end(JSON.stringify({ html }));
103
- } catch {
104
- response.writeHead(404, { "content-type": JSON_TYPE });
105
- response.end(
106
- JSON.stringify({
107
- message: `Template "${input.templateName}" not found or render failed`,
108
- }),
109
- );
110
- }
78
+ /**
79
+ * Renders an email template of the application's email bundle, waiting for a
80
+ * development build in progress.
81
+ *
82
+ * @param template The template's name
83
+ * @param props The template's props
84
+ * @param options `locale`, the language to render in
85
+ */
86
+ export async function renderEmailTemplate(template, props, options = {}) {
87
+ const { renderEmail } = await loadEmailRenderer();
88
+ return renderEmail(template, props, options);
111
89
  }
@@ -0,0 +1,20 @@
1
+ // The favicon placeholder of a page whose render declares no icon.
2
+ //
3
+ // A document without any `<link rel="icon">` makes the browser ask for
4
+ // `/favicon.ico`, a request the frontend server would pass on to the backend
5
+ // on every page load. An empty data URL stops it. The template used to carry
6
+ // that placeholder itself, but a module declaring real icons through `useHead`
7
+ // (light and dark, one per `prefers-color-scheme`) then competed with it, and
8
+ // the browser could keep the empty one: it only goes in when the rendered head
9
+ // has no icon of its own.
10
+
11
+ const FAVICON_PLACEHOLDER = '<link rel="icon" href="data:," />';
12
+ /** A `<link>` whose `rel` holds the `icon` token, `shortcut icon` included. */
13
+ const ICON_LINK = /<link\b[^>]*\srel=(["'])(?:[^"']*\s)?icon(?:\s[^"']*)?\1/i;
14
+
15
+ /** The rendered head tags, behind the placeholder when they hold no icon. */
16
+ export function withFaviconPlaceholder(headTags) {
17
+ return ICON_LINK.test(headTags)
18
+ ? headTags
19
+ : `${FAVICON_PLACEHOLDER}${headTags}`;
20
+ }
@@ -32,13 +32,58 @@ export function inertiaAppHtml(page) {
32
32
  return `<script data-page="app" type="application/json">${serialized}</script><div id="app"></div>`;
33
33
  }
34
34
 
35
- export function createInertiaPage(url, props, version = assetVersion()) {
36
- return {
37
- component: "DmsDynamicPage",
35
+ /** The one Inertia component every DMS page renders through. */
36
+ export const DMS_PAGE_COMPONENT = "DmsDynamicPage";
37
+
38
+ export function createInertiaPage(
39
+ url,
40
+ props,
41
+ version = assetVersion(),
42
+ onceProps,
43
+ ) {
44
+ const page = {
45
+ component: DMS_PAGE_COMPONENT,
38
46
  props: { ...props, errors: props?.errors ?? {} },
39
47
  url,
40
48
  version,
41
49
  };
50
+ if (onceProps) page.onceProps = onceProps;
51
+ return page;
52
+ }
53
+
54
+ function headerList(request, name) {
55
+ return String(request.headers[name] ?? "")
56
+ .split(",")
57
+ .map((entry) => entry.trim())
58
+ .filter(Boolean);
59
+ }
60
+
61
+ /**
62
+ * Which top-level props a request asks for. A full visit asks for all of them.
63
+ * A partial reload (`router.reload({ only, except })`) of the page on screen
64
+ * names them: a nested path selects its top-level prop, which is sent whole,
65
+ * and only a top-level name excludes one.
66
+ */
67
+ export function requestedProps(request) {
68
+ const partial =
69
+ Boolean(request.headers["x-inertia"]) &&
70
+ request.headers["x-inertia-partial-component"] === DMS_PAGE_COMPONENT;
71
+ if (!partial) return { partial, wants: () => true };
72
+ const only = new Set(
73
+ headerList(request, "x-inertia-partial-data").map(
74
+ (path) => path.split(".")[0],
75
+ ),
76
+ );
77
+ const except = new Set(headerList(request, "x-inertia-partial-except"));
78
+ return {
79
+ partial,
80
+ wants: (prop) => (only.size === 0 || only.has(prop)) && !except.has(prop),
81
+ };
82
+ }
83
+
84
+ /** The once props the browser already holds, unexpired, by key. */
85
+ export function heldOnceProps(request) {
86
+ return new Set(headerList(request, "x-inertia-except-once-props"));
42
87
  }
43
88
 
44
89
  export function inertiaHeaders(version = assetVersion()) {
@@ -0,0 +1,19 @@
1
+ // The address the frontend server listens on.
2
+
3
+ const LOOPBACK = "127.0.0.1";
4
+ const EVERY_INTERFACE = "0.0.0.0";
5
+
6
+ /**
7
+ * `HOST` when set. Otherwise the development server (`ajs dms dev`) listens
8
+ * on the loopback address only: it serves the sources, the modules' server
9
+ * code and its own error overlays, which nobody else on the network should
10
+ * reach. `HOST=0.0.0.0` opens it to them. A production server listens on
11
+ * every interface, as a container expects.
12
+ *
13
+ * @param env The server's environment
14
+ */
15
+ export function listenHost(env = process.env) {
16
+ const host = env.HOST?.trim();
17
+ if (host) return host;
18
+ return env.DMS_DEV === "true" ? LOOPBACK : EVERY_INTERFACE;
19
+ }
@@ -0,0 +1,265 @@
1
+ // What a module route handler receives and how its answer is written: the
2
+ // Web-standard request, the session, the module's sealed cookies, the backend,
3
+ // and the defaults every route gets once (same-origin on unsafe methods, a body
4
+ // limit, `no-store`). See `server-module.ts` for the types a module reads.
5
+
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { backend, json, RequestBodyError } from "./auth/backend.mjs";
9
+ import {
10
+ clientIp,
11
+ CROSS_ORIGIN_ERROR,
12
+ isSameOrigin,
13
+ requestScheme,
14
+ } from "./auth/client-ip.mjs";
15
+ import {
16
+ clearSealedCookie,
17
+ clearSession,
18
+ readSealedCookie,
19
+ readSession,
20
+ SESSION_COOKIE,
21
+ setRequestSession,
22
+ writeSealedCookie,
23
+ writeSession,
24
+ } from "./auth/session.mjs";
25
+ import { renderEmailTemplate } from "./email.mjs";
26
+ import { backendUrl } from "./request-target.mjs";
27
+
28
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
29
+ const COOKIE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
30
+ /** The sealed cookies of the module that owns sign-in: session, accounts, flows. */
31
+ const RESERVED_COOKIE_PREFIX = "dms_";
32
+ const BODY_TOO_LARGE = "Request body too large";
33
+
34
+ function requestOrigin(request) {
35
+ const host = request.headers.host;
36
+ try {
37
+ return new URL(`${requestScheme(request)}://${host}`).origin;
38
+ } catch {
39
+ return "http://frontend.local";
40
+ }
41
+ }
42
+
43
+ function requestHeaders(request) {
44
+ const headers = new Headers();
45
+ for (const [name, value] of Object.entries(request.headers)) {
46
+ if (name.startsWith(":") || value === undefined) continue;
47
+ for (const entry of [value].flat()) headers.append(name, entry);
48
+ }
49
+ return headers;
50
+ }
51
+
52
+ /** The request body as a Web stream that errors past `limit` bytes. */
53
+ function limitedBody(request, limit) {
54
+ let size = 0;
55
+ return Readable.toWeb(request).pipeThrough(
56
+ new TransformStream({
57
+ transform(chunk, controller) {
58
+ size += chunk.byteLength;
59
+ if (size > limit)
60
+ controller.error(new RequestBodyError(413, BODY_TOO_LARGE));
61
+ else controller.enqueue(chunk);
62
+ },
63
+ }),
64
+ );
65
+ }
66
+
67
+ function webRequest(request, route) {
68
+ const url = new URL(request.url, requestOrigin(request));
69
+ const hasBody = !["GET", "HEAD"].includes(request.method);
70
+ const declaredLength = Number(request.headers["content-length"] ?? 0);
71
+ if (hasBody && declaredLength > route.bodyLimit)
72
+ throw new RequestBodyError(413, BODY_TOO_LARGE);
73
+ return new Request(url, {
74
+ method: request.method,
75
+ headers: requestHeaders(request),
76
+ body: hasBody ? limitedBody(request, route.bodyLimit) : undefined,
77
+ duplex: "half",
78
+ });
79
+ }
80
+
81
+ async function readJson(webRequestValue) {
82
+ if (!webRequestValue.body) return {};
83
+ const chunks = [];
84
+ const reader = webRequestValue.body.getReader();
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ chunks.push(value);
89
+ }
90
+ if (!chunks.length) return {};
91
+ try {
92
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
93
+ } catch {
94
+ throw new RequestBodyError(400, "Invalid request body");
95
+ }
96
+ }
97
+
98
+ function checkedCookieName(name) {
99
+ if (typeof name !== "string" || !COOKIE_NAME.test(name))
100
+ throw new Error(`Invalid cookie name: ${String(name)}`);
101
+ if (name === SESSION_COOKIE)
102
+ throw new Error(
103
+ `The ${SESSION_COOKIE} cookie is the session's: use event.session`,
104
+ );
105
+ return name;
106
+ }
107
+
108
+ function sessionAccess(request, response, view) {
109
+ return {
110
+ read: () => readSession(request),
111
+ view: () => view?.(readSession(request)) ?? {},
112
+ write(session) {
113
+ session.updatedAt = Date.now();
114
+ writeSession(response, session);
115
+ },
116
+ clear: () => clearSession(response),
117
+ useForRequest: (session) => setRequestSession(request, session),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * The name a module's sealed cookie takes in the jar. The module that owns
123
+ * sign-in (`registerAuth`) names its own, the `dms_*` ones included; any other
124
+ * module's are prefixed with its id and a dot, which no name of the owner's
125
+ * holds, so no module reads or overwrites another's, nor the owner's session
126
+ * and account cookies.
127
+ */
128
+ function moduleCookieName(name, moduleId, authOwner) {
129
+ const checked = checkedCookieName(name);
130
+ if (moduleId === authOwner) return checked;
131
+ if (checked.startsWith(RESERVED_COOKIE_PREFIX))
132
+ throw new Error(
133
+ `The ${RESERVED_COOKIE_PREFIX}* cookies belong to the module that owns sign-in` +
134
+ `${authOwner ? ` (${authOwner})` : ""}: choose another name than ${name}`,
135
+ );
136
+ return `${String(moduleId).replaceAll(/[^A-Za-z0-9_-]/g, "-")}.${checked}`;
137
+ }
138
+
139
+ function cookieAccess(request, response, moduleId, authOwner) {
140
+ const named = (name) => moduleCookieName(name, moduleId, authOwner);
141
+ return {
142
+ readSealed: (name) => readSealedCookie(request, named(name)),
143
+ writeSealed: (name, value, options = {}) =>
144
+ writeSealedCookie(
145
+ response,
146
+ named(name),
147
+ value,
148
+ options.maxAge,
149
+ options.path ?? "/",
150
+ ),
151
+ clear: (name, options = {}) =>
152
+ clearSealedCookie(response, named(name), options.path ?? "/"),
153
+ };
154
+ }
155
+
156
+ function frozenCopy(value) {
157
+ return Object.freeze(structuredClone(value ?? {}));
158
+ }
159
+
160
+ /**
161
+ * The event a handler receives.
162
+ *
163
+ * @param context `{ request, response, route, params, module, app, view,
164
+ * authOwner }`, where `module` is `{ id, options, privateOptions }`, `app`
165
+ * the deployment's read-only data, `view` what a session shows a page and
166
+ * `authOwner` the id of the module that owns sign-in
167
+ */
168
+ export function createServerEvent(context) {
169
+ const { request, response, route, params } = context;
170
+ const event = {
171
+ request: webRequest(request, route),
172
+ params: Object.freeze({ ...params }),
173
+ session: sessionAccess(request, response, context.view),
174
+ cookies: cookieAccess(
175
+ request,
176
+ response,
177
+ context.module.id,
178
+ context.authOwner,
179
+ ),
180
+ backend: (path, init = {}) => backend(path, request, init),
181
+ backendUrl: (path) => backendUrl(path),
182
+ clientIp: () => clientIp(request),
183
+ module: Object.freeze({
184
+ id: context.module.id,
185
+ options: frozenCopy(context.module.options),
186
+ privateOptions: frozenCopy(context.module.privateOptions),
187
+ }),
188
+ renderEmail: (template, props, options = {}) =>
189
+ renderEmailTemplate(template, props, options),
190
+ app: Object.freeze({
191
+ authEstablishEndpoints: Object.freeze([
192
+ ...(context.app?.authEstablishEndpoints ?? []),
193
+ ]),
194
+ }),
195
+ };
196
+ event.url = new URL(event.request.url);
197
+ event.readJson = () => readJson(event.request);
198
+ return event;
199
+ }
200
+
201
+ function isResponse(value) {
202
+ return (
203
+ value instanceof Response ||
204
+ (typeof value === "object" &&
205
+ value !== null &&
206
+ typeof value.status === "number" &&
207
+ typeof value.headers?.get === "function" &&
208
+ "body" in value)
209
+ );
210
+ }
211
+
212
+ function asResponse(result) {
213
+ if (isResponse(result)) return result;
214
+ if (result === undefined || result === null)
215
+ return new Response(null, { status: 204 });
216
+ return Response.json(result);
217
+ }
218
+
219
+ /**
220
+ * Writes a handler's answer. The cookies the event wrote are already on the
221
+ * Node response; those of a returned `Response` are added to them.
222
+ */
223
+ async function writeServerResponse(request, response, result) {
224
+ const answer = asResponse(result);
225
+ const cookies = [response.getHeader("set-cookie") ?? []]
226
+ .flat()
227
+ .concat(answer.headers.getSetCookie());
228
+ answer.headers.forEach((value, name) => {
229
+ if (name !== "set-cookie") response.setHeader(name, value);
230
+ });
231
+ if (cookies.length) response.setHeader("set-cookie", cookies);
232
+ if (!answer.headers.has("cache-control"))
233
+ response.setHeader("cache-control", "no-store");
234
+ response.writeHead(answer.status, answer.statusText || undefined);
235
+ if (!answer.body || request.method === "HEAD") {
236
+ await answer.body?.cancel();
237
+ response.end();
238
+ return;
239
+ }
240
+ await pipeline(Readable.fromWeb(answer.body), response);
241
+ }
242
+
243
+ /**
244
+ * Answers a request with the module route it reached. The route's defaults
245
+ * apply first: a request with a state-changing method needs the frontend's
246
+ * own `Origin` unless the route says otherwise. An error the handler lets
247
+ * through reaches the server's error handling with the cookies written so
248
+ * far.
249
+ *
250
+ * @param context See `createServerEvent`
251
+ */
252
+ export async function answerModuleRoute(context) {
253
+ const { request, response, route } = context;
254
+ if (
255
+ route.sameOrigin &&
256
+ !SAFE_METHODS.has(request.method) &&
257
+ !isSameOrigin(request)
258
+ ) {
259
+ json(response, 403, CROSS_ORIGIN_ERROR);
260
+ return;
261
+ }
262
+ const event = createServerEvent(context);
263
+ const result = await route.handler(event);
264
+ await writeServerResponse(request, response, result);
265
+ }