@antelopejs/dms-frontend 0.3.0 → 0.3.2

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.
@@ -18,17 +18,25 @@ import {
18
18
  refreshSession,
19
19
  } from "./server/auth/routes.mjs";
20
20
  import { readSession } from "./server/auth/session.mjs";
21
+ import {
22
+ BackendResponseError,
23
+ backendResponseError,
24
+ redirectAccessRefusal,
25
+ UNEXPECTED_ERROR_BODY,
26
+ } from "./server/backend-response.mjs";
21
27
  import { productionHtmlTemplate } from "./server/client-manifest.mjs";
22
28
  import { documentStyleTags } from "./server/dev-styles.mjs";
23
- import { handleEmailRender } from "./server/email.mjs";
29
+ import { handleEmailRender, watchEmailBundle } from "./server/email.mjs";
24
30
  import { HOMEPAGE } from "./server/homepage.mjs";
25
31
  import { htmlTag } from "./server/html-tag.mjs";
32
+ import * as requestFailure from "./server/request-failure.mjs";
26
33
  import { handleTester } from "./server/tester.mjs";
27
34
  import {
28
35
  createInertiaPage,
29
36
  htmlHeaders,
30
37
  inertiaAppHtml,
31
38
  inertiaHeaders,
39
+ isFrontendVisit,
32
40
  redirectFrontendVisit,
33
41
  handleAssetVersionMismatch,
34
42
  } from "./server/inertia.mjs";
@@ -73,13 +81,6 @@ let vite;
73
81
  let vitePromise;
74
82
  let frontendHttpServer;
75
83
 
76
- class BackendResponseError extends Error {
77
- constructor(status) {
78
- super(`DMS backend returned ${status}`);
79
- this.status = status;
80
- }
81
- }
82
-
83
84
  async function developmentServer() {
84
85
  if (process.env.DMS_DEV !== "true") return undefined;
85
86
  if (vite) return vite;
@@ -139,7 +140,7 @@ async function backendJson(path, request) {
139
140
  headers: backendHeaders(request),
140
141
  });
141
142
  if (!response.ok) {
142
- throw new BackendResponseError(response.status);
143
+ throw await backendResponseError(response);
143
144
  }
144
145
  return response.json();
145
146
  }
@@ -185,14 +186,6 @@ function writeProxyHeaders(response, upstream) {
185
186
  response.writeHead(upstream.status, headers);
186
187
  }
187
188
 
