@antelopejs/dms-frontend 0.0.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 (54) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +131 -0
  3. package/dist/commands/build.js +66 -0
  4. package/dist/commands/clean.js +42 -0
  5. package/dist/commands/dev.js +116 -0
  6. package/dist/commands/prepare.js +60 -0
  7. package/dist/commands/start.js +49 -0
  8. package/dist/commands/verify-source.js +39 -0
  9. package/dist/common.js +24 -0
  10. package/dist/config.js +142 -0
  11. package/dist/discovery.js +123 -0
  12. package/dist/fs-sync.js +142 -0
  13. package/dist/index.js +44 -0
  14. package/dist/layer-watch.js +154 -0
  15. package/dist/layers.js +120 -0
  16. package/dist/manifest.js +109 -0
  17. package/dist/materialize.js +249 -0
  18. package/dist/ports.js +30 -0
  19. package/dist/update-check.js +173 -0
  20. package/dist/utils/cli-ui.js +178 -0
  21. package/dist/verify-source-runner.js +228 -0
  22. package/dist/workspace-setup.js +109 -0
  23. package/dist/workspace.js +76 -0
  24. package/package.json +97 -0
  25. package/templates/vue/DmsDynamicPage.vue +89 -0
  26. package/templates/vue/app-config-stub.mjs +1 -0
  27. package/templates/vue/app-runtime.ts +240 -0
  28. package/templates/vue/compress-assets.mjs +48 -0
  29. package/templates/vue/email-locales.ts +32 -0
  30. package/templates/vue/email-renderer.ts +159 -0
  31. package/templates/vue/email-runtime.ts +23 -0
  32. package/templates/vue/frontend-module.ts +1418 -0
  33. package/templates/vue/globals.d.ts +1 -0
  34. package/templates/vue/index.html +24 -0
  35. package/templates/vue/main.ts +33 -0
  36. package/templates/vue/npmrc +2 -0
  37. package/templates/vue/package.json +35 -0
  38. package/templates/vue/pnpm-workspace.yaml +4 -0
  39. package/templates/vue/server/auth/backend.mjs +83 -0
  40. package/templates/vue/server/auth/client-ip.mjs +52 -0
  41. package/templates/vue/server/auth/oauth.mjs +213 -0
  42. package/templates/vue/server/auth/routes.mjs +254 -0
  43. package/templates/vue/server/auth/session.mjs +180 -0
  44. package/templates/vue/server/client-manifest.mjs +116 -0
  45. package/templates/vue/server/email.mjs +36 -0
  46. package/templates/vue/server/inertia.mjs +79 -0
  47. package/templates/vue/server/render-token.mjs +81 -0
  48. package/templates/vue/server/tester.mjs +228 -0
  49. package/templates/vue/server.mjs +526 -0
  50. package/templates/vue/ssr-renderer.ts +146 -0
  51. package/templates/vue/tsconfig.json +31 -0
  52. package/templates/vue/typecheck-loader.mjs +13 -0
  53. package/templates/vue/vite.config.ts +161 -0
  54. package/templates/vue/vite.email.config.ts +77 -0
