@antelopejs/dms-frontend 0.2.8 → 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.
@@ -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,
@@ -193,15 +195,8 @@ export interface DmsCookieOptions<T> {
193
195
  sameSite?: "strict" | "lax" | "none";
194
196
  secure?: boolean;
195
197
  }
196
- export type DmsColorModePreference = "system" | "light" | "dark";
197
198
  type DmsLinkPrefetchMode = "mount" | "hover" | "click";
198
199
  type DmsLinkPrefetch = boolean | DmsLinkPrefetchMode | DmsLinkPrefetchMode[];
199
- export interface DmsColorMode {
200
- preference: DmsColorModePreference;
201
- value: "light" | "dark";
202
- unknown: boolean;
203
- forced: boolean;
204
- }
205
200
  export interface DmsAppContext {
206
201
  provide<T>(key: string, value: T): void;
207
202
  vueApp: App;
@@ -256,6 +251,12 @@ export interface DmsFrontendSdk {
256
251
  handler: DmsMiddleware,
257
252
  options?: DmsMiddlewareRegistrationOptions,
258
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;
259
260
  provide<T>(key: string | InjectionKey<T>, value: T): void;
260
261
  use(plugin: Plugin): void;
261
262
  }
@@ -316,6 +317,11 @@ export interface DmsFrontendRuntime {
316
317
  pageVersion: number;
317
318
  /** The `Cookie` header of the request a server runtime renders. */
318
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;
319
325
  }
320
326
 
321
327
  const components = new Map<string, DmsComponentRegistration>();
@@ -327,20 +333,12 @@ const plugins: Plugin[] = [];
327
333
  const pluginSetups: DmsPluginRegistration[] = [];
328
334
  const middleware: DmsMiddleware[] = [];
329
335
  const namedMiddleware = new Map<string, DmsMiddleware>();
336
+ const accessRedirects = new Map<string, string>();
330
337
  const injections = new Map<string | symbol, unknown>();
331
338
  const runtimeConfig = ref<DmsRuntimeConfig>({
332
339
  public: {},
333
340
  } as DmsRuntimeConfig);
334
341
  const appConfig = ref<Record<string, unknown>>({});
335
- const COLOR_MODE_STORAGE_KEY = "dms-color-mode";
336
- const COLOR_MODE_CLASSES = ["light", "dark"];
337
- const colorMode = reactive<DmsColorMode>({
338
- preference: "system",
339
- value: "light",
340
- unknown: false,
341
- forced: false,
342
- });
343
- let isColorModeInitialized = false;
344
342
  const DMS_RUNTIME_KEY: InjectionKey<DmsFrontendRuntime> = Symbol("dms-runtime");
345
343
  type DmsRuntimeContext = DmsAppContext["runWithContext"];
346
344
  const runtimeContexts = new WeakMap<DmsFrontendRuntime, DmsRuntimeContext>();
@@ -374,6 +372,7 @@ export function createDmsFrontendRuntime(
374
372
  }),
375
373
  hasNavigationListener: false,
376
374
  pageVersion: 0,
375
+ scope: effectScope(true),
377
376
  };
378
377
  }
379
378
 
@@ -555,10 +554,14 @@ export function useDmsRoute(pattern?: string): DmsRoute {
555
554
  runtime.currentRoutePattern = pattern;
556
555
  const page = usePage();
557
556
  updateRoute(runtime, page.url);
558
- watch(
559
- () => page.url,
560
- (url) => updateRoute(runtime, url),
561
- );
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
+ );
562
565
  return runtime.route;
563
566
  }
564
567
 
@@ -1105,46 +1108,6 @@ export function useDmsCookie<T = string | null>(
1105
1108
  return runtime.sharedState.get(key) as Ref<T>;
1106
1109
  }
1107
1110
 