188
- function isFrontendVisit(request) {
189
- if (request.method !== "GET") return false;
190
- return (
191
- Boolean(request.headers[INERTIA_HEADER]) ||
192
- request.headers.accept?.includes("text/html")
193
- );
194
- }
195
-
196
189
  function redirectToHomepage(request, response, pathname) {
197
190
  if (pathname !== "/" || HOMEPAGE === "/") return false;
198
191
  redirectFrontendVisit(request, response, HOMEPAGE);
@@ -232,7 +225,12 @@ async function proxy(request, response, fallbackOnNotFound = false) {
232
225
  return true;
233
226
  }
234
227
  const target = new URL(request.url, process.env.DMS_API_BASE_URL);
228
+ // Cancelling the body alone misses a client that leaves before the backend
229
+ // answers: its stream, an SSE feed above all, then stayed open for good.
230
+ const abandoned = new AbortController();
231
+ response.once("close", () => abandoned.abort());
235
232
  const upstream = await fetch(target, {
233
+ signal: abandoned.signal,
236
234
  method: request.method,
237
235
  headers: backendHeaders(request),
238
236
  body: ["GET", "HEAD"].includes(request.method) ? undefined : request,
@@ -386,14 +384,16 @@ async function writeBackendError(error, request, response) {
386
384
  redirectFrontendVisit(request, response, location);
387
385
  return;
388
386
  }
387
+ const loadRenderer = async () => ssrRenderer(await developmentServer());
388
+ if (await redirectAccessRefusal(error, request, response, loadRenderer))
389
+ return;
389
390
  console.error("DMS backend request failed", error);
390
391
  if (isFrontendVisit(request)) {
391
- const statusMessage = "DMS backend request failed";
392
392
  const props = {
393
393
  path: pathname,
394
394
  page: {},
395
395
  ...publicSession(readSession(request)),
396
- error: { statusCode: status, statusMessage, message: statusMessage },
396
+ error: { statusCode: status },
397
397
  };
398
398
  const page = createInertiaPage(request.url, props);
399
399
  if (request.headers[INERTIA_HEADER]) {
@@ -411,7 +411,7 @@ async function writeBackendError(error, request, response) {
411
411
  const payload =
412
412
  error instanceof UpstreamError || error instanceof RequestBodyError
413
413
  ? { message: error.message }
414
- : { error: "DMS backend request failed" };
414
+ : UNEXPECTED_ERROR_BODY;
415
415
  response.writeHead(status, { "content-type": JSON_TYPE });
416
416
  response.end(JSON.stringify(payload));
417
417
  }
@@ -433,6 +433,8 @@ export async function handleRequest(request, response) {
433
433
  return handleAuth(request, response, url);
434
434
  if (requestOwnership(request.method, pathname) === "server")
435
435
  return proxy(request, response);
436
+ if (requestFailure.hasMalformedPath(request.url))
437
+ return requestFailure.writeBadRequest(response);
436
438
  if (!isFrontendVisit(request) && (await proxy(request, response, true)))
437
439
  return;
438
440
  const devServer = await developmentServer();
@@ -472,31 +474,32 @@ export async function handleRequest(request, response) {
472
474
  );
473
475
  }
474
476
 
477
+ async function writeRequestFailure(error, request, response) {
478
+ if (requestFailure.isResponseGone(error, response)) return;
479
+ if (
480
+ error instanceof BackendResponseError &&
481
+ error.status === 401 &&
482
+ request.method === "GET" &&
483
+ readSession(request) &&
484
+ !request.dmsSessionRetry
485
+ ) {
486
+ request.dmsSessionRetry = true;
487
+ if (await refreshSession(request, response)) {
488
+ await handleRequestSafely(request, response);
489
+ return;
490
+ }
491
+ }
492
+ await writeBackendError(error, request, response);
493
+ }
494
+
475
495
  export async function handleRequestSafely(request, response) {
476
496
  try {
477
497
  await handleRequest(request, response);
478
498
  } catch (error) {
479
- if (
480
- error?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
481
- response.destroyed ||
482
- response.writableEnded ||
483
- response.headersSent
484
- )
485
- return;
486
- if (
487
- error instanceof BackendResponseError &&
488
- error.status === 401 &&
489
- request.method === "GET" &&
490
- readSession(request) &&
491
- !request.dmsSessionRetry
492
- ) {
493
- request.dmsSessionRetry = true;
494
- if (await refreshSession(request, response)) {
495
- await handleRequestSafely(request, response);
496
- return;
497
- }
498
- }
499
- await writeBackendError(error, request, response);
499
+ // An error page failing too must not reject: that would end the process.
500
+ await writeRequestFailure(error, request, response).catch((failure) =>
501
+ requestFailure.abandonResponse(failure, response),
502
+ );
500
503
  }
501
504
  }
502
505
 
@@ -531,4 +534,7 @@ async function announceReady() {
531
534
  ? `[${address}]`
532
535
  : address;
533
536
  console.log(`✓ Server ready on http://${host}:${port}`);
537
+ // Only the production build produces the email bundle otherwise. Started
538
+ // once the server answers, so it never delays the first page.
539
+ if (process.env.DMS_DEV === "true") watchEmailBundle();
534
540
  }
@@ -18,8 +18,10 @@ import {
18
18
  createDmsFrontendRuntime,
19
19
  hasDmsPage,
20
20
  preloadDmsPage,
21
+ resolveDmsAccessRedirect,
21
22
  setDmsServerRuntimeResolver,
22
23
  setupFrontendModules,
24
+ trackDmsServerScopes,
23
25
  } from "./frontend-module";
24
26
  import { frontendModules } from "./frontend-modules.generated";
25
27
 
@@ -51,8 +53,14 @@ interface DmsSsrContext {
51
53
  }
52
54
 
53
55
  const SSR_ASYNC_DATA_ID = "dms-ssr-async-data";
54
- const runtimeStorage = new AsyncLocalStorage<DmsFrontendRuntime>();
55
- setDmsServerRuntimeResolver(() => runtimeStorage.getStore());
56
+ // Holds the runtime rather than being it: every socket, timer and promise a
57
+ // render starts keeps this store for its whole life, and a pooled backend
58
+ // connection then serving an SSE stream lives for hours. Emptied once the
59
+ // render is over, the store no longer ties the render to them.
60
+ const runtimeStorage = new AsyncLocalStorage<{
61
+ runtime?: DmsFrontendRuntime;
62
+ }>();
63
+ setDmsServerRuntimeResolver(() => runtimeStorage.getStore()?.runtime);
56
64
 
57
65
  function asyncDataScript(runtime: DmsFrontendRuntime): string {
58
66
  const value = JSON.stringify(serializeDmsAsyncData(runtime)).replaceAll(
@@ -73,6 +81,10 @@ export function isDmsFrontendPage(path: string): boolean {
73
81
  return hasDmsPage(path);
74
82
  }
75
83
 
84
+ export function accessRedirect(code: string): string | undefined {
85
+ return resolveDmsAccessRedirect(code);
86
+ }
87
+
76
88
  export async function renderDmsPage(
77
89
  page: DmsSsrPage,
78
90
  serverFetch?: DmsServerFetch,
@@ -84,9 +96,14 @@ export async function renderDmsPage(
84
96
  true,
85
97
  requestCookies,
86
98
  );
87
- return runtimeStorage.run(runtime, () =>
88
- renderDmsPageWithRuntime(page, runtime, serverFetch),
89
- );
99
+ const store = { runtime };
100
+ try {
101
+ return await runtimeStorage.run(store, () =>
102
+ renderDmsPageWithRuntime(page, runtime, serverFetch),
103
+ );
104
+ } finally {
105
+ store.runtime = undefined;
106
+ }
90
107
  }
91
108
 
92
109
  async function renderDmsPageWithRuntime(
@@ -95,64 +112,79 @@ async function renderDmsPageWithRuntime(
95
112
  serverFetch?: DmsServerFetch,
96
113
  ): Promise<DmsSsrResult> {
97
114
  await preloadDmsPage(page.props);
98
- const head = createHead();
99
- const ssrContext: DmsSsrContext = {};
100
- let asyncComponents = new Set<string>();
101
- const inertiaResult = await createInertiaApp({
102
- page,
103
- render: (app) => renderToString(app, ssrContext),
104
- resolve: resolveDmsInertiaPage,
105
- async setup({ App, props, plugin }) {
106
- const app = createSSRApp({ render: () => h(App, props) });
107
- asyncComponents = trackDmsAsyncComponents(app);
108
- const configured = await configureDmsApp({
109
- app,
110
- head,
111
- initialPageProps: page.props,
112
- initialPageUrl: page.url,
113
- inertiaPlugin: plugin,
114
- runtime,
115
+ let stopScopes = () => {};
116
+ try {
117
+ const head = createHead();
118
+ const ssrContext: DmsSsrContext = {};
119
+ let asyncComponents = new Set<string>();
120
+ const inertiaResult = await createInertiaApp({
121
+ page,
122
+ render: (app) => renderToString(app, ssrContext),
123
+ resolve: resolveDmsInertiaPage,
124
+ async setup({ App, props, plugin }) {
125
+ const app = createSSRApp({ render: () => h(App, props) });
126
+ asyncComponents = trackDmsAsyncComponents(app);
127
+ stopScopes = trackDmsServerScopes(app);
128
+ const configured = await configureDmsApp({
129
+ app,
130
+ head,
131
+ initialPageProps: page.props,
132
+ initialPageUrl: page.url,
133
+ inertiaPlugin: plugin,
134
+ runtime,
135
+ serverFetch,
136
+ });
137
+ return app;
138
+ },
139
+ });
140
+ if (!inertiaResult) throw new Error("Inertia SSR returned no result");
141
+ await runtime.pendingNavigation;
142
+ if (runtime.serverRedirect) {
143
+ return {
144
+ body: "",
145
+ head: { bodyAttrs: "", headTags: "", htmlAttrs: "" },
146
+ overlays: "",
147
+ redirect: runtime.serverRedirect,
148
+ };
149
+ }
150
+ if (runtime.currentError.value && !page.props.error) {
151
+ const failure = runtime.currentError.value as Error & DmsErrorData;
152
+ const expected =
153
+ Number.isInteger(failure.statusCode) &&
154
+ failure.statusCode! >= 400 &&
155
+ failure.statusCode! <= 599;
156
+ // An unexpected failure reaches the page as its status code alone, which
157
+ // the DMS error page describes in the visitor's language; the technical
158
+ // cause only reaches the server log.
159
+ if (!expected) console.error("DMS page render failed", failure);
160
+ const error = expected
161
+ ? {
162
+ statusCode: failure.statusCode,
163
+ message: failure.message,
164
+ statusMessage: failure.statusMessage,
165
+ }
166
+ : { statusCode: 500 };
167
+ return renderDmsPage(
168
+ { ...page, props: { ...page.props, error } },
115
169
  serverFetch,
116
- });
117
- return app;
118
- },
119
- });
120
- if (!inertiaResult) throw new Error("Inertia SSR returned no result");
121
- await runtime.pendingNavigation;
122
- if (runtime.serverRedirect) {
170
+ runtime.requestCookies,
171
+ );
172
+ }
173
+ const renderedHead = await renderSSRHead(head);
123
174
  return {
124
- body: "",
125
- head: { bodyAttrs: "", headTags: "", htmlAttrs: "" },
126
- overlays: "",
127
- redirect: runtime.serverRedirect,
128
- };
129
- }
130
- if (runtime.currentError.value && !page.props.error) {
131
- const failure = runtime.currentError.value as Error & DmsErrorData;
132
- const expected =
133
- Number.isInteger(failure.statusCode) &&
134
- failure.statusCode! >= 400 &&
135
- failure.statusCode! <= 599;
136
- const error = {
137
- statusCode: expected ? failure.statusCode : 500,
138
- message: expected ? failure.message : "An unexpected error occurred",
139
- statusMessage: expected ? failure.statusMessage : "Application error",
175
+ error: page.props.error,
176
+ body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
177
+ head: {
178
+ bodyAttrs: renderedHead.bodyAttrs,
179
+ headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
180
+ htmlAttrs: renderedHead.htmlAttrs,
181
+ },
182
+ overlays: ssrContext.teleports?.["#dms-overlays"] ?? "",
140
183
  };
141
- return renderDmsPage(
142
- { ...page, props: { ...page.props, error } },
143
- serverFetch,
144
- runtime.requestCookies,
145
- );
184
+ } finally {
185
+ // Only once the head and the async data are serialized: both still read
186
+ // state the rendered components own.
187
+ stopScopes();
188
+ runtime.scope.stop();
146
189
  }
147
- const renderedHead = await renderSSRHead(head);
148
- return {
149
- error: page.props.error,
150
- body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
151
- head: {
152
- bodyAttrs: renderedHead.bodyAttrs,
153
- headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
154
- htmlAttrs: renderedHead.htmlAttrs,
155
- },
156
- overlays: ssrContext.teleports?.["#dms-overlays"] ?? "",
157
- };
158
190
  }
@@ -42,10 +42,14 @@ export default defineConfig({
42
42
  for (const file of readdirSync(directory).filter((name) =>
43
43
  name.endsWith(".json"),
44
44
  )) {
45
+ const catalog = resolve(directory, file);
46
+ // Read as data rather than imported: without this, a build in watch
47
+ // mode would keep emitting the catalogs of its first run.
48
+ this.addWatchFile(catalog);
45
49
  this.emitFile({
46
50
  type: "asset",
47
51
  fileName: `locales/${file}`,
48
- source: readFileSync(resolve(directory, file), "utf8"),
52
+ source: readFileSync(catalog, "utf8"),
49
53
  });
50
54
  }
51
55
  },