@@ -0,0 +1,254 @@
1
+ import { createHash } from "node:crypto";
2
+ import { backend, body, json, UpstreamError } from "./backend.mjs";
3
+ import { isSameOrigin } from "./client-ip.mjs";
4
+ import {
5
+ oauthCallback,
6
+ oauthHandoff,
7
+ oauthStart,
8
+ sessionFrom,
9
+ } from "./oauth.mjs";
10
+ import {
11
+ clearSession,
12
+ persistAccountSession,
13
+ readAccount,
14
+ readSession,
15
+ removeAccount,
16
+ setRequestSession,
17
+ } from "./session.mjs";
18
+
19
+ const REFRESH_TTL_MS = 15_000;
20
+ const refreshes = new Map();
21
+
22
+ const PASSTHROUGH = new Map([
23
+ ["/auth/request-2fa-email", "/api/auth/request-2fa-email"],
24
+ ]);
25
+
26
+ export function publicSession(session) {
27
+ if (!session) return {};
28
+ return {
29
+ user: session.user,
30
+ session: {
31
+ accountId: session.accountId,
32
+ activeTenantId: session.activeTenantId,
33
+ },
34
+ };
35
+ }
36
+
37
+ function refreshDigest(token) {
38
+ return createHash("sha256").update(token).digest("base64url");
39
+ }
40
+
41
+ function singleFlight(token, operation) {
42
+ const now = Date.now();
43
+ for (const [key, entry] of refreshes)
44
+ if (
45
+ entry.expiresAt &&
46
+ entry.expiresAt <= now &&
47
+ refreshes.get(key) === entry
48
+ )
49
+ refreshes.delete(key);
50
+ const key = refreshDigest(token);
51
+ const current = refreshes.get(key);
52
+ if (current) return current.promise;
53
+ const entry = { promise: undefined, expiresAt: undefined };
54
+ const promise = Promise.resolve().then(operation);
55
+ entry.promise = promise;
56
+ refreshes.set(key, entry);
57
+ const retain = () => {
58
+ entry.expiresAt = Date.now() + REFRESH_TTL_MS;
59
+ setTimeout(() => {
60
+ if (refreshes.get(key) === entry) refreshes.delete(key);
61
+ }, REFRESH_TTL_MS).unref();
62
+ };
63
+ promise.then(retain, retain);
64
+ return promise;
65
+ }
66
+
67
+ async function establish(request, response, endpoint) {
68
+ const result = await backend(endpoint, request, {
69
+ method: "POST",
70
+ body: await body(request),
71
+ });
72
+ if (result.requires_2fa || result.requires_tenant_assignment)
73
+ return json(response, 200, result);
74
+ const session = sessionFrom(result);
75
+ const account = persistAccountSession(request, response, session);
76
+ json(response, 200, { user: result.user, account });
77
+ }
78
+
79
+ export async function refreshSession(request, response) {
80
+ const session = readSession(request);
81
+ if (!session?.refreshToken) return undefined;
82
+ try {
83
+ const tokens = await singleFlight(session.refreshToken, () =>
84
+ backend("/api/auth/refresh", request, {
85
+ method: "POST",
86
+ token: session.accessToken,
87
+ body: { token: session.refreshToken },
88
+ }),
89
+ );
90
+ const user = await backend("/api/auth/me", request, {
91
+ token: tokens.access_token,
92
+ });
93
+ const refreshed = sessionFrom({ ...tokens, user });
94
+ persistAccountSession(request, response, refreshed, session.accountId);
95
+ setRequestSession(request, refreshed);
96
+ return refreshed;
97
+ } catch (error) {
98
+ const rejected =
99
+ error instanceof UpstreamError && [400, 401, 403].includes(error.status);
100
+ if (rejected) clearSession(response);
101
+ setRequestSession(request, undefined);
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ async function refresh(request, response) {
107
+ json(response, 200, publicSession(await refreshSession(request, response)));
108
+ }
109
+
110
+ async function logout(request, response) {
111
+ const session = readSession(request);
112
+ if (session?.refreshToken && session?.accessToken)
113
+ await backend("/api/auth/logout", request, {
114
+ method: "POST",
115
+ token: session.accessToken,
116
+ body: { token: session.refreshToken },
117
+ }).catch(() => {});
118
+ clearSession(response);
119
+ if (session?.accountId) removeAccount(request, response, session.accountId);
120
+ json(response, 200, {});
121
+ }
122
+
123
+ async function removeStoredAccount(request, response) {
124
+ const input = await body(request);
125
+ removeAccount(request, response, input.accountId);
126
+ json(response, 200, { success: true });
127
+ }
128
+
129
+ async function switchTenant(request, response) {
130
+ const session = readSession(request);
131
+ const input = await body(request);
132
+ if (!session) return json(response, 401, { message: "Not authenticated" });
133
+ if (!input.tenantId)
134
+ return json(response, 400, { message: "tenantId required" });
135
+ const tokens = await backend("/api/auth/switch-tenant", request, {
136
+ method: "POST",
137
+ token: session.accessToken,
138
+ body: { refreshToken: session.refreshToken, tenantId: input.tenantId },
139
+ });
140
+ const user = await backend("/api/auth/me", request, {
141
+ token: tokens.access_token,
142
+ });
143
+ const switched = sessionFrom({ ...tokens, user });
144
+ persistAccountSession(request, response, switched, session.accountId);
145
+ json(response, 200, { success: true, user });
146
+ }
147
+
148
+ async function switchAccount(request, response) {
149
+ const input = await body(request);
150
+ const account = readAccount(request, input.accountId);
151
+ if (!account) return json(response, 401, { message: "Account expired" });
152
+ try {
153
+ const tokens = await backend("/api/auth/refresh", request, {
154
+ method: "POST",
155
+ body: { token: account.refreshToken },
156
+ });
157
+ const user = await backend("/api/auth/me", request, {
158
+ token: tokens.access_token,
159
+ });
160
+ const session = sessionFrom({ ...tokens, user });
161
+ const descriptor = persistAccountSession(
162
+ request,
163
+ response,
164
+ session,
165
+ input.accountId,
166
+ );
167
+ json(response, 200, { success: true, user, account: descriptor });
168
+ } catch (error) {
169
+ if (
170
+ error instanceof UpstreamError &&
171
+ [400, 401, 403].includes(error.status)
172
+ )
173
+ removeAccount(request, response, input.accountId);
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ async function validateAccount(request, response) {
179
+ const input = await body(request);
180
+ const account = readAccount(request, input.accountId);
181
+ if (!account) return json(response, 200, { valid: false });
182
+ try {
183
+ const tokens = await backend("/api/auth/refresh", request, {
184
+ method: "POST",
185
+ body: { token: account.refreshToken },
186
+ });
187
+ persistAccountSession(
188
+ request,
189
+ response,
190
+ sessionFrom({
191
+ ...tokens,
192
+ user: { id: account.userId, email: account.email, name: account.name },
193
+ }),
194
+ input.accountId,
195
+ );
196
+ json(response, 200, { valid: true });
197
+ } catch (error) {
198
+ const rejected =
199
+ error instanceof UpstreamError && [400, 401, 403].includes(error.status);
200
+ if (rejected) removeAccount(request, response, input.accountId);
201
+ json(response, 200, { valid: rejected ? false : null });
202
+ }
203
+ }
204
+
205
+ const actions = {
206
+ "/auth/login": (request, response) =>
207
+ establish(request, response, "/api/auth/login"),
208
+ "/auth/signup": (request, response) =>
209
+ establish(request, response, "/api/auth/signup"),
210
+ "/auth/verify-2fa": (request, response) =>
211
+ establish(request, response, "/api/auth/verify-2fa"),
212
+ "/auth/switch-account": switchAccount,
213
+ "/auth/switch-tenant": switchTenant,
214
+ "/auth/validate-account": validateAccount,
215
+ "/auth/remove-account": removeStoredAccount,
216
+ };
217
+
218
+ export async function handleAuth(request, response, url) {
219
+ if (url.pathname === "/api/_auth/session") {
220
+ if (request.method === "GET")
221
+ return json(response, 200, publicSession(readSession(request)));
222
+ if (!["POST", "DELETE"].includes(request.method)) {
223
+ response.setHeader("allow", "GET, POST, DELETE");
224
+ return json(response, 405, { error: "Method Not Allowed" });
225
+ }
226
+ if (!isSameOrigin(request))
227
+ return json(response, 403, { error: "Forbidden" });
228
+ return request.method === "DELETE"
229
+ ? logout(request, response)
230
+ : refresh(request, response);
231
+ }
232
+ const match = url.pathname.match(
233
+ /^\/auth\/oauth\/([^/]+)\/(start|callback)$/,
234
+ );
235
+ if (match)
236
+ return match[2] === "start"
237
+ ? oauthStart(request, response, match[1], url)
238
+ : oauthCallback(request, response, match[1], url);
239
+ if (request.method !== "POST" || !isSameOrigin(request))
240
+ return json(response, 403, { error: "Forbidden" });
241
+ if (url.pathname === "/auth/oauth/handoff")
242
+ return oauthHandoff(request, response);
243
+ const endpoint = PASSTHROUGH.get(url.pathname);
244
+ if (endpoint)
245
+ return json(
246
+ response,
247
+ 200,
248
+ await backend(endpoint, request, {
249
+ method: "POST",
250
+ body: await body(request),
251
+ }),
252
+ );
253
+ return actions[url.pathname]?.(request, response);
254
+ }
@@ -0,0 +1,180 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ randomUUID,
7
+ } from "node:crypto";
8
+
9
+ const SESSION_COOKIE = "dms_session";
10
+ const ACCOUNT_INDEX_COOKIE = "dms_account_index";
11
+ const ACCOUNT_COOKIE_PREFIX = "dms_account_";
12
+ const REQUEST_SESSION = Symbol("dms-request-session");
13
+ const MAX_AGE = 60 * 60 * 24 * 30;
14
+ const UUID =
15
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
+
17
+ function key() {
18
+ const secret = process.env.DMS_SESSION_SECRET;
19
+ if (!secret || secret.length < 32)
20
+ throw new Error("DMS_SESSION_SECRET must contain at least 32 characters");
21
+ return createHash("sha256").update(secret).digest();
22
+ }
23
+
24
+ function encode(value) {
25
+ const iv = randomBytes(12);
26
+ const cipher = createCipheriv("aes-256-gcm", key(), iv);
27
+ const encrypted = Buffer.concat([
28
+ cipher.update(JSON.stringify(value)),
29
+ cipher.final(),
30
+ ]);
31
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString(
32
+ "base64url",
33
+ );
34
+ }
35
+
36
+ function decode(value) {
37
+ try {
38
+ const payload = Buffer.from(value, "base64url");
39
+ const decipher = createDecipheriv(
40
+ "aes-256-gcm",
41
+ key(),
42
+ payload.subarray(0, 12),
43
+ );
44
+ decipher.setAuthTag(payload.subarray(12, 28));
45
+ return JSON.parse(
46
+ Buffer.concat([decipher.update(payload.subarray(28)), decipher.final()]),
47
+ );
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function cookieValue(request, name) {
54
+ const cookies = request.headers.cookie?.split(";") ?? [];
55
+ return cookies
56
+ .map((part) => part.trim().split("="))
57
+ .find(([key]) => key === name)?.[1];
58
+ }
59
+
60
+ function attributes(maxAge, path = "/") {
61
+ const secure = process.env.DMS_COOKIE_SECURE !== "false" ? "; Secure" : "";
62
+ return `Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
63
+ }
64
+
65
+ function appendCookie(response, cookie) {
66
+ const current = response.getHeader("set-cookie");
67
+ response.setHeader(
68
+ "set-cookie",
69
+ current
70
+ ? [cookie, ...(Array.isArray(current) ? current : [current])]
71
+ : cookie,
72
+ );
73
+ }
74
+
75
+ function accountCookieName(accountId) {
76
+ if (!UUID.test(accountId)) throw new Error("Invalid account id");
77
+ return `${ACCOUNT_COOKIE_PREFIX}${accountId.replaceAll("-", "")}`;
78
+ }
79
+
80
+ function descriptor(session, accountId) {
81
+ return {
82
+ accountId,
83
+ userId: session.user?._id ?? session.user?.id,
84
+ email: session.user?.email,
85
+ name: session.user?.name,
86
+ activeTenantId: session.activeTenantId,
87
+ };
88
+ }
89
+
90
+ export function readSession(request) {
91
+ if (REQUEST_SESSION in request) return request[REQUEST_SESSION];
92
+ const value = cookieValue(request, SESSION_COOKIE);
93
+ return value ? decode(value) : undefined;
94
+ }
95
+
96
+ export function setRequestSession(request, session) {
97
+ request[REQUEST_SESSION] = session;
98
+ }
99
+
100
+ export function writeSession(response, session) {
101
+ appendCookie(
102
+ response,
103
+ `${SESSION_COOKIE}=${encode(session)}; ${attributes(MAX_AGE)}`,
104
+ );
105
+ }
106
+
107
+ export function clearSession(response) {
108
+ appendCookie(response, `${SESSION_COOKIE}=; ${attributes(0)}`);
109
+ }
110
+
111
+ export function readAccounts(request) {
112
+ const value = cookieValue(request, ACCOUNT_INDEX_COOKIE);
113
+ return value ? (decode(value) ?? {}) : {};
114
+ }
115
+
116
+ function writeAccountIndex(response, accounts) {
117
+ appendCookie(
118
+ response,
119
+ `${ACCOUNT_INDEX_COOKIE}=${encode(accounts)}; ${attributes(MAX_AGE)}`,
120
+ );
121
+ }
122
+
123
+ export function readAccount(request, accountId) {
124
+ if (!UUID.test(accountId ?? "")) return undefined;
125
+ const value = cookieValue(request, accountCookieName(accountId));
126
+ return value ? decode(value) : undefined;
127
+ }
128
+
129
+ export function storeAccount(request, response, session, accountId) {
130
+ const id = accountId ?? randomUUID();
131
+ const accountDescriptor = descriptor(session, id);
132
+ const accounts = { ...readAccounts(request), [id]: accountDescriptor };
133
+ writeAccountIndex(response, accounts);
134
+ appendCookie(
135
+ response,
136
+ `${accountCookieName(id)}=${encode({ refreshToken: session.refreshToken, ...accountDescriptor })}; ${attributes(MAX_AGE)}`,
137
+ );
138
+ return accountDescriptor;
139
+ }
140
+
141
+ export function assertAccountInvariant(session, accountId) {
142
+ if (!accountId || session.accountId !== accountId)
143
+ throw new Error("Account session invariant violated");
144
+ }
145
+
146
+ export function persistAccountSession(request, response, session, accountId) {
147
+ const id = accountId ?? session.accountId ?? randomUUID();
148
+ session.accountId = id;
149
+ const account = storeAccount(request, response, session, id);
150
+ assertAccountInvariant(session, account.accountId);
151
+ writeSession(response, session);
152
+ return account;
153
+ }
154
+
155
+ export function removeAccount(request, response, accountId) {
156
+ const accounts = { ...readAccounts(request) };
157
+ delete accounts[accountId];
158
+ writeAccountIndex(response, accounts);
159
+ if (UUID.test(accountId ?? ""))
160
+ appendCookie(
161
+ response,
162
+ `${accountCookieName(accountId)}=; ${attributes(0)}`,
163
+ );
164
+ }
165
+
166
+ export function readSealedCookie(request, name) {
167
+ const value = cookieValue(request, name);
168
+ return value ? decode(value) : undefined;
169
+ }
170
+
171
+ export function writeSealedCookie(response, name, value, maxAge) {
172
+ appendCookie(
173
+ response,
174
+ `${name}=${encode(value)}; ${attributes(maxAge, "/auth/oauth")}`,
175
+ );
176
+ }
177
+
178
+ export function clearSealedCookie(response, name) {
179
+ appendCookie(response, `${name}=; ${attributes(0, "/auth/oauth")}`);
180
+ }
@@ -0,0 +1,116 @@
1
+ // Reading the built client manifest and turning a rendered page into the style
2
+ // and module-preload tags its HTML needs.
3
+ //
4
+ // Lives apart from server.mjs so the file stays under the size the linter
5
+ // allows. Production-only: in dev, Vite serves these itself.
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url));
12
+ const BUILT_TEMPLATE_PATH = join(PROJECT_ROOT, "dist/client/index.html");
13
+ const CLIENT_MANIFEST_PATH = join(
14
+ PROJECT_ROOT,
15
+ "dist/client/.vite/manifest.json",
16
+ );
17
+ const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
18
+
19
+ // Read once and kept: both files are build output and cannot change while the
20
+ // server is up.
21
+ let clientManifest;
22
+ let productionTemplate;
23
+
24
+ export function productionHtmlTemplate() {
25
+ if (productionTemplate) return productionTemplate;
26
+ const templatePath = existsSync(BUILT_TEMPLATE_PATH)
27
+ ? BUILT_TEMPLATE_PATH
28
+ : SOURCE_TEMPLATE_PATH;
29
+ productionTemplate = readFileSync(templatePath, "utf8");
30
+ return productionTemplate;
31
+ }
32
+
33
+ export function normalizeDmsName(name) {
34
+ return name
35
+ .replace(/^lazy/i, "")
36
+ .replace(/^dms[-_]?/i, "")
37
+ .replace(/[^a-z0-9]/gi, "")
38
+ .toLowerCase();
39
+ }
40
+
41
+ export function collectRenderedComponentNames(value, names) {
42
+ if (!value || typeof value !== "object") return;
43
+ if (Array.isArray(value)) {
44
+ value.forEach((entry) => {
45
+ collectRenderedComponentNames(entry, names);
46
+ });
47
+ return;
48
+ }
49
+ if (typeof value.componentName === "string")
50
+ names.add(normalizeDmsName(value.componentName));
51
+ Object.values(value).forEach((entry) => {
52
+ collectRenderedComponentNames(entry, names);
53
+ });
54
+ }
55
+
56
+ function productionClientManifest() {
57
+ if (clientManifest) return clientManifest;
58
+ if (!existsSync(CLIENT_MANIFEST_PATH)) return undefined;
59
+ clientManifest = JSON.parse(readFileSync(CLIENT_MANIFEST_PATH, "utf8"));
60
+ return clientManifest;
61
+ }
62
+
63
+ function sourceComponentName(source) {
64
+ const filename =
65
+ source
66
+ .split("/")
67
+ .at(-1)
68
+ ?.replace(/\.vue$/, "") ?? "";
69
+ return normalizeDmsName(filename);
70
+ }
71
+
72
+ function matchedPageManifestEntries(page, manifest) {
73
+ const names = new Set();
74
+ const layout = page.props?.page?.layout;
75
+ collectRenderedComponentNames(layout, names);
76
+ [
77
+ page.props?.page?.componentName,
78
+ layout?.componentName,
79
+ layout?.layout?.componentName,
80
+ ].forEach((name) => {
81
+ if (typeof name === "string") names.add(normalizeDmsName(name));
82
+ });
83
+ return Object.entries(manifest)
84
+ .filter(
85
+ ([source, entry]) =>
86
+ entry.isDynamicEntry && names.has(sourceComponentName(source)),
87
+ )
88
+ .map(([key]) => key);
89
+ }
90
+
91
+ function collectManifestStyles(manifest, key, styles, visited) {
92
+ if (visited.has(key)) return;
93
+ visited.add(key);
94
+ const entry = manifest[key];
95
+ if (!entry) return;
96
+ for (const file of entry.css ?? []) styles.add(file);
97
+ for (const imported of entry.imports ?? [])
98
+ collectManifestStyles(manifest, imported, styles, visited);
99
+ }
100
+
101
+ export function pageModuleStyles(page) {
102
+ const manifest = productionClientManifest();
103
+ const styles = new Set();
104
+ if (!manifest) return styles;
105
+ const visited = new Set();
106
+ for (const key of matchedPageManifestEntries(page, manifest))
107
+ collectManifestStyles(manifest, key, styles, visited);
108
+ return styles;
109
+ }
110
+
111
+ export function pageModulePreloads(page, template) {
112
+ return [...pageModuleStyles(page)]
113
+ .filter((file) => !template.includes(`href="/${file}"`))
114
+ .map((file) => `<link rel="stylesheet" href="/${file}">`)
115
+ .join("");
116
+ }
@@ -0,0 +1,36 @@
1
+ import { body } from "./auth/backend.mjs";
2
+ import { validRenderToken } from "./render-token.mjs";
3
+
4
+ const JSON_TYPE = "application/json";
5
+
6
+ /** Renders an email template for an authenticated backend request. */
7
+ export async function handleEmailRender(request, response) {
8
+ if (!validRenderToken(request.headers["x-dms-service-token"])) {
9
+ response.writeHead(401, { "content-type": JSON_TYPE });
10
+ return response.end(JSON.stringify({ message: "Invalid service token" }));
11
+ }
12
+ const input = await body(request);
13
+ if (
14
+ typeof input.templateName !== "string" ||
15
+ input.templateName.trim() === "" ||
16
+ typeof input.props !== "object"
17
+ ) {
18
+ response.writeHead(400, { "content-type": JSON_TYPE });
19
+ return response.end(JSON.stringify({ message: "Invalid request body" }));
20
+ }
21
+ try {
22
+ const { renderEmail } = await import("../dist/server/email-renderer.js");
23
+ const html = await renderEmail(input.templateName, input.props ?? {}, {
24
+ locale: request.headers["x-content-language"],
25
+ });
26
+ response.writeHead(200, { "content-type": JSON_TYPE });
27
+ response.end(JSON.stringify({ html }));
28
+ } catch {
29
+ response.writeHead(404, { "content-type": JSON_TYPE });
30
+ response.end(
31
+ JSON.stringify({
32
+ message: `Template "${input.templateName}" not found or render failed`,
33
+ }),
34
+ );
35
+ }
36
+ }
@@ -0,0 +1,79 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const CLIENT_MANIFEST_PATH = fileURLToPath(
6
+ new URL("../dist/client/.vite/manifest.json", import.meta.url),
7
+ );
8
+ let productionAssetVersion;
9
+
10
+ export function assetVersion() {
11
+ if (process.env.DMS_DEV === "true" || !existsSync(CLIENT_MANIFEST_PATH))
12
+ return "development";
13
+ productionAssetVersion ??= createHash("sha256")
14
+ .update(readFileSync(CLIENT_MANIFEST_PATH))
15
+ .digest("hex");
16
+ return productionAssetVersion;
17
+ }
18
+
19
+ export function inertiaAppHtml(page) {
20
+ const serialized = JSON.stringify(page).replaceAll("/", "\\/");
21
+ return `<script data-page="app" type="application/json">${serialized}</script><div id="app"></div>`;
22
+ }
23
+
24
+ export function createInertiaPage(url, props, version = assetVersion()) {
25
+ return {
26
+ component: "DmsDynamicPage",
27
+ props: { ...props, errors: props?.errors ?? {} },
28
+ url,
29
+ version,
30
+ };
31
+ }
32
+
33
+ export function inertiaHeaders(version = assetVersion()) {
34
+ return {
35
+ "content-type": "application/json",
36
+ "x-inertia": "true",
37
+ vary: "X-Inertia",
38
+ "x-inertia-version": version,
39
+ };
40
+ }
41
+
42
+ export function handleAssetVersionMismatch(request, response) {
43
+ if (
44
+ !request.headers["x-inertia"] ||
45
+ !request.headers["x-inertia-version"] ||
46
+ request.headers["x-inertia-version"] === assetVersion()
47
+ )
48
+ return false;
49
+ response.writeHead(409, {
50
+ vary: "X-Inertia",
51
+ "x-inertia-location": request.url,
52
+ "x-inertia-version": assetVersion(),
53
+ });
54
+ response.end();
55
+ return true;
56
+ }
57
+
58
+ export function redirectFrontendVisit(request, response, location) {
59
+ const vary = { vary: "X-Inertia" };
60
+ if (!request.headers["x-inertia"]) {
61
+ response.writeHead(request.method === "GET" ? 302 : 303, {
62
+ ...vary,
63
+ location,
64
+ });
65
+ } else if (location.includes("#") && !request.headers["x-inertia-prefetch"]) {
66
+ response.writeHead(409, { ...vary, "x-inertia-redirect": location });
67
+ } else if (
68
+ new URL(location, "http://frontend.local").origin !==
69
+ "http://frontend.local"
70
+ ) {
71
+ response.writeHead(409, { ...vary, "x-inertia-location": location });
72
+ } else {
73
+ response.writeHead(request.method === "GET" ? 302 : 303, {
74
+ ...vary,
75
+ location,
76
+ });
77
+ }
78
+ response.end();
79
+ }