@antelopejs/dms-frontend 0.6.1 → 0.6.3

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.
@@ -11,8 +11,28 @@ const HOP_BY_HOP_HEADERS = [
11
11
  "upgrade",
12
12
  ];
13
13
 
14
- /** Writes the backend's status and end-to-end headers on `response`. */
15
- export function writeProxyHeaders(response, upstream) {
14
+ /** `Accept-Encoding` added to the backend's own `Vary`, once. */
15
+ function varyWithEncoding(vary) {
16
+ const names = (vary ?? "")
17
+ .split(",")
18
+ .map((name) => name.trim())
19
+ .filter(Boolean);
20
+ if (names.some((name) => name === "*" || /^accept-encoding$/i.test(name)))
21
+ return names.join(", ");
22
+ return [...names, "Accept-Encoding"].join(", ");
23
+ }
24
+
25
+ /**
26
+ * Writes the backend's status and end-to-end headers on `response`. With an
27
+ * `encoding`, the proxy compresses the body itself: its length is no longer
28
+ * known, and a strong validator no longer names these bytes.
29
+ */
30
+ export function writeProxyHeaders(
31
+ response,
32
+ upstream,
33
+ encoding,
34
+ varies = false,
35
+ ) {
16
36
  const headers = Object.fromEntries(upstream.headers);
17
37
  const named = (headers.connection ?? "")
18
38
  .split(",")
@@ -21,5 +41,12 @@ export function writeProxyHeaders(response, upstream) {
21
41
  delete headers["set-cookie"];
22
42
  const cookies = upstream.headers.getSetCookie?.() ?? [];
23
43
  if (cookies.length) headers["set-cookie"] = cookies;
44
+ if (encoding || varies) headers.vary = varyWithEncoding(headers.vary);
45
+ if (encoding) {
46
+ delete headers["content-length"];
47
+ headers["content-encoding"] = encoding;
48
+ if (headers.etag && !headers.etag.startsWith("W/"))
49
+ headers.etag = `W/${headers.etag}`;
50
+ }
24
51
  response.writeHead(upstream.status, headers);
25
52
  }
@@ -0,0 +1,109 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+
4
+ // What a build or a module's `public/` directory may hold. A type missing
5
+ // here went out as `application/octet-stream`: fonts among them, which a
6
+ // browser then reports as a mismatch against the `font/woff2` it preloaded.
7
+ const MIME_TYPES = {
8
+ ".avif": "image/avif",
9
+ ".css": "text/css",
10
+ ".gif": "image/gif",
11
+ ".html": "text/html; charset=utf-8",
12
+ ".ico": "image/x-icon",
13
+ ".jpeg": "image/jpeg",
14
+ ".jpg": "image/jpeg",
15
+ ".js": "text/javascript",
16
+ ".json": "application/json",
17
+ ".map": "application/json",
18
+ ".mjs": "text/javascript",
19
+ ".mp4": "video/mp4",
20
+ ".otf": "font/otf",
21
+ ".pdf": "application/pdf",
22
+ ".png": "image/png",
23
+ ".svg": "image/svg+xml",
24
+ ".ttf": "font/ttf",
25
+ ".txt": "text/plain; charset=utf-8",
26
+ ".wasm": "application/wasm",
27
+ ".webm": "video/webm",
28
+ ".webmanifest": "application/manifest+json",
29
+ ".webp": "image/webp",
30
+ ".woff": "font/woff",
31
+ ".woff2": "font/woff2",
32
+ ".xml": "application/xml",
33
+ };
34
+ const DEFAULT_MIME_TYPE = "application/octet-stream";
35
+ // Vite names everything under `assets/` after its content: a new build
36
+ // writes new names, so a client never needs to ask again.
37
+ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
38
+ // Any other file keeps its name across builds (a module's `public/` file,
39
+ // a favicon): the client asks again on each use, and is answered 304 while
40
+ // it has not changed.
41
+ const REVALIDATED_CACHE = "no-cache";
42
+
43
+ const isFile = (path) => existsSync(path) && statSync(path).isFile();
44
+
45
+ /** The precompressed variant of `file` the client accepts, if the build wrote one. */
46
+ function acceptedVariant(file, request) {
47
+ const accepted = request.headers["accept-encoding"] ?? "";
48
+ if (accepted.includes("br") && isFile(`${file}.br`))
49
+ return { file: `${file}.br`, encoding: "br" };
50
+ if (accepted.includes("gzip") && isFile(`${file}.gz`))
51
+ return { file: `${file}.gz`, encoding: "gzip" };
52
+ return { file, encoding: undefined };
53
+ }
54
+
55
+ /**
56
+ * Weak, as the bytes of one representation are not compared: the size and
57
+ * modification time of the file sent, so each encoding has its own.
58
+ */
59
+ function entityTag(stats) {
60
+ return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`;
61
+ }
62
+
63
+ const opaqueTag = (tag) => tag.trim().replace(/^W\//, "");
64
+
65
+ /** RFC 9110 §13.1.2 and §13.1.3: `If-None-Match` first, the date otherwise. */
66
+ function isNotModified(request, tag, stats) {
67
+ const noneMatch = request.headers["if-none-match"];
68
+ if (noneMatch !== undefined) {
69
+ if (noneMatch.trim() === "*") return true;
70
+ const wanted = opaqueTag(tag);
71
+ return noneMatch
72
+ .split(",")
73
+ .some((candidate) => opaqueTag(candidate) === wanted);
74
+ }
75
+ const since = Date.parse(request.headers["if-modified-since"] ?? "");
76
+ // The header has a one-second resolution.
77
+ return (
78
+ !Number.isNaN(since) && Math.floor(stats.mtimeMs / 1000) * 1000 <= since
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Send `file`, a file the caller already resolved and checked (see
84
+ * `publicBuildFile`), as the variant the client accepts, or answer 304 when the
85
+ * client's copy is current. `immutable` for a file named after its content.
86
+ */
87
+ export function serveStaticFile(file, request, response, { immutable }) {
88
+ const selected = acceptedVariant(file, request);
89
+ const stats = statSync(selected.file);
90
+ const tag = entityTag(stats);
91
+ const headers = {
92
+ "cache-control": immutable ? IMMUTABLE_CACHE : REVALIDATED_CACHE,
93
+ etag: tag,
94
+ "last-modified": stats.mtime.toUTCString(),
95
+ vary: "Accept-Encoding",
96
+ };
97
+ if (isNotModified(request, tag, stats)) {
98
+ response.writeHead(304, headers);
99
+ response.end();
100
+ return;
101
+ }
102
+ headers["content-type"] =
103
+ MIME_TYPES[extname(file).toLowerCase()] ?? DEFAULT_MIME_TYPE;
104
+ headers["content-length"] = stats.size;
105
+ if (selected.encoding) headers["content-encoding"] = selected.encoding;
106
+ response.writeHead(200, headers);
107
+ if (request.method === "HEAD") response.end();
108
+ else createReadStream(selected.file).pipe(response);
109
+ }
@@ -1,8 +1,6 @@
1
- import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer, maxHeaderSize } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
- import { Readable } from "node:stream";
5
- import { pipeline } from "node:stream/promises";
6
4
  import { fileURLToPath } from "node:url";
7
5
  import { ofetch } from "ofetch";
8
6
  import {
@@ -25,6 +23,8 @@ import {
25
23
  reportReady,
26
24
  reportStartError,
27
25
  } from "./server/cli-report.mjs";
26
+ import { publicBuildFile } from "./server/built-files.mjs";
27
+ import { serveStaticFile } from "./server/static-files.mjs";
28
28
  import { productionHtmlTemplate } from "./server/client-manifest.mjs";
29
29
  import { writeContent } from "./server/content.mjs";
30
30
  import {
@@ -49,7 +49,10 @@ import { htmlTag } from "./server/html-tag.mjs";
49
49
  import { listenHost } from "./server/listen-host.mjs";
50
50
  import * as icons from "./server/icons.mjs";
51
51
  import * as requestFailure from "./server/request-failure.mjs";
52
- import { writeProxyHeaders } from "./server/proxy-headers.mjs";
52
+ import {
53
+ PLAIN_ANSWER_HEADERS,
54
+ relayAnswer,
55
+ } from "./server/proxy-compression.mjs";
53
56
  import { backendUrl, refuseForeignTarget } from "./server/request-target.mjs";
54
57
  import {
55
58
  createInertiaPage,
@@ -65,13 +68,9 @@ export * from "./server/inertia.mjs";
65
68
  const INERTIA_HEADER = "x-inertia";
66
69
  const JSON_TYPE = "application/json";
67
70
  const PROJECT_ROOT = fileURLToPath(new URL(".", import.meta.url));
68
- const MIME_TYPES = {
69
- ".css": "text/css",
70
- ".js": "text/javascript",
71
- ".svg": "image/svg+xml",
72
- };
73
71
  const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
74
72
  const LOCAL_ROUTES = [[icons.isIconRequest, icons.handleIcons]];
73
+ const CLIENT_ROOT = join(PROJECT_ROOT, "dist/client");
75
74
  const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
76
75
  const BUILT_SSR_RENDERER_PATH = join(PROJECT_ROOT, "dist/ssr/ssr-renderer.js");
77
76
  // The SSR bundle keeps vue-i18n external, so Node loads its esm-bundler build,
@@ -215,48 +214,24 @@ async function proxy(request, response, backendProxy) {
215
214
  const upstream = await fetch(backendUrl(request.url), {
216
215
  signal: abandoned.signal,
217
216
  method: request.method,
218
- headers: backendHeaders(request, access),
217
+ headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
219
218
  body: ["GET", "HEAD"].includes(request.method) ? undefined : request,
220
219
  duplex: "half",
221
220
  });
222
- writeProxyHeaders(response, upstream);
223
- if (!upstream.body || request.method === "HEAD") response.end();
224
- else await pipeline(Readable.fromWeb(upstream.body), response);
225
- }
226
-
227
- function acceptedAsset(pathname, request) {
228
- const file = resolve(PROJECT_ROOT, "dist/client", `.${pathname}`);
229
- const accepted = request.headers["accept-encoding"] ?? "";
230
- if (accepted.includes("br") && existsSync(`${file}.br`))
231
- return { file: `${file}.br`, encoding: "br", source: file };
232
- if (accepted.includes("gzip") && existsSync(`${file}.gz`))
233
- return { file: `${file}.gz`, encoding: "gzip", source: file };
234
- return { file, encoding: undefined, source: file };
221
+ await relayAnswer(request, response, upstream, { fetched: true });
235
222
  }
236
223
 
224
+ /**
225
+ * Send the built file `pathname` names, or `false` when the build has none
226
+ * there. `publicBuildFile` decides which file that is; `serveStaticFile` how
227
+ * it goes out.
228
+ */
237
229
  function serveAsset(pathname, request, response) {
238
- const selected = acceptedAsset(pathname, request);
239
- // Native separator: `selected.source` comes from `resolve`, so a hardcoded
240
- // `/` here would make the containment check fail for every asset on Windows.
241
- const assetRoot = `${resolve(PROJECT_ROOT, "dist/client")}${sep}`;
242
- if (
243
- !selected.source.startsWith(assetRoot) ||
244
- !existsSync(selected.file) ||
245
- !statSync(selected.file).isFile()
246
- )
247
- return false;
248
- const headers = {
249
- "cache-control": pathname.startsWith("/assets/")
250
- ? "public, max-age=31536000, immutable"
251
- : "no-cache",
252
- "content-type":
253
- MIME_TYPES[extname(selected.source)] ?? "application/octet-stream",
254
- "content-length": statSync(selected.file).size,
255
- vary: "Accept-Encoding",
256
- };
257
- if (selected.encoding) headers["content-encoding"] = selected.encoding;
258
- response.writeHead(200, headers);
259
- createReadStream(selected.file).pipe(response);
230
+ const file = publicBuildFile(CLIENT_ROOT, pathname);
231
+ if (!file) return false;
232
+ serveStaticFile(file, request, response, {
233
+ immutable: pathname.startsWith("/assets/"),
234
+ });
260
235
  return true;
261
236
  }
262
237
 
@@ -391,8 +366,8 @@ async function answeredByVite(devServer, request, response) {
391
366
 
392
367
  /**
393
368
  * Whether the renderer has a file at `pathname`: a development source Vite
394
- * serves (under the workspace or its `public/`), or a built asset. Only a
395
- * path with an extension names one.
369
+ * serves (under the workspace or its `public/`), or a built asset the
370
+ * production server serves. Only a path with an extension names one.
396
371
  */
397
372
  function hasRendererFile(devServer, requested) {
398
373
  let pathname;
@@ -403,9 +378,10 @@ function hasRendererFile(devServer, requested) {
403
378
  return false;
404
379
  }
405
380
  if (extname(pathname) === "") return false;
406
- const roots = devServer
407
- ? [devServer.config.root, devServer.config.publicDir].filter(Boolean)
408
- : [join(PROJECT_ROOT, "dist/client")];
381
+ if (!devServer) return publicBuildFile(CLIENT_ROOT, requested) !== undefined;
382
+ const roots = [devServer.config.root, devServer.config.publicDir].filter(
383
+ Boolean,
384
+ );
409
385
  return roots.some((root) => {
410
386
  const file = resolve(root, `.${pathname}`);
411
387
  return (
@@ -0,0 +1,53 @@
1
+ // Nuxt UI's `useState` outside Nuxt keeps its state in one object for the
2
+ // whole module: on a server, for the whole process. A toast one render adds
3
+ // (`useToast()` keeps its list there) would then show in every later render,
4
+ // whoever asks for the page, until the server restarts. Nuxt UI's stub keeps
5
+ // its state in the DMS's per-application state instead, as Nuxt's own
6
+ // `useState` does, and the build fails if the stub changes form or another
7
+ // Nuxt UI file defines `useState`, rather than letting the leak come back
8
+ // unnoticed with a Nuxt UI release.
9
+
10
+ /** Nuxt UI's stub of Nuxt's composables, which defines `useState`. */
11
+ const STUB = /\/@nuxt\/ui\/dist\/runtime\/vue\/stubs\/base\.js$/;
12
+ /** `useState` as Nuxt UI 4 defines it: a module-wide record of refs. */
13
+ const MODULE_STATE =
14
+ /const state = \{\};\s*export const useState = \(key, init\) => \{\s*if \(state\[key\]\) \{\s*return state\[key\];\s*\}\s*const value = ref\(init\(\)\);\s*state\[key\] = value;\s*return value;\s*\};/;
15
+ /** A definition of `useState`, wherever Nuxt UI moves it. */
16
+ const DEFINES_USE_STATE = /export\s+(?:const|function|let|var)\s+useState\b/;
17
+ const APPLICATION_STATE =
18
+ 'import { useDmsState } from "#dms/frontend-module";\n' +
19
+ "export const useState = (key, init) => useDmsState(`nuxt-ui:${key}`, init);";
20
+
21
+ /**
22
+ * The rewrite, as a Vite plugin.
23
+ *
24
+ * @param isNuxtUiFile `(id) => boolean`, whether a module is Nuxt UI's
25
+ */
26
+ export function uiRequestState(isNuxtUiFile) {
27
+ return {
28
+ name: "dms-ui-request-state",
29
+ enforce: "pre",
30
+ transform(code, id) {
31
+ const path = id.replaceAll("\\", "/").split("?")[0];
32
+ if (!isNuxtUiFile(id)) return null;
33
+ if (!STUB.test(path)) {
34
+ if (DEFINES_USE_STATE.test(code))
35
+ this.error(
36
+ `${id} defines useState outside the stub the DMS rewrites: a ` +
37
+ "server render could show another request's state. Update " +
38
+ "ui-request-state.mjs for this Nuxt UI release.",
39
+ );
40
+ return null;
41
+ }
42
+ if (!MODULE_STATE.test(code)) {
43
+ if (!code.includes("useState")) return null;
44
+ this.error(
45
+ `${id} defines useState in a form the DMS does not rewrite: a ` +
46
+ "server render could show another request's state. Update " +
47
+ "ui-request-state.mjs for this Nuxt UI release.",
48
+ );
49
+ }
50
+ return { code: code.replace(MODULE_STATE, APPLICATION_STATE), map: null };
51
+ },
52
+ };
53
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "vite";
12
12
  import { rejectServerSourcesInClient } from "./client-server-guard.mjs";
13
13
  import { uiInertiaPage } from "./ui-inertia-page.mjs";
14
+ import { uiRequestState } from "./ui-request-state.mjs";
14
15
  import { autoImportDirectories } from "./frontend-build-loader";
15
16
  import { frontendBuilds } from "./frontend-builds.generated";
16
17
  import { orderHeadForFirstPaint } from "./head-order.mjs";
@@ -64,6 +65,12 @@ const importDirectories = autoImportDirectories(
64
65
  frontendBuilds,
65
66
  (message) => logger.warn(message),
66
67
  );
68
+ /**
69
+ * Dependencies pre-bundled by name. A module's own dependencies need no entry:
70
+ * the startup crawl below (`optimizerEntries`) finds them in its sources. A
71
+ * name no module installs any more only earns a "Failed to resolve
72
+ * dependency" warning at every start.
73
+ */
67
74
  const optimizedDependencies = [
68
75
  "vue",
69
76
  "@inertiajs/vue3",
@@ -73,7 +80,6 @@ const optimizedDependencies = [
73
80
  "@vueuse/core",
74
81
  "reka-ui",
75
82
  "@internationalized/date",
76
- "vuedraggable",
77
83
  "vue3-apexcharts",
78
84
  "apexcharts",
79
85
  "tailwind-variants",
@@ -130,7 +136,9 @@ const uiInertiaLinkImport = resolve(
130
136
  * Inertia keeps a single page for the whole process, written by each server
131
137
  * render as it starts: a link rendered after an await could take another
132
138
  * request's URL to set its active state. Their `usePage()` is the DMS one,
133
- * which gives the page of the request being rendered.
139
+ * which gives the page of the request being rendered. Likewise, their
140
+ * `useState()` (the toasts among others) is the DMS's per-application state,
141
+ * not one record for the whole process.
134
142
  */
135
143
  const isNuxtUiFile = (id: string) =>
136
144
  normalizePath(id).includes("/node_modules/@nuxt/ui/");
@@ -149,6 +157,7 @@ export default defineConfig({
149
157
  },
150
158
  },
151
159
  uiInertiaPage(isNuxtUiFile),
160
+ uiRequestState(isNuxtUiFile),
152
161
  vue(),
153
162
  ui({
154
163
  router: "inertia",