@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.
- package/LICENSE +190 -0
- package/README.md +131 -0
- package/dist/commands/build.js +66 -0
- package/dist/commands/clean.js +42 -0
- package/dist/commands/dev.js +116 -0
- package/dist/commands/prepare.js +60 -0
- package/dist/commands/start.js +49 -0
- package/dist/commands/verify-source.js +39 -0
- package/dist/common.js +24 -0
- package/dist/config.js +142 -0
- package/dist/discovery.js +123 -0
- package/dist/fs-sync.js +142 -0
- package/dist/index.js +44 -0
- package/dist/layer-watch.js +154 -0
- package/dist/layers.js +120 -0
- package/dist/manifest.js +109 -0
- package/dist/materialize.js +249 -0
- package/dist/ports.js +30 -0
- package/dist/update-check.js +173 -0
- package/dist/utils/cli-ui.js +178 -0
- package/dist/verify-source-runner.js +228 -0
- package/dist/workspace-setup.js +109 -0
- package/dist/workspace.js +76 -0
- package/package.json +97 -0
- package/templates/vue/DmsDynamicPage.vue +89 -0
- package/templates/vue/app-config-stub.mjs +1 -0
- package/templates/vue/app-runtime.ts +240 -0
- package/templates/vue/compress-assets.mjs +48 -0
- package/templates/vue/email-locales.ts +32 -0
- package/templates/vue/email-renderer.ts +159 -0
- package/templates/vue/email-runtime.ts +23 -0
- package/templates/vue/frontend-module.ts +1418 -0
- package/templates/vue/globals.d.ts +1 -0
- package/templates/vue/index.html +24 -0
- package/templates/vue/main.ts +33 -0
- package/templates/vue/npmrc +2 -0
- package/templates/vue/package.json +35 -0
- package/templates/vue/pnpm-workspace.yaml +4 -0
- package/templates/vue/server/auth/backend.mjs +83 -0
- package/templates/vue/server/auth/client-ip.mjs +52 -0
- package/templates/vue/server/auth/oauth.mjs +213 -0
- package/templates/vue/server/auth/routes.mjs +254 -0
- package/templates/vue/server/auth/session.mjs +180 -0
- package/templates/vue/server/client-manifest.mjs +116 -0
- package/templates/vue/server/email.mjs +36 -0
- package/templates/vue/server/inertia.mjs +79 -0
- package/templates/vue/server/render-token.mjs +81 -0
- package/templates/vue/server/tester.mjs +228 -0
- package/templates/vue/server.mjs +526 -0
- package/templates/vue/ssr-renderer.ts +146 -0
- package/templates/vue/tsconfig.json +31 -0
- package/templates/vue/typecheck-loader.mjs +13 -0
- package/templates/vue/vite.config.ts +161 -0
- package/templates/vue/vite.email.config.ts +77 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { extname, join, resolve } from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
createBrotliCompress,
|
|
9
|
+
createGzip,
|
|
10
|
+
constants as zlibConstants,
|
|
11
|
+
} from "node:zlib";
|
|
12
|
+
import { ofetch } from "ofetch";
|
|
13
|
+
import { RequestBodyError, UpstreamError } from "./server/auth/backend.mjs";
|
|
14
|
+
import { isSameOrigin } from "./server/auth/client-ip.mjs";
|
|
15
|
+
import {
|
|
16
|
+
handleAuth,
|
|
17
|
+
publicSession,
|
|
18
|
+
refreshSession,
|
|
19
|
+
} from "./server/auth/routes.mjs";
|
|
20
|
+
import { readSession } from "./server/auth/session.mjs";
|
|
21
|
+
import {
|
|
22
|
+
pageModulePreloads,
|
|
23
|
+
productionHtmlTemplate,
|
|
24
|
+
} from "./server/client-manifest.mjs";
|
|
25
|
+
import { handleEmailRender } from "./server/email.mjs";
|
|
26
|
+
import { handleTester } from "./server/tester.mjs";
|
|
27
|
+
import {
|
|
28
|
+
createInertiaPage,
|
|
29
|
+
inertiaAppHtml,
|
|
30
|
+
inertiaHeaders,
|
|
31
|
+
redirectFrontendVisit,
|
|
32
|
+
handleAssetVersionMismatch,
|
|
33
|
+
} from "./server/inertia.mjs";
|
|
34
|
+
export * from "./server/inertia.mjs";
|
|
35
|
+
|
|
36
|
+
const INERTIA_HEADER = "x-inertia";
|
|
37
|
+
const JSON_TYPE = "application/json";
|
|
38
|
+
const PROJECT_ROOT = fileURLToPath(new URL(".", import.meta.url));
|
|
39
|
+
const MODULE_REGISTRY_PATH = join(
|
|
40
|
+
PROJECT_ROOT,
|
|
41
|
+
"generated-frontend-modules.json",
|
|
42
|
+
);
|
|
43
|
+
const MODULE_REGISTRY = existsSync(MODULE_REGISTRY_PATH)
|
|
44
|
+
? JSON.parse(readFileSync(MODULE_REGISTRY_PATH, "utf8"))
|
|
45
|
+
: { modules: [] };
|
|
46
|
+
const MIME_TYPES = {
|
|
47
|
+
".css": "text/css",
|
|
48
|
+
".js": "text/javascript",
|
|
49
|
+
".svg": "image/svg+xml",
|
|
50
|
+
};
|
|
51
|
+
const PROXY_PREFIXES = ["/api/", "/dms/"];
|
|
52
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
53
|
+
const AUTH_SERVER_ROUTES = [
|
|
54
|
+
["GET", /^\/api\/_auth\/session\/?$/],
|
|
55
|
+
["POST", /^\/api\/_auth\/session\/?$/],
|
|
56
|
+
["DELETE", /^\/api\/_auth\/session\/?$/],
|
|
57
|
+
[
|
|
58
|
+
"POST",
|
|
59
|
+
/^\/auth\/(?:login|signup|verify-2fa|request-2fa-email|switch-account|switch-tenant|validate-account|remove-account)\/?$/,
|
|
60
|
+
],
|
|
61
|
+
["POST", /^\/auth\/oauth\/handoff\/?$/],
|
|
62
|
+
["GET", /^\/auth\/oauth\/[^/]+\/(?:start|callback)\/?$/],
|
|
63
|
+
];
|
|
64
|
+
const HTML_RENDER_ROUTE = "/api/html/render";
|
|
65
|
+
const TESTER_ROUTE = /^\/api\/_dms\/tester\/?$/;
|
|
66
|
+
const AUTH_PAGE = "/auth";
|
|
67
|
+
const ONBOARDING_PAGE = "/onboarding";
|
|
68
|
+
const MINIMUM_COMPRESSION_BYTES = 1_024;
|
|
69
|
+
const DYNAMIC_BROTLI_QUALITY = 4;
|
|
70
|
+
const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
|
|
71
|
+
const BUILT_SSR_RENDERER_PATH = join(PROJECT_ROOT, "dist/ssr/ssr-renderer.js");
|
|
72
|
+
let productionSsrRenderer;
|
|
73
|
+
let vite;
|
|
74
|
+
let vitePromise;
|
|
75
|
+
let frontendHttpServer;
|
|
76
|
+
|
|
77
|
+
function findHomepage(options) {
|
|
78
|
+
if (!options || typeof options !== "object") return undefined;
|
|
79
|
+
if (typeof options.homepage === "string") return options.homepage;
|
|
80
|
+
return Object.values(options)
|
|
81
|
+
.map(findHomepage)
|
|
82
|
+
.find((homepage) => homepage !== undefined);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const HOMEPAGE =
|
|
86
|
+
MODULE_REGISTRY.modules
|
|
87
|
+
.map((module) => findHomepage(module.options))
|
|
88
|
+
.find(Boolean) ?? "/";
|
|
89
|
+
|
|
90
|
+
class BackendResponseError extends Error {
|
|
91
|
+
constructor(status) {
|
|
92
|
+
super(`DMS backend returned ${status}`);
|
|
93
|
+
this.status = status;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function developmentServer() {
|
|
98
|
+
if (process.env.DMS_DEV !== "true") return undefined;
|
|
99
|
+
if (vite) return vite;
|
|
100
|
+
vitePromise ??= import("vite")
|
|
101
|
+
.then(({ createServer: createViteServer }) => {
|
|
102
|
+
const serverOptions = { middlewareMode: true };
|
|
103
|
+
if (frontendHttpServer)
|
|
104
|
+
serverOptions.hmr = { server: frontendHttpServer };
|
|
105
|
+
return createViteServer({ server: serverOptions, appType: "custom" });
|
|
106
|
+
})
|
|
107
|
+
.then((server) => {
|
|
108
|
+
vite = server;
|
|
109
|
+
return server;
|
|
110
|
+
})
|
|
111
|
+
.catch((error) => {
|
|
112
|
+
vitePromise = undefined;
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
return vitePromise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function backendHeaders(request) {
|
|
119
|
+
const names = [
|
|
120
|
+
"accept",
|
|
121
|
+
"content-type",
|
|
122
|
+
"user-agent",
|
|
123
|
+
"x-content-language",
|
|
124
|
+
"x-realtime-session",
|
|
125
|
+
];
|
|
126
|
+
const headers = Object.fromEntries(
|
|
127
|
+
names.flatMap((name) =>
|
|
128
|
+
request.headers[name] ? [[name, request.headers[name]]] : [],
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const session = readSession(request);
|
|
132
|
+
if (session?.accessToken)
|
|
133
|
+
headers.authorization = `Bearer ${session.accessToken}`;
|
|
134
|
+
return headers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function serverComponentFetch(request) {
|
|
138
|
+
return (path, options = {}) => {
|
|
139
|
+
const headers = new Headers(backendHeaders(request));
|
|
140
|
+
new Headers(options.headers).forEach((value, name) => {
|
|
141
|
+
headers.set(name, value);
|
|
142
|
+
});
|
|
143
|
+
return ofetch(path, {
|
|
144
|
+
...options,
|
|
145
|
+
baseURL: process.env.DMS_BACKEND_URL,
|
|
146
|
+
headers,
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function backendJson(path, request) {
|
|
152
|
+
const response = await fetch(new URL(path, process.env.DMS_BACKEND_URL), {
|
|
153
|
+
headers: backendHeaders(request),
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new BackendResponseError(response.status);
|
|
157
|
+
}
|
|
158
|
+
return response.json();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function fetchPage(request, pathname) {
|
|
162
|
+
const query = new URLSearchParams({ path: pathname });
|
|
163
|
+
return backendJson(`/dms/page?${query}`, request);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function pageProps(request) {
|
|
167
|
+
const pathname = new URL(request.url, "http://frontend.local").pathname;
|
|
168
|
+
const session = publicSession(readSession(request));
|
|
169
|
+
try {
|
|
170
|
+
const page = await fetchPage(request, pathname);
|
|
171
|
+
return { path: pathname, page, ...session };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (!(error instanceof BackendResponseError) || error.status !== 404)
|
|
174
|
+
throw error;
|
|
175
|
+
const renderer = await ssrRenderer(await developmentServer());
|
|
176
|
+
if (!renderer.isDmsFrontendPage(pathname)) throw error;
|
|
177
|
+
return {
|
|
178
|
+
path: pathname,
|
|
179
|
+
...session,
|
|
180
|
+
page: {
|
|
181
|
+
componentName: pathname,
|
|
182
|
+
isFrontendOnly: true,
|
|
183
|
+
route: {
|
|
184
|
+
fullId: pathname.slice(1).replaceAll("/", "."),
|
|
185
|
+
fullSlug: pathname,
|
|
186
|
+
hasAccess: pathname.startsWith("/auth/"),
|
|
187
|
+
},
|
|
188
|
+
layout: { layout: { componentName: "DefaultLayout" } },
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writeProxyHeaders(response, upstream) {
|
|
195
|
+
const headers = Object.fromEntries(upstream.headers);
|
|
196
|
+
delete headers["set-cookie"];
|
|
197
|
+
const cookies = upstream.headers.getSetCookie?.() ?? [];
|
|
198
|
+
if (cookies.length) headers["set-cookie"] = cookies;
|
|
199
|
+
response.writeHead(upstream.status, headers);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isFrontendVisit(request) {
|
|
203
|
+
if (request.method !== "GET") return false;
|
|
204
|
+
return (
|
|
205
|
+
Boolean(request.headers[INERTIA_HEADER]) ||
|
|
206
|
+
request.headers.accept?.includes("text/html")
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function redirectToHomepage(request, response, pathname) {
|
|
211
|
+
if (pathname !== "/" || HOMEPAGE === "/") return false;
|
|
212
|
+
redirectFrontendVisit(request, response, HOMEPAGE);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function redirectSignedOutPrivatePage(request, response, props) {
|
|
217
|
+
const route = props.page?.route;
|
|
218
|
+
if (
|
|
219
|
+
route?.publicAccess === true ||
|
|
220
|
+
route?.hasAccess !== false ||
|
|
221
|
+
readSession(request)
|
|
222
|
+
)
|
|
223
|
+
return false;
|
|
224
|
+
const location = `${AUTH_PAGE}?redirect=${encodeURIComponent(request.url)}`;
|
|
225
|
+
redirectFrontendVisit(request, response, location);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function requestOwnership(method, pathname) {
|
|
230
|
+
if (PROXY_PREFIXES.some((prefix) => pathname.startsWith(prefix)))
|
|
231
|
+
return "server";
|
|
232
|
+
if (
|
|
233
|
+
AUTH_SERVER_ROUTES.some(
|
|
234
|
+
([routeMethod, pattern]) =>
|
|
235
|
+
routeMethod === method && pattern.test(pathname),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return "server";
|
|
239
|
+
return method === "GET" ? "frontend" : "server";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function proxy(request, response, fallbackOnNotFound = false) {
|
|
243
|
+
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
|
|
244
|
+
response.writeHead(403, { "content-type": JSON_TYPE });
|
|
245
|
+
response.end(JSON.stringify({ error: "Forbidden" }));
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
const target = new URL(request.url, process.env.DMS_BACKEND_URL);
|
|
249
|
+
const upstream = await fetch(target, {
|
|
250
|
+
method: request.method,
|
|
251
|
+
headers: backendHeaders(request),
|
|
252
|
+
body: ["GET", "HEAD"].includes(request.method) ? undefined : request,
|
|
253
|
+
duplex: "half",
|
|
254
|
+
});
|
|
255
|
+
if (fallbackOnNotFound && upstream.status === 404) {
|
|
256
|
+
await upstream.body?.cancel();
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
writeProxyHeaders(response, upstream);
|
|
260
|
+
if (!upstream.body || request.method === "HEAD") response.end();
|
|
261
|
+
else await pipeline(Readable.fromWeb(upstream.body), response);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function acceptedAsset(pathname, request) {
|
|
266
|
+
const file = resolve(PROJECT_ROOT, "dist/client", `.${pathname}`);
|
|
267
|
+
const accepted = request.headers["accept-encoding"] ?? "";
|
|
268
|
+
if (accepted.includes("br") && existsSync(`${file}.br`))
|
|
269
|
+
return { file: `${file}.br`, encoding: "br", source: file };
|
|
270
|
+
if (accepted.includes("gzip") && existsSync(`${file}.gz`))
|
|
271
|
+
return { file: `${file}.gz`, encoding: "gzip", source: file };
|
|
272
|
+
return { file, encoding: undefined, source: file };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function serveAsset(pathname, request, response) {
|
|
276
|
+
const selected = acceptedAsset(pathname, request);
|
|
277
|
+
const assetRoot = `${resolve(PROJECT_ROOT, "dist/client")}/`;
|
|
278
|
+
if (
|
|
279
|
+
!selected.source.startsWith(assetRoot) ||
|
|
280
|
+
!existsSync(selected.file) ||
|
|
281
|
+
!statSync(selected.file).isFile()
|
|
282
|
+
)
|
|
283
|
+
return false;
|
|
284
|
+
const headers = {
|
|
285
|
+
"cache-control": pathname.startsWith("/assets/")
|
|
286
|
+
? "public, max-age=31536000, immutable"
|
|
287
|
+
: "no-cache",
|
|
288
|
+
"content-type":
|
|
289
|
+
MIME_TYPES[extname(selected.source)] ?? "application/octet-stream",
|
|
290
|
+
"content-length": statSync(selected.file).size,
|
|
291
|
+
vary: "Accept-Encoding",
|
|
292
|
+
};
|
|
293
|
+
if (selected.encoding) headers["content-encoding"] = selected.encoding;
|
|
294
|
+
response.writeHead(200, headers);
|
|
295
|
+
createReadStream(selected.file).pipe(response);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function responseEncoder(request, content) {
|
|
300
|
+
if (Buffer.byteLength(content) < MINIMUM_COMPRESSION_BYTES) return undefined;
|
|
301
|
+
const accepted = request.headers["accept-encoding"] ?? "";
|
|
302
|
+
if (accepted.includes("br"))
|
|
303
|
+
return {
|
|
304
|
+
encoding: "br",
|
|
305
|
+
stream: createBrotliCompress({
|
|
306
|
+
params: {
|
|
307
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: DYNAMIC_BROTLI_QUALITY,
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
if (accepted.includes("gzip"))
|
|
312
|
+
return { encoding: "gzip", stream: createGzip() };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function writeContent(request, response, status, headers, content) {
|
|
316
|
+
const encoder = responseEncoder(request, content);
|
|
317
|
+
const vary = [headers.vary, "Accept-Encoding"].filter(Boolean).join(", ");
|
|
318
|
+
const encodedHeaders = { ...headers, vary };
|
|
319
|
+
if (!encoder) {
|
|
320
|
+
response.writeHead(status, encodedHeaders);
|
|
321
|
+
response.end(content);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
encodedHeaders["content-encoding"] = encoder.encoding;
|
|
325
|
+
response.writeHead(status, encodedHeaders);
|
|
326
|
+
await pipeline(Readable.from([content]), encoder.stream, response);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function ssrRenderer(devServer) {
|
|
330
|
+
if (devServer) return devServer.ssrLoadModule("/ssr-renderer.ts");
|
|
331
|
+
productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH);
|
|
332
|
+
return productionSsrRenderer;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function renderHtml(page, requestUrl, serverFetch) {
|
|
336
|
+
const devServer = await developmentServer();
|
|
337
|
+
const rawTemplate = devServer
|
|
338
|
+
? readFileSync(SOURCE_TEMPLATE_PATH, "utf8")
|
|
339
|
+
: productionHtmlTemplate();
|
|
340
|
+
const template = devServer
|
|
341
|
+
? await devServer.transformIndexHtml(requestUrl, rawTemplate)
|
|
342
|
+
: rawTemplate;
|
|
343
|
+
let rendered;
|
|
344
|
+
try {
|
|
345
|
+
const renderer = await ssrRenderer(devServer);
|
|
346
|
+
rendered = await renderer.renderDmsPage(page, serverFetch);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(
|
|
349
|
+
"DMS SSR render failed; falling back to client rendering",
|
|
350
|
+
error,
|
|
351
|
+
);
|
|
352
|
+
rendered = {
|
|
353
|
+
body: inertiaAppHtml(page),
|
|
354
|
+
head: { headTags: "", htmlAttrs: "", bodyAttrs: "" },
|
|
355
|
+
overlays: "",
|
|
356
|
+
error: { statusCode: 500 },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (rendered.redirect)
|
|
360
|
+
return { html: "", status: 200, redirect: rendered.redirect };
|
|
361
|
+
const preloads = devServer ? "" : pageModulePreloads(page, template);
|
|
362
|
+
const html = template
|
|
363
|
+
.replace("<title>Antelope DMS</title>", rendered.head.headTags)
|
|
364
|
+
.replace("<html", `<html ${rendered.head.htmlAttrs}`)
|
|
365
|
+
.replace("<body", `<body ${rendered.head.bodyAttrs}`)
|
|
366
|
+
.replace("</head>", `${preloads}</head>`)
|
|
367
|
+
.replace(
|
|
368
|
+
'<div id="dms-overlays" class="isolate"></div>',
|
|
369
|
+
`<div id="dms-overlays" class="isolate">${rendered.overlays}</div>`,
|
|
370
|
+
)
|
|
371
|
+
.replace("__DMS_APP__", () => rendered.body);
|
|
372
|
+
return { html, status: rendered.error?.statusCode ?? 200 };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function writeBackendError(error, request, response) {
|
|
376
|
+
const status = Number.isInteger(error?.status) ? error.status : 502;
|
|
377
|
+
const pathname = new URL(request.url, "http://frontend.local").pathname;
|
|
378
|
+
if (
|
|
379
|
+
error instanceof BackendResponseError &&
|
|
380
|
+
[401, 403].includes(status) &&
|
|
381
|
+
!readSession(request) &&
|
|
382
|
+
!pathname.startsWith(AUTH_PAGE) &&
|
|
383
|
+
pathname !== ONBOARDING_PAGE
|
|
384
|
+
) {
|
|
385
|
+
const location = `${AUTH_PAGE}?redirect=${encodeURIComponent(request.url)}`;
|
|
386
|
+
redirectFrontendVisit(request, response, location);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.error("DMS backend request failed", error);
|
|
390
|
+
if (isFrontendVisit(request)) {
|
|
391
|
+
const statusMessage = "DMS backend request failed";
|
|
392
|
+
const props = {
|
|
393
|
+
path: pathname,
|
|
394
|
+
page: {},
|
|
395
|
+
...publicSession(readSession(request)),
|
|
396
|
+
error: { statusCode: status, statusMessage, message: statusMessage },
|
|
397
|
+
};
|
|
398
|
+
const page = createInertiaPage(request.url, props);
|
|
399
|
+
if (request.headers[INERTIA_HEADER]) {
|
|
400
|
+
return writeContent(
|
|
401
|
+
request,
|
|
402
|
+
response,
|
|
403
|
+
status,
|
|
404
|
+
inertiaHeaders(page.version),
|
|
405
|
+
JSON.stringify(page),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const { html } = await renderHtml(
|
|
409
|
+
page,
|
|
410
|
+
request.url,
|
|
411
|
+
serverComponentFetch(request),
|
|
412
|
+
);
|
|
413
|
+
return writeContent(
|
|
414
|
+
request,
|
|
415
|
+
response,
|
|
416
|
+
status,
|
|
417
|
+
{ "content-type": "text/html", vary: "X-Inertia" },
|
|
418
|
+
html,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const payload =
|
|
422
|
+
error instanceof UpstreamError || error instanceof RequestBodyError
|
|
423
|
+
? { message: error.message }
|
|
424
|
+
: { error: "DMS backend request failed" };
|
|
425
|
+
response.writeHead(status, { "content-type": JSON_TYPE });
|
|
426
|
+
response.end(JSON.stringify(payload));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function handleRequest(request, response) {
|
|
430
|
+
const url = new URL(request.url, "http://frontend.local");
|
|
431
|
+
const pathname = url.pathname;
|
|
432
|
+
if (TESTER_ROUTE.test(pathname)) return handleTester(request, response);
|
|
433
|
+
if (pathname === "/api/_auth/session")
|
|
434
|
+
return handleAuth(request, response, url);
|
|
435
|
+
if (request.method === "POST" && pathname === HTML_RENDER_ROUTE)
|
|
436
|
+
return handleEmailRender(request, response);
|
|
437
|
+
if (
|
|
438
|
+
AUTH_SERVER_ROUTES.some(
|
|
439
|
+
([method, pattern]) =>
|
|
440
|
+
method === request.method && pattern.test(pathname),
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
return handleAuth(request, response, url);
|
|
444
|
+
if (requestOwnership(request.method, pathname) === "server")
|
|
445
|
+
return proxy(request, response);
|
|
446
|
+
if (!isFrontendVisit(request) && (await proxy(request, response, true)))
|
|
447
|
+
return;
|
|
448
|
+
const devServer = await developmentServer();
|
|
449
|
+
if (devServer && pathname !== "/") {
|
|
450
|
+
const handled = await new Promise((resolve) => {
|
|
451
|
+
devServer.middlewares(request, response, () => resolve(false));
|
|
452
|
+
response.once("finish", () => resolve(true));
|
|
453
|
+
});
|
|
454
|
+
if (handled) return;
|
|
455
|
+
}
|
|
456
|
+
if (!devServer && serveAsset(pathname, request, response)) return;
|
|
457
|
+
if (handleAssetVersionMismatch(request, response)) return;
|
|
458
|
+
if (redirectToHomepage(request, response, pathname)) return;
|
|
459
|
+
const props = await pageProps(request);
|
|
460
|
+
if (redirectSignedOutPrivatePage(request, response, props)) return;
|
|
461
|
+
const page = createInertiaPage(request.url, props);
|
|
462
|
+
if (request.headers[INERTIA_HEADER]) {
|
|
463
|
+
return writeContent(
|
|
464
|
+
request,
|
|
465
|
+
response,
|
|
466
|
+
200,
|
|
467
|
+
inertiaHeaders(page.version),
|
|
468
|
+
JSON.stringify(page),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const rendered = await renderHtml(
|
|
472
|
+
page,
|
|
473
|
+
request.url,
|
|
474
|
+
serverComponentFetch(request),
|
|
475
|
+
);
|
|
476
|
+
if (rendered.redirect) {
|
|
477
|
+
redirectFrontendVisit(request, response, rendered.redirect);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
return writeContent(
|
|
481
|
+
request,
|
|
482
|
+
response,
|
|
483
|
+
rendered.status,
|
|
484
|
+
{ "content-type": "text/html", vary: "X-Inertia" },
|
|
485
|
+
rendered.html,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function handleRequestSafely(request, response) {
|
|
490
|
+
try {
|
|
491
|
+
await handleRequest(request, response);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (
|
|
494
|
+
error?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
495
|
+
response.destroyed ||
|
|
496
|
+
response.writableEnded ||
|
|
497
|
+
response.headersSent
|
|
498
|
+
)
|
|
499
|
+
return;
|
|
500
|
+
if (
|
|
501
|
+
error instanceof BackendResponseError &&
|
|
502
|
+
error.status === 401 &&
|
|
503
|
+
request.method === "GET" &&
|
|
504
|
+
readSession(request) &&
|
|
505
|
+
!request.dmsSessionRetry
|
|
506
|
+
) {
|
|
507
|
+
request.dmsSessionRetry = true;
|
|
508
|
+
if (await refreshSession(request, response)) {
|
|
509
|
+
await handleRequestSafely(request, response);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
await writeBackendError(error, request, response);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
process.argv[1] &&
|
|
519
|
+
import.meta.url === new URL(`file://${process.argv[1]}`).href
|
|
520
|
+
) {
|
|
521
|
+
frontendHttpServer = createServer(handleRequestSafely);
|
|
522
|
+
frontendHttpServer.listen(
|
|
523
|
+
Number(process.env.PORT ?? 3001),
|
|
524
|
+
process.env.HOST ?? "0.0.0.0",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createInertiaApp } from "@inertiajs/vue3";
|
|
3
|
+
import { createHead, renderSSRHead } from "@unhead/vue/server";
|
|
4
|
+
import { renderToString } from "@vue/server-renderer";
|
|
5
|
+
import { createSSRApp, h } from "vue";
|
|
6
|
+
import {
|
|
7
|
+
type DmsServerFetch,
|
|
8
|
+
configureDmsApp,
|
|
9
|
+
resolveDmsInertiaPage,
|
|
10
|
+
serializeDmsAsyncData,
|
|
11
|
+
} from "./app-runtime";
|
|
12
|
+
import {
|
|
13
|
+
type DmsErrorData,
|
|
14
|
+
type DmsFrontendRuntime,
|
|
15
|
+
type DmsPageProps,
|
|
16
|
+
createDmsFrontendRuntime,
|
|
17
|
+
hasDmsPage,
|
|
18
|
+
preloadDmsPage,
|
|
19
|
+
setDmsServerRuntimeResolver,
|
|
20
|
+
setupFrontendModules,
|
|
21
|
+
} from "./frontend-module";
|
|
22
|
+
import { frontendModules } from "./frontend-modules.generated";
|
|
23
|
+
|
|
24
|
+
export interface DmsSsrPage {
|
|
25
|
+
component: string;
|
|
26
|
+
props: DmsPageProps;
|
|
27
|
+
url: string;
|
|
28
|
+
version: string;
|
|
29
|
+
clearHistory?: boolean;
|
|
30
|
+
encryptHistory?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DmsSsrResult {
|
|
34
|
+
body: string;
|
|
35
|
+
head: DmsSsrHead;
|
|
36
|
+
overlays: string;
|
|
37
|
+
error?: DmsErrorData;
|
|
38
|
+
redirect?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DmsSsrHead {
|
|
42
|
+
bodyAttrs: string;
|
|
43
|
+
headTags: string;
|
|
44
|
+
htmlAttrs: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DmsSsrContext {
|
|
48
|
+
teleports?: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SSR_ASYNC_DATA_ID = "dms-ssr-async-data";
|
|
52
|
+
const runtimeStorage = new AsyncLocalStorage<DmsFrontendRuntime>();
|
|
53
|
+
setDmsServerRuntimeResolver(() => runtimeStorage.getStore());
|
|
54
|
+
|
|
55
|
+
function asyncDataScript(runtime: DmsFrontendRuntime): string {
|
|
56
|
+
const value = JSON.stringify(serializeDmsAsyncData(runtime)).replaceAll(
|
|
57
|
+
"<",
|
|
58
|
+
"\\u003c",
|
|
59
|
+
);
|
|
60
|
+
return `<script id="${SSR_ASYNC_DATA_ID}" type="application/json">${value}</script>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await setupFrontendModules(frontendModules);
|
|
64
|
+
|
|
65
|
+
export function isDmsFrontendPage(path: string): boolean {
|
|
66
|
+
return hasDmsPage(path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function renderDmsPage(
|
|
70
|
+
page: DmsSsrPage,
|
|
71
|
+
serverFetch?: DmsServerFetch,
|
|
72
|
+
): Promise<DmsSsrResult> {
|
|
73
|
+
const runtime = createDmsFrontendRuntime(
|
|
74
|
+
serverFetch as typeof import("ofetch").ofetch,
|
|
75
|
+
{},
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
return runtimeStorage.run(runtime, () =>
|
|
79
|
+
renderDmsPageWithRuntime(page, runtime, serverFetch),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function renderDmsPageWithRuntime(
|
|
84
|
+
page: DmsSsrPage,
|
|
85
|
+
runtime: DmsFrontendRuntime,
|
|
86
|
+
serverFetch?: DmsServerFetch,
|
|
87
|
+
): Promise<DmsSsrResult> {
|
|
88
|
+
await preloadDmsPage(page.props);
|
|
89
|
+
const head = createHead();
|
|
90
|
+
const ssrContext: DmsSsrContext = {};
|
|
91
|
+
const inertiaResult = await createInertiaApp({
|
|
92
|
+
page,
|
|
93
|
+
render: (app) => renderToString(app, ssrContext),
|
|
94
|
+
resolve: resolveDmsInertiaPage,
|
|
95
|
+
async setup({ App, props, plugin }) {
|
|
96
|
+
const app = createSSRApp({ render: () => h(App, props) });
|
|
97
|
+
const configured = await configureDmsApp({
|
|
98
|
+
app,
|
|
99
|
+
head,
|
|
100
|
+
initialPageProps: page.props,
|
|
101
|
+
initialPageUrl: page.url,
|
|
102
|
+
inertiaPlugin: plugin,
|
|
103
|
+
runtime,
|
|
104
|
+
serverFetch,
|
|
105
|
+
});
|
|
106
|
+
return app;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
if (!inertiaResult) throw new Error("Inertia SSR returned no result");
|
|
110
|
+
await runtime.pendingNavigation;
|
|
111
|
+
if (runtime.serverRedirect) {
|
|
112
|
+
return {
|
|
113
|
+
body: "",
|
|
114
|
+
head: { bodyAttrs: "", headTags: "", htmlAttrs: "" },
|
|
115
|
+
overlays: "",
|
|
116
|
+
redirect: runtime.serverRedirect,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (runtime.currentError.value && !page.props.error) {
|
|
120
|
+
const failure = runtime.currentError.value as Error & DmsErrorData;
|
|
121
|
+
const expected =
|
|
122
|
+
Number.isInteger(failure.statusCode) &&
|
|
123
|
+
failure.statusCode! >= 400 &&
|
|
124
|
+
failure.statusCode! <= 599;
|
|
125
|
+
const error = {
|
|
126
|
+
statusCode: expected ? failure.statusCode : 500,
|
|
127
|
+
message: expected ? failure.message : "An unexpected error occurred",
|
|
128
|
+
statusMessage: expected ? failure.statusMessage : "Application error",
|
|
129
|
+
};
|
|
130
|
+
return renderDmsPage(
|
|
131
|
+
{ ...page, props: { ...page.props, error } },
|
|
132
|
+
serverFetch,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const renderedHead = await renderSSRHead(head);
|
|
136
|
+
return {
|
|
137
|
+
error: page.props.error,
|
|
138
|
+
body: `${inertiaResult.body}${asyncDataScript(runtime)}`,
|
|
139
|
+
head: {
|
|
140
|
+
bodyAttrs: renderedHead.bodyAttrs,
|
|
141
|
+
headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
|
|
142
|
+
htmlAttrs: renderedHead.htmlAttrs,
|
|
143
|
+
},
|
|
144
|
+
overlays: ssrContext.teleports?.["#dms-overlays"] ?? "",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./frontend-paths.generated.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"types": ["vite/client"]
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"globals.d.ts",
|
|
18
|
+
"auto-imports.d.ts",
|
|
19
|
+
"frontend-modules.generated.ts"
|
|
20
|
+
],
|
|
21
|
+
"include": ["frontend-modules/**/*.ts", "frontend-modules/**/*.vue"],
|
|
22
|
+
"exclude": [
|
|
23
|
+
"**/node_modules",
|
|
24
|
+
"**/dist",
|
|
25
|
+
"**/tests",
|
|
26
|
+
"**/*.test.ts",
|
|
27
|
+
"**/*.spec.ts",
|
|
28
|
+
"**/vitest.config.ts",
|
|
29
|
+
"**/eslint.config.*"
|
|
30
|
+
]
|
|
31
|
+
}
|