1108
- function preferredColorMode(): "light" | "dark" {
1109
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches
1110
- ? "dark"
1111
- : "light";
1112
- }
1113
-
1114
- function applyColorMode(preference: DmsColorModePreference): void {
1115
- colorMode.value = preference === "system" ? preferredColorMode() : preference;
1116
- document.documentElement.classList.remove(...COLOR_MODE_CLASSES);
1117
- document.documentElement.classList.add(colorMode.value);
1118
- }
1119
-
1120
- function initializeColorMode(): void {
1121
- if (isColorModeInitialized) return;
1122
- isColorModeInitialized = true;
1123
- if (typeof window === "undefined") return;
1124
- const stored = window.localStorage.getItem(COLOR_MODE_STORAGE_KEY);
1125
- if (COLOR_MODE_CLASSES.includes(stored ?? "") || stored === "system") {
1126
- colorMode.preference = stored as DmsColorModePreference;
1127
- }
1128
- watch(
1129
- () => colorMode.preference,
1130
- (preference) => {
1131
- applyColorMode(preference);
1132
- window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, preference);
1133
- },
1134
- { immediate: true },
1135
- );
1136
- window
1137
- .matchMedia?.("(prefers-color-scheme: dark)")
1138
- .addEventListener("change", () => {
1139
- if (colorMode.preference === "system") applyColorMode("system");
1140
- });
1141
- }
1142
-
1143
- export function useColorMode(): DmsColorMode {
1144
- initializeColorMode();
1145
- return colorMode;
1146
- }
1147
-
1148
1111
  export function createError(
1149
1112
  input: string | DmsErrorData,
1150
1113
  ): Error & DmsErrorData {
@@ -1228,6 +1191,29 @@ export function trackDmsAsyncComponents(app: App): Set<string> {
1228
1191
  return rendered;
1229
1192
  }
1230
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
+
1231
1217
  export async function resolveDmsAsyncComponents(
1232
1218
  names: readonly string[],
1233
1219
  ): Promise<void> {
@@ -1308,6 +1294,10 @@ function findDmsPageEntry(props: DmsPageProps): DmsFrontendEntry | undefined {
1308
1294
  : dynamicPages.get(CATCH_ALL_PAGE_KEY);
1309
1295
  }
1310
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
+ }
1311
1301
  export function hasDmsPage(name: string): boolean {
1312
1302
  return pages.has(normalizeDmsPageKey(name));
1313
1303
  }
@@ -1430,6 +1420,9 @@ function createSdk(options: DmsModuleOptions): DmsFrontendSdk {
1430
1420
  if (!namedMiddleware.has(name)) namedMiddleware.set(name, handler);
1431
1421
  if (options.global) middleware.push(handler);
1432
1422
  },
1423
+ registerAccessRedirect: (code, path) => {
1424
+ if (!accessRedirects.has(code)) accessRedirects.set(code, path);
1425
+ },
1433
1426
  provide: (key, value) => {
1434
1427
  if (!injections.has(key)) injections.set(key, value);
1435
1428
  },
@@ -1515,7 +1508,9 @@ export async function installDmsPlugins(
1515
1508
  });
1516
1509
  for (const registration of pluginSetups) {
1517
1510
  if (registration.clientOnly && typeof window === "undefined") continue;
1518
- await app.runWithContext(() => registration.setup(appContext));
1511
+ await runtime.scope.run(() =>
1512
+ app.runWithContext(() => registration.setup(appContext)),
1513
+ );
1519
1514
  }
1520
1515
  return async () => {
1521
1516
  for (const callback of hooks.get("app:mounted") ?? []) await callback();
@@ -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/
@@ -4,16 +4,6 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="data:," />
7
- <script>
8
- const preference = localStorage.getItem("dms-color-mode") || "system";
9
- const mode =
10
- preference === "system"
11
- ? matchMedia("(prefers-color-scheme: dark)").matches
12
- ? "dark"
13
- : "light"
14
- : preference;
15
- document.documentElement.classList.add(mode);
16
- </script>
17
7
  <title>Antelope DMS</title>
18
8
  </head>
19
9
  <body>
@@ -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) ?? "DMS backend request failed";
51
- throw new UpstreamError(response.status, message);
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 import("../dist/server/email-renderer.js");
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
+ }