@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.
@@ -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
- watch(
543
- () => page.url,
544
- (url) => updateRoute(runtime, url),
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 app.runWithContext(() => registration.setup(appContext));
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();
@@ -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) ?? "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
+ }