@antelopejs/dms-frontend 0.3.0 → 0.3.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 +24 -0
- package/dist/commands/dev.js +4 -1
- package/dist/commands/start.js +3 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.js +1 -0
- package/dist/layer-watch.js +42 -2
- package/dist/manifest-secrets.d.ts +31 -0
- package/dist/manifest-secrets.js +101 -0
- package/dist/materialize.d.ts +2 -0
- package/dist/materialize.js +102 -10
- package/dist/ports.js +1 -1
- package/dist/workspace-setup.d.ts +2 -0
- package/dist/workspace-setup.js +2 -0
- package/package.json +1 -1
- package/templates/vue/app-runtime.ts +27 -2
- package/templates/vue/frontend-module.ts +56 -5
- package/templates/vue/gitignore +9 -0
- package/templates/vue/network-error.ts +70 -0
- package/templates/vue/server/auth/backend.mjs +9 -2
- package/templates/vue/server/backend-response.mjs +85 -0
- package/templates/vue/server/email-bundle-watch.mjs +31 -0
- package/templates/vue/server/email.mjs +76 -1
- package/templates/vue/server/inertia.mjs +9 -0
- package/templates/vue/server/request-failure.mjs +54 -0
- package/templates/vue/server.mjs +47 -41
- package/templates/vue/ssr-renderer.ts +93 -61
- package/templates/vue/vite.email.config.ts +5 -1
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
type ComputedRef,
|
|
18
18
|
computed,
|
|
19
19
|
defineComponent,
|
|
20
|
+
type EffectScope,
|
|
21
|
+
effectScope,
|
|
20
22
|
getCurrentInstance,
|
|
21
23
|
getCurrentScope,
|
|
22
24
|
h,
|
|
@@ -249,6 +251,12 @@ export interface DmsFrontendSdk {
|
|
|
249
251
|
handler: DmsMiddleware,
|
|
250
252
|
options?: DmsMiddlewareRegistrationOptions,
|
|
251
253
|
): void;
|
|
254
|
+
/**
|
|
255
|
+
* Sends a page visit the backend refuses with a typed 403 (its body is
|
|
256
|
+
* `code`) to `path` instead of the error page. The server answers with the
|
|
257
|
+
* redirect before anything renders, so a reload lands on `path` directly.
|
|
258
|
+
*/
|
|
259
|
+
registerAccessRedirect(code: string, path: string): void;
|
|
252
260
|
provide<T>(key: string | InjectionKey<T>, value: T): void;
|
|
253
261
|
use(plugin: Plugin): void;
|
|
254
262
|
}
|
|
@@ -309,6 +317,11 @@ export interface DmsFrontendRuntime {
|
|
|
309
317
|
pageVersion: number;
|
|
310
318
|
/** The `Cookie` header of the request a server runtime renders. */
|
|
311
319
|
requestCookies?: string;
|
|
320
|
+
/**
|
|
321
|
+
* Owns the effects plugins create while they set up. A server render stops
|
|
322
|
+
* it once done: nothing else would, since a server app is never unmounted.
|
|
323
|
+
*/
|
|
324
|
+
scope: EffectScope;
|
|
312
325
|
}
|
|
313
326
|
|
|
314
327
|
const components = new Map<string, DmsComponentRegistration>();
|
|
@@ -320,6 +333,7 @@ const plugins: Plugin[] = [];
|
|
|
320
333
|
const pluginSetups: DmsPluginRegistration[] = [];
|
|
321
334
|
const middleware: DmsMiddleware[] = [];
|
|
322
335
|
const namedMiddleware = new Map<string, DmsMiddleware>();
|
|
336
|
+
const accessRedirects = new Map<string, string>();
|
|
323
337
|
const injections = new Map<string | symbol, unknown>();
|
|
324
338
|
const runtimeConfig = ref<DmsRuntimeConfig>({
|
|
325
339
|
public: {},
|
|
@@ -358,6 +372,7 @@ export function createDmsFrontendRuntime(
|
|
|
358
372
|
}),
|
|
359
373
|
hasNavigationListener: false,
|
|
360
374
|
pageVersion: 0,
|
|
375
|
+
scope: effectScope(true),
|
|
361
376
|
};
|
|
362
377
|
}
|
|
363
378
|
|
|
@@ -539,10 +554,14 @@ export function useDmsRoute(pattern?: string): DmsRoute {
|
|
|
539
554
|
runtime.currentRoutePattern = pattern;
|
|
540
555
|
const page = usePage();
|
|
541
556
|
updateRoute(runtime, page.url);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
)
|
|
557
|
+
// A server render never changes its URL, and Inertia's page is shared by
|
|
558
|
+
// every render the process runs: a watcher there would only tie this
|
|
559
|
+
// request's runtime to all the requests after it.
|
|
560
|
+
if (!runtime.isServer)
|
|
561
|
+
watch(
|
|
562
|
+
() => page.url,
|
|
563
|
+
(url) => updateRoute(runtime, url),
|
|
564
|
+
);
|
|
546
565
|
return runtime.route;
|
|
547
566
|
}
|
|
548
567
|
|
|
@@ -1172,6 +1191,29 @@ export function trackDmsAsyncComponents(app: App): Set<string> {
|
|
|
1172
1191
|
return rendered;
|
|
1173
1192
|
}
|
|
1174
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* Records the scope of every component a server render creates, and returns
|
|
1196
|
+
* the function that stops them once the render is over.
|
|
1197
|
+
*
|
|
1198
|
+
* A server app is never mounted, so it is never unmounted either: Vue leaves
|
|
1199
|
+
* the scope of each component it rendered running, and nothing handed to
|
|
1200
|
+
* `onScopeDispose` is ever released. Nuxt UI's `useRuntimeHook` registers in
|
|
1201
|
+
* a process-wide hook registry and only unregisters there, so every server
|
|
1202
|
+
* render of the dashboard sidebar stayed reachable, its whole component tree
|
|
1203
|
+
* and page props with it, until the server ran out of heap.
|
|
1204
|
+
*/
|
|
1205
|
+
export function trackDmsServerScopes(app: App): () => void {
|
|
1206
|
+
const scopes: EffectScope[] = [];
|
|
1207
|
+
app.mixin({
|
|
1208
|
+
beforeCreate(this: ComponentPublicInstance) {
|
|
1209
|
+
scopes.push(this.$.scope);
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
return () => {
|
|
1213
|
+
for (const scope of scopes.splice(0)) scope.stop();
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1175
1217
|
export async function resolveDmsAsyncComponents(
|
|
1176
1218
|
names: readonly string[],
|
|
1177
1219
|
): Promise<void> {
|
|
@@ -1252,6 +1294,10 @@ function findDmsPageEntry(props: DmsPageProps): DmsFrontendEntry | undefined {
|
|
|
1252
1294
|
: dynamicPages.get(CATCH_ALL_PAGE_KEY);
|
|
1253
1295
|
}
|
|
1254
1296
|
|
|
1297
|
+
/** The path a frontend module registered for a typed backend refusal. */
|
|
1298
|
+
export function resolveDmsAccessRedirect(code: string): string | undefined {
|
|
1299
|
+
return accessRedirects.get(code);
|
|
1300
|
+
}
|
|
1255
1301
|
export function hasDmsPage(name: string): boolean {
|
|
1256
1302
|
return pages.has(normalizeDmsPageKey(name));
|
|
1257
1303
|
}
|
|
@@ -1374,6 +1420,9 @@ function createSdk(options: DmsModuleOptions): DmsFrontendSdk {
|
|
|
1374
1420
|
if (!namedMiddleware.has(name)) namedMiddleware.set(name, handler);
|
|
1375
1421
|
if (options.global) middleware.push(handler);
|
|
1376
1422
|
},
|
|
1423
|
+
registerAccessRedirect: (code, path) => {
|
|
1424
|
+
if (!accessRedirects.has(code)) accessRedirects.set(code, path);
|
|
1425
|
+
},
|
|
1377
1426
|
provide: (key, value) => {
|
|
1378
1427
|
if (!injections.has(key)) injections.set(key, value);
|
|
1379
1428
|
},
|
|
@@ -1459,7 +1508,9 @@ export async function installDmsPlugins(
|
|
|
1459
1508
|
});
|
|
1460
1509
|
for (const registration of pluginSetups) {
|
|
1461
1510
|
if (registration.clientOnly && typeof window === "undefined") continue;
|
|
1462
|
-
await
|
|
1511
|
+
await runtime.scope.run(() =>
|
|
1512
|
+
app.runWithContext(() => registration.setup(appContext)),
|
|
1513
|
+
);
|
|
1463
1514
|
}
|
|
1464
1515
|
return async () => {
|
|
1465
1516
|
for (const callback of hooks.get("app:mounted") ?? []) await callback();
|
package/templates/vue/gitignore
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
# Translation catalogs are data, not markup, and Tailwind's automatic source
|
|
2
|
+
# detection honors this file. A catalog it scans reloads the whole page when it
|
|
3
|
+
# changes, where the dev server would otherwise apply the edited translations
|
|
4
|
+
# in place.
|
|
5
|
+
locales.generated/
|
|
6
|
+
frontend-modules/**/i18n/locales/
|
|
1
7
|
# Rewritten by the auto-import plugins while the dev server discovers new
|
|
2
8
|
# components and composables. Tailwind's automatic source detection honors this
|
|
3
9
|
# file: without it, every rewrite invalidates the stylesheets and triggers a
|
|
4
10
|
# CSS hot update on plain navigation.
|
|
5
11
|
auto-imports.d.ts
|
|
6
12
|
components.d.ts
|
|
13
|
+
# Build output, which the email bundle watcher rewrites during development:
|
|
14
|
+
# read as Tailwind sources, each rebuild would push a burst of CSS updates.
|
|
15
|
+
dist/
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { router } from "@inertiajs/vue3";
|
|
2
|
+
|
|
3
|
+
export interface DmsNetworkErrorToast {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
color: "error";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DmsNetworkErrorTranslator {
|
|
11
|
+
locale: { value: string };
|
|
12
|
+
t: (key: string) => string;
|
|
13
|
+
te: (key: string) => boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NETWORK_ERROR_TOAST_ID = "dms-network-error";
|
|
17
|
+
const NETWORK_ERROR_KEY = "error.network";
|
|
18
|
+
const DEFAULT_LOCALE = "en";
|
|
19
|
+
// Shipped with the renderer, since no module is guaranteed to translate the
|
|
20
|
+
// case; a module overrides them with `error.network.title` and
|
|
21
|
+
// `error.network.description` in its own locale files.
|
|
22
|
+
const NETWORK_ERROR_MESSAGES: Record<
|
|
23
|
+
string,
|
|
24
|
+
Record<"title" | "description", string>
|
|
25
|
+
> = {
|
|
26
|
+
en: {
|
|
27
|
+
title: "Connection lost",
|
|
28
|
+
description:
|
|
29
|
+
"The server could not be reached. Check your connection and try again.",
|
|
30
|
+
},
|
|
31
|
+
fr: {
|
|
32
|
+
title: "Connexion perdue",
|
|
33
|
+
description:
|
|
34
|
+
"Le serveur est injoignable. Vérifiez votre connexion et réessayez.",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function networkErrorMessage(
|
|
39
|
+
i18n: DmsNetworkErrorTranslator,
|
|
40
|
+
name: "title" | "description",
|
|
41
|
+
): string {
|
|
42
|
+
const key = `${NETWORK_ERROR_KEY}.${name}`;
|
|
43
|
+
if (i18n.te(key)) return i18n.t(key);
|
|
44
|
+
const locale = i18n.locale.value.slice(0, 2);
|
|
45
|
+
return (NETWORK_ERROR_MESSAGES[locale] ??
|
|
46
|
+
NETWORK_ERROR_MESSAGES[DEFAULT_LOCALE])[name];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tell the visitor when an Inertia visit never reached the server, instead of
|
|
51
|
+
* leaving the click without effect and the failure as an uncaught rejection.
|
|
52
|
+
* The current page stays as it was, so they can retry once back online.
|
|
53
|
+
*
|
|
54
|
+
* @returns A function removing the listener
|
|
55
|
+
*/
|
|
56
|
+
export function showNetworkErrors(
|
|
57
|
+
notify: (toast: DmsNetworkErrorToast) => void,
|
|
58
|
+
i18n: DmsNetworkErrorTranslator,
|
|
59
|
+
): () => void {
|
|
60
|
+
return router.on("networkError", (event) => {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
notify({
|
|
63
|
+
// One toast however many visits fail while the server is away.
|
|
64
|
+
id: NETWORK_ERROR_TOAST_ID,
|
|
65
|
+
title: networkErrorMessage(i18n, "title"),
|
|
66
|
+
description: networkErrorMessage(i18n, "description"),
|
|
67
|
+
color: "error",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -15,6 +15,9 @@ export class UpstreamError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const PUBLIC_AUTH_MESSAGE = /^error\.[a-z0-9_.-]{1,100}$/;
|
|
18
|
+
// The API error contract's generic key: the technical cause of a failure the
|
|
19
|
+
// backend did not describe stays in the server log.
|
|
20
|
+
export const GENERIC_ERROR_MESSAGE = "error.500.description";
|
|
18
21
|
|
|
19
22
|
export function publicBackendMessage(data) {
|
|
20
23
|
const message = typeof data === "string" ? data : data?.message;
|
|
@@ -47,8 +50,12 @@ export async function backend(path, request, options = {}) {
|
|
|
47
50
|
data = response.ok ? {} : text;
|
|
48
51
|
}
|
|
49
52
|
if (!response.ok) {
|
|
50
|
-
const message = publicBackendMessage(data)
|
|
51
|
-
|
|
53
|
+
const message = publicBackendMessage(data);
|
|
54
|
+
if (!message)
|
|
55
|
+
console.error(
|
|
56
|
+
`DMS backend request failed: ${response.status} ${options.method ?? "GET"} ${path}`,
|
|
57
|
+
);
|
|
58
|
+
throw new UpstreamError(response.status, message ?? GENERIC_ERROR_MESSAGE);
|
|
52
59
|
}
|
|
53
60
|
return data;
|
|
54
61
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Reading a failed DMS backend response for the page a visitor gets instead.
|
|
2
|
+
//
|
|
3
|
+
// Lives apart from server.mjs so the file stays under the size the linter
|
|
4
|
+
// allows.
|
|
5
|
+
|
|
6
|
+
import { GENERIC_ERROR_MESSAGE } from "./auth/backend.mjs";
|
|
7
|
+
import { isFrontendVisit, redirectFrontendVisit } from "./inertia.mjs";
|
|
8
|
+
|
|
9
|
+
// What a JSON caller gets when the backend fails: the generic i18n key of the
|
|
10
|
+
// API error contract. A page gets the status code alone, which the DMS error
|
|
11
|
+
// page describes in the visitor's language; the technical cause only reaches
|
|
12
|
+
// the server log.
|
|
13
|
+
export const UNEXPECTED_ERROR_BODY = { message: GENERIC_ERROR_MESSAGE };
|
|
14
|
+
// A typed refusal carries a machine-readable i18n key as its body, bare or
|
|
15
|
+
// JSON-encoded, e.g. `saas.errors.workspace.access_blocked`.
|
|
16
|
+
const REFUSAL_CODE = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/i;
|
|
17
|
+
const REFUSAL_CODE_MAX_LENGTH = 200;
|
|
18
|
+
const FORBIDDEN = 403;
|
|
19
|
+
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
20
|
+
|
|
21
|
+
export class BackendResponseError extends Error {
|
|
22
|
+
constructor(status, code) {
|
|
23
|
+
super(`DMS backend returned ${status}`);
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function refusalCode(response) {
|
|
30
|
+
const text = (await response.text().catch(() => "")).trim();
|
|
31
|
+
if (text.length > REFUSAL_CODE_MAX_LENGTH) return undefined;
|
|
32
|
+
let value = text;
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(text);
|
|
35
|
+
value = typeof data === "string" ? data : data?.message;
|
|
36
|
+
} catch {
|
|
37
|
+
// A bare code.
|
|
38
|
+
}
|
|
39
|
+
return typeof value === "string" && REFUSAL_CODE.test(value)
|
|
40
|
+
? value
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The error for a failed backend response, with its typed code if any. */
|
|
45
|
+
export async function backendResponseError(response) {
|
|
46
|
+
return new BackendResponseError(response.status, await refusalCode(response));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Redirect a page visit the backend refused with a typed 403 (a suspended
|
|
51
|
+
* workspace, say) to the path a frontend module registered for its code
|
|
52
|
+
* through the SDK's `registerAccessRedirect`, looked up in the SSR renderer
|
|
53
|
+
* `loadRenderer` resolves. Only a same-origin path other than the refused one
|
|
54
|
+
* qualifies: a refused destination sent to itself again would loop.
|
|
55
|
+
*
|
|
56
|
+
* @returns Whether the visit was redirected
|
|
57
|
+
*/
|
|
58
|
+
export async function redirectAccessRefusal(
|
|
59
|
+
error,
|
|
60
|
+
request,
|
|
61
|
+
response,
|
|
62
|
+
loadRenderer,
|
|
63
|
+
) {
|
|
64
|
+
if (
|
|
65
|
+
!isFrontendVisit(request) ||
|
|
66
|
+
!(error instanceof BackendResponseError) ||
|
|
67
|
+
error.status !== FORBIDDEN ||
|
|
68
|
+
!error.code
|
|
69
|
+
)
|
|
70
|
+
return false;
|
|
71
|
+
let location;
|
|
72
|
+
try {
|
|
73
|
+
location = (await loadRenderer()).accessRedirect?.(error.code);
|
|
74
|
+
} catch (rendererError) {
|
|
75
|
+
console.error("DMS access redirect lookup failed", rendererError);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (!location) return false;
|
|
79
|
+
const target = new URL(location, FRONTEND_ORIGIN);
|
|
80
|
+
const refused = new URL(request.url, FRONTEND_ORIGIN);
|
|
81
|
+
if (target.origin !== FRONTEND_ORIGIN || target.pathname === refused.pathname)
|
|
82
|
+
return false;
|
|
83
|
+
redirectFrontendVisit(request, response, location);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Development-only email bundle builder, forked by the frontend server.
|
|
2
|
+
//
|
|
3
|
+
// Runs Vite's email build in watch mode so `dist/server` exists as soon as the
|
|
4
|
+
// dev server does and follows every template edit the layer watchers mirror
|
|
5
|
+
// into the workspace. It lives in its own process so its builds never hold the
|
|
6
|
+
// dev server's event loop, and it reports each build over IPC so the server
|
|
7
|
+
// renders from a finished bundle only.
|
|
8
|
+
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { build } from "vite";
|
|
11
|
+
|
|
12
|
+
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
13
|
+
|
|
14
|
+
// The server owns this process: when its IPC channel closes, however the
|
|
15
|
+
// server ended, there is nobody left to build for.
|
|
16
|
+
process.on("disconnect", () => process.exit(0));
|
|
17
|
+
|
|
18
|
+
const watcher = await build({
|
|
19
|
+
root: PROJECT_ROOT,
|
|
20
|
+
configFile: fileURLToPath(
|
|
21
|
+
new URL("../vite.email.config.ts", import.meta.url),
|
|
22
|
+
),
|
|
23
|
+
logLevel: "warn",
|
|
24
|
+
build: { watch: {} },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
watcher.on("event", (event) => {
|
|
28
|
+
if (event.code === "START") process.send?.({ state: "building" });
|
|
29
|
+
else if (event.code === "END") process.send?.({ state: "ready" });
|
|
30
|
+
else if (event.code === "ERROR") process.send?.({ state: "failed" });
|
|
31
|
+
});
|
|
@@ -1,7 +1,82 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { constants, setPriority } from "node:os";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
import { body } from "./auth/backend.mjs";
|
|
2
5
|
import { validRenderToken } from "./render-token.mjs";
|
|
3
6
|
|
|
4
7
|
const JSON_TYPE = "application/json";
|
|
8
|
+
const EMAIL_RENDERER_URL = new URL(
|
|
9
|
+
"../dist/server/email-renderer.js",
|
|
10
|
+
import.meta.url,
|
|
11
|
+
);
|
|
12
|
+
const EMAIL_BUNDLE_WATCH_URL = new URL(
|
|
13
|
+
"./email-bundle-watch.mjs",
|
|
14
|
+
import.meta.url,
|
|
15
|
+
);
|
|
16
|
+
// A render waits this long for a build in progress, then tries the bundle
|
|
17
|
+
// already on disk rather than holding the backend's request indefinitely.
|
|
18
|
+
const EMAIL_BUILD_WAIT_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
// Settled whenever no email build is running. The version changes with each
|
|
21
|
+
// finished build: Node caches an imported module by URL, so the renderer is
|
|
22
|
+
// imported under a new one to pick up rebuilt templates.
|
|
23
|
+
let emailBuild = Promise.resolve();
|
|
24
|
+
let settleEmailBuild;
|
|
25
|
+
let emailBundleVersion = 0;
|
|
26
|
+
|
|
27
|
+
function emailBuildStarted() {
|
|
28
|
+
if (settleEmailBuild) return;
|
|
29
|
+
emailBuild = new Promise((settle) => {
|
|
30
|
+
settleEmailBuild = settle;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function emailBuildSettled() {
|
|
35
|
+
emailBundleVersion += 1;
|
|
36
|
+
settleEmailBuild?.();
|
|
37
|
+
settleEmailBuild = undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the email bundle now and again on every template change, for
|
|
42
|
+
* development: only the production build otherwise produces it. The builder
|
|
43
|
+
* runs in a child process at low priority, so the dev server keeps answering
|
|
44
|
+
* while it works.
|
|
45
|
+
*/
|
|
46
|
+
export function watchEmailBundle() {
|
|
47
|
+
emailBuildStarted();
|
|
48
|
+
const builder = fork(fileURLToPath(EMAIL_BUNDLE_WATCH_URL), {
|
|
49
|
+
cwd: fileURLToPath(new URL("..", import.meta.url)),
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
setPriority(builder.pid, constants.priority.PRIORITY_BELOW_NORMAL);
|
|
53
|
+
} catch {
|
|
54
|
+
// Not permitted on this platform; the build only runs at normal priority.
|
|
55
|
+
}
|
|
56
|
+
builder.on("message", (message) => {
|
|
57
|
+
if (message?.state === "building") emailBuildStarted();
|
|
58
|
+
else emailBuildSettled();
|
|
59
|
+
});
|
|
60
|
+
builder.on("exit", (code) => {
|
|
61
|
+
emailBuildSettled();
|
|
62
|
+
if (code) console.error(`DMS email bundle watcher exited with ${code}`);
|
|
63
|
+
});
|
|
64
|
+
return builder;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadEmailRenderer() {
|
|
68
|
+
let timer;
|
|
69
|
+
await Promise.race([
|
|
70
|
+
emailBuild,
|
|
71
|
+
new Promise((settle) => {
|
|
72
|
+
timer = setTimeout(settle, EMAIL_BUILD_WAIT_MS);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
const url = new URL(EMAIL_RENDERER_URL);
|
|
77
|
+
if (emailBundleVersion) url.searchParams.set("v", emailBundleVersion);
|
|
78
|
+
return import(url.href);
|
|
79
|
+
}
|
|
5
80
|
|
|
6
81
|
/** Renders an email template for an authenticated backend request. */
|
|
7
82
|
export async function handleEmailRender(request, response) {
|
|
@@ -19,7 +94,7 @@ export async function handleEmailRender(request, response) {
|
|
|
19
94
|
return response.end(JSON.stringify({ message: "Invalid request body" }));
|
|
20
95
|
}
|
|
21
96
|
try {
|
|
22
|
-
const { renderEmail } = await
|
|
97
|
+
const { renderEmail } = await loadEmailRenderer();
|
|
23
98
|
const html = await renderEmail(input.templateName, input.props ?? {}, {
|
|
24
99
|
locale: request.headers["x-content-language"],
|
|
25
100
|
});
|
|
@@ -76,6 +76,15 @@ export function handleAssetVersionMismatch(request, response) {
|
|
|
76
76
|
return true;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** A page visit: a document or Inertia request, as opposed to an API call. */
|
|
80
|
+
export function isFrontendVisit(request) {
|
|
81
|
+
if (request.method !== "GET") return false;
|
|
82
|
+
return (
|
|
83
|
+
Boolean(request.headers["x-inertia"]) ||
|
|
84
|
+
request.headers.accept?.includes("text/html")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
export function redirectFrontendVisit(request, response, location) {
|
|
80
89
|
const vary = { vary: "X-Inertia" };
|
|
81
90
|
if (!request.headers["x-inertia"]) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Answers for requests the server cannot serve at all: an unreadable URL, or a
|
|
2
|
+
// failure while it was already rendering the error page.
|
|
3
|
+
//
|
|
4
|
+
// Lives apart from server.mjs so the file stays under the size the linter
|
|
5
|
+
// allows.
|
|
6
|
+
|
|
7
|
+
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
8
|
+
const TEXT_TYPE = "text/plain; charset=utf-8";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether the path of `url` holds a percent-escape no decoder accepts, such as
|
|
12
|
+
* `/projects/%E0%A4%A`. Vite and the router decode the path, and throw on it.
|
|
13
|
+
*/
|
|
14
|
+
export function hasMalformedPath(url) {
|
|
15
|
+
try {
|
|
16
|
+
decodeURI(new URL(url, FRONTEND_ORIGIN).pathname);
|
|
17
|
+
return false;
|
|
18
|
+
} catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a failed request can no longer be answered: the client went away, or
|
|
25
|
+
* the response already started.
|
|
26
|
+
*/
|
|
27
|
+
export function isResponseGone(error, response) {
|
|
28
|
+
return (
|
|
29
|
+
error?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
30
|
+
response.destroyed ||
|
|
31
|
+
response.writableEnded ||
|
|
32
|
+
response.headersSent
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeBadRequest(response) {
|
|
37
|
+
response.writeHead(400, { "content-type": TEXT_TYPE });
|
|
38
|
+
response.end("Bad Request");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The last resort once even the error page failed: a bare 500, or a dropped
|
|
43
|
+
* connection when the response already started. The server keeps running
|
|
44
|
+
* either way.
|
|
45
|
+
*/
|
|
46
|
+
export function abandonResponse(error, response) {
|
|
47
|
+
console.error("DMS error response failed", error);
|
|
48
|
+
if (response.headersSent || response.writableEnded) {
|
|
49
|
+
response.destroy();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
response.writeHead(500, { "content-type": TEXT_TYPE });
|
|
53
|
+
response.end("Internal Server Error");
|
|
54
|
+
}
|