@antelopejs/dms-frontend 0.3.6 → 0.3.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -333,6 +333,11 @@ export interface DmsFrontendRuntime {
333
333
  pendingNavigation?: Promise<void>;
334
334
  hasNavigationListener: boolean;
335
335
  pageVersion: number;
336
+ /**
337
+ * Identifies the page on screen (its path, or the error page shown at that
338
+ * path), whose meta `route.meta` holds.
339
+ */
340
+ pageKey?: string;
336
341
  /** The `Cookie` header of the request a server runtime renders. */
337
342
  requestCookies?: string;
338
343
  /**
@@ -495,7 +500,10 @@ function parseRoute(runtime: DmsFrontendRuntime, url: string): DmsRoute {
495
500
  }
496
501
 
497
502
  function updateRoute(runtime: DmsFrontendRuntime, url: string): void {
498
- Object.assign(runtime.route, parseRoute(runtime, url));
503
+ // The meta is the page's, not the URL's: a page declares it, then reads its
504
+ // route, and the middleware the meta selected must still find it.
505
+ const { meta: _pageMeta, ...location } = parseRoute(runtime, url);
506
+ Object.assign(runtime.route, location);
499
507
  }
500
508
 
501
509
  function locationToUrl(
@@ -578,16 +586,22 @@ async function visit(
578
586
  export function useDmsRoute(pattern?: string): DmsRoute {
579
587
  const runtime = useDmsRuntime();
580
588
  runtime.currentRoutePattern = pattern;
589
+ // A server render answers one request, whose URL the runtime was given with
590
+ // its page, and never changes it. Inertia's page is one ref for the whole
591
+ // process, written by each render as it starts: after an await it may hold
592
+ // another request's URL, and while the plugins set up, the previous one's.
593
+ if (runtime.isServer) {
594
+ updateRoute(runtime, runtime.route.fullPath);
595
+ return runtime.route;
596
+ }
581
597
  const page = usePage();
582
- updateRoute(runtime, page.url);
583
- // A server render never changes its URL, and Inertia's page is shared by
584
- // every render the process runs: a watcher there would only tie this
585
- // request's runtime to all the requests after it.
586
- if (!runtime.isServer)
587
- watch(
588
- () => page.url,
589
- (url) => updateRoute(runtime, url),
590
- );
598
+ // The browser's plugins set up before Inertia has a page: until then the
599
+ // runtime's route is the initial page's.
600
+ if (page.url !== undefined) updateRoute(runtime, page.url);
601
+ watch(
602
+ () => page.url,
603
+ (url) => updateRoute(runtime, url),
604
+ );
591
605
  return runtime.route;
592
606
  }
593
607
 
@@ -1360,6 +1374,14 @@ export function getDmsLayoutProps(
1360
1374
  export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1361
1375
  const runtime = useDmsRuntime();
1362
1376
  if (url) updateRoute(runtime, url);
1377
+ // Pages are keyed by path: another path mounts another page, which declares
1378
+ // its own meta, and so does an error page shown in place of the page. A
1379
+ // visit that only changes the query keeps the page, and so its meta.
1380
+ const pageKey = props.error ? `error:${props.path}` : props.path;
1381
+ if (pageKey !== runtime.pageKey) {
1382
+ runtime.pageKey = pageKey;
1383
+ runtime.route.meta = {};
1384
+ }
1363
1385
  runtime.pageVersion++;
1364
1386
  runtime.currentError.value = null;
1365
1387
  useDmsState<DmsUser | null>("dms-user", () => null).value =
@@ -0,0 +1,41 @@
1
+ // Answers for requests Node rejects before the request handler runs. Without
2
+ // a `clientError` listener Node answers on its own and logs nothing, so a page
3
+ // refused for its headers left no trace in the server output.
4
+
5
+ const HEADER_OVERFLOW = "HPE_HEADER_OVERFLOW";
6
+ const HEADER_OVERFLOW_RESPONSE =
7
+ "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n";
8
+ const BAD_REQUEST_RESPONSE =
9
+ "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
10
+ // A browser retries a refused page with the same cookies: one hint per minute
11
+ // is enough to explain them all.
12
+ const HEADER_OVERFLOW_HINT_INTERVAL_MS = 60_000;
13
+
14
+ let lastHeaderOverflowHint = -Infinity;
15
+
16
+ function warnHeaderOverflow(maxHeaderSize) {
17
+ const now = Date.now();
18
+ if (now - lastHeaderOverflowHint < HEADER_OVERFLOW_HINT_INTERVAL_MS) return;
19
+ lastHeaderOverflowHint = now;
20
+ console.warn(
21
+ `DMS refused a request with 431: its headers exceed the ${maxHeaderSize}-byte limit. ` +
22
+ "This is most often cookies accumulated on this host (browsers share " +
23
+ "localhost cookies across ports); clear this site's cookies and reload.",
24
+ );
25
+ }
26
+
27
+ /**
28
+ * The server's `clientError` listener: explains a 431 in the logs, and
29
+ * otherwise answers as Node does without a listener.
30
+ */
31
+ export function handleClientError(error, socket, maxHeaderSize) {
32
+ let response = BAD_REQUEST_RESPONSE;
33
+ if (error.code === HEADER_OVERFLOW) {
34
+ response = HEADER_OVERFLOW_RESPONSE;
35
+ warnHeaderOverflow(maxHeaderSize);
36
+ }
37
+ // Once a response went out on this connection, a status line would land in
38
+ // the middle of it.
39
+ if (socket.writable && socket.bytesWritten === 0) socket.write(response);
40
+ socket.destroy();
41
+ }
@@ -1,5 +1,5 @@
1
1
  import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
2
- import { createServer } from "node:http";
2
+ import { createServer, maxHeaderSize } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
@@ -19,6 +19,7 @@ import {
19
19
  redirectAccessRefusal,
20
20
  UNEXPECTED_ERROR_BODY,
21
21
  } from "./server/backend-response.mjs";
22
+ import { handleClientError } from "./server/client-error.mjs";
22
23
  import { productionHtmlTemplate } from "./server/client-manifest.mjs";
23
24
  import { writeContent } from "./server/content.mjs";
24
25
  import {
@@ -400,6 +401,22 @@ async function answeredByVite(devServer, request, response) {
400
401
  return result.status !== "unhandled";
401
402
  }
402
403
 
404
+ /**
405
+ * Vite's own URLs (`/@vite/client`, `/@id/…`, `/@fs/…`) and paths with an
406
+ * extension name files: modules, stylesheets, fonts, images, built assets.
407
+ * The backend's routes outside `/api` and `/dms` are mostly the paths of the
408
+ * pages they serve data for, which have none (`/<page>/pagelayout`).
409
+ */
410
+ function isFilePath(pathname) {
411
+ return pathname.startsWith("/@") || extname(pathname) !== "";
412
+ }
413
+
414
+ /** Vite's files in development, the build's in production. */
415
+ async function answeredWithFile(devServer, request, response, pathname) {
416
+ if (!devServer) return serveAsset(pathname, request, response);
417
+ return pathname !== "/" && answeredByVite(devServer, request, response);
418
+ }
419
+
403
420
  export async function handleRequest(request, response) {
404
421
  const url = new URL(request.url, "http://frontend.local");
405
422
  const pathname = url.pathname;
@@ -420,13 +437,18 @@ export async function handleRequest(request, response) {
420
437
  return proxy(request, response);
421
438
  if (requestFailure.hasMalformedPath(request.url))
422
439
  return requestFailure.writeBadRequest(response);
423
- if (!isFrontendVisit(request) && (await proxy(request, response, true)))
424
- return;
425
440
  const devServer = await developmentServer();
426
- if (devServer && pathname !== "/") {
427
- if (await answeredByVite(devServer, request, response)) return;
428
- }
429
- if (!devServer && serveAsset(pathname, request, response)) return;
441
+ const visit = isFrontendVisit(request);
442
+ // A file is looked for among the frontend's own before the backend is asked
443
+ // for it: asking first cost a backend round trip for each of the hundreds of
444
+ // modules a development page loads, and for each built asset. A file the
445
+ // frontend does not have, a media file, is still the backend's.
446
+ const fileFirst = !visit && isFilePath(pathname);
447
+ const serveFile = () =>
448
+ answeredWithFile(devServer, request, response, pathname);
449
+ if (fileFirst && (await serveFile())) return;
450
+ if (!visit && (await proxy(request, response, true))) return;
451
+ if (!fileFirst && (await serveFile())) return;
430
452
  if (handleAssetVersionMismatch(request, response)) return;
431
453
  if (redirectToHomepage(request, response, pathname)) return;
432
454
  const props = await pageProps(request);
@@ -489,6 +511,9 @@ if (
489
511
  import.meta.url === new URL(`file://${process.argv[1]}`).href
490
512
  ) {
491
513
  frontendHttpServer = createServer(handleRequestSafely);
514
+ frontendHttpServer.on("clientError", (error, socket) =>
515
+ handleClientError(error, socket, maxHeaderSize),
516
+ );
492
517
  frontendHttpServer.listen(
493
518
  Number(process.env.PORT ?? 3001),
494
519
  process.env.HOST ?? "0.0.0.0",