@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.
package/dist/config.js CHANGED
@@ -56,6 +56,7 @@ exports.TEMPLATE_FILES = [
56
56
  ["head-order.mjs", "head-order.mjs"],
57
57
  ["client-server-guard.mjs", "client-server-guard.mjs"],
58
58
  ["ui-inertia-page.mjs", "ui-inertia-page.mjs"],
59
+ ["ui-request-state.mjs", "ui-request-state.mjs"],
59
60
  ["icon-api.mjs", "icon-api.mjs"],
60
61
  ["icon-scan.mjs", "icon-scan.mjs"],
61
62
  ["email-renderer.ts", "email-renderer.ts"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -15,6 +15,10 @@
15
15
  "vite",
16
16
  "vue"
17
17
  ],
18
+ "homepage": "https://dms.antelopejs.com/docs/frontend",
19
+ "bugs": {
20
+ "url": "https://github.com/AntelopeJS/dms-frontend/issues"
21
+ },
18
22
  "license": "Apache-2.0",
19
23
  "repository": {
20
24
  "type": "git",
@@ -26,7 +30,8 @@
26
30
  "files": [
27
31
  "dist/",
28
32
  "!dist/.tsbuildinfo",
29
- "templates/"
33
+ "templates/",
34
+ "skills/"
30
35
  ],
31
36
  "main": "./dist/common.js",
32
37
  "types": "./dist/common.d.ts",
@@ -59,11 +64,13 @@
59
64
  "test": "node --import tsx --test tests/*.test.ts tests/*.test.mts",
60
65
  "test:real-source": "pnpm run build && node dist/verify-source-runner.js",
61
66
  "format": "oxfmt .",
62
- "lint": "oxlint && oxfmt --check . && antelopejs-check-interface-ranges",
67
+ "lint": "oxlint && oxfmt --check . && antelopejs-check-interface-ranges && pnpm run docs:reference:check",
63
68
  "typecheck": "tsc --noEmit",
64
69
  "lint:fix": "oxlint --fix --max-warnings=1000000 && oxfmt .",
65
70
  "knip": "knip --dependencies",
66
- "knip:all": "knip"
71
+ "knip:all": "knip",
72
+ "docs:reference": "tsx scripts/docs-reference.ts",
73
+ "docs:reference:check": "tsx scripts/docs-reference.ts --check"
67
74
  },
68
75
  "dependencies": {
69
76
  "chokidar": "^4.0.3",
@@ -73,8 +80,8 @@
73
80
  "unzipper": "^0.12.3"
74
81
  },
75
82
  "devDependencies": {
76
- "@antelopejs/core": "^1.13.2",
77
- "@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
83
+ "@antelopejs/core": "^1.16.0",
84
+ "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
78
85
  "@iconify/utils": "^3.1.0",
79
86
  "@iconify/vue": "^5.0.1",
80
87
  "@inertiajs/vue3": "^3.7.1",
@@ -102,7 +109,7 @@
102
109
  "vue-i18n": "^11.2.7"
103
110
  },
104
111
  "peerDependencies": {
105
- "@antelopejs/core": ">=1.13.2 <2"
112
+ "@antelopejs/core": ">=1.16.0 <2"
106
113
  },
107
114
  "optionalDependencies": {
108
115
  "@oxc-parser/binding-darwin-arm64": "^0.95.0",
@@ -114,5 +121,10 @@
114
121
  "engines": {
115
122
  "node": ">=20.12.0"
116
123
  },
117
- "packageManager": "pnpm@10.6.5"
124
+ "packageManager": "pnpm@10.6.5",
125
+ "antelopeJs": {
126
+ "skills": [
127
+ "./skills"
128
+ ]
129
+ }
118
130
  }
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: dms-frontend
3
+ description: Runs, builds, deploys and extends the AntelopeJS DMS frontend with @antelopejs/dms-frontend (the ajs dms CLI, the Vue renderer, the frontend-module SDK). Use when running ajs dms dev, build, start, prepare, workspaces, clean or verify-source; configuring DMS_* environment variables, the session or bootstrap secret; writing a frontend module's dms.frontend.ts, dms.server.ts (server routes, auth hooks, route rules, backend proxies) or dms.frontend.build.ts; declaring engines["@antelopejs/dms-frontend"]; deploying the frontend server behind a reverse proxy; or debugging a refused range, a missing secret, a 404 from the proxy or a stale workspace.
4
+ icon: i-lucide-app-window
5
+ category: tooling
6
+ tags: [frontend, cli, renderer, vue, inertia, ssr, deployment, dms-module]
7
+ ---
8
+
9
+ # dms-frontend
10
+
11
+ `@antelopejs/dms-frontend` is the DMS frontend: the `ajs dms` CLI, the Vue renderer it generates, and the SDK frontend modules register through. The full guide is at <https://dms.antelopejs.com/docs/frontend>; this skill holds what the `--help` output does not say. For starting and stopping the backend and frontend of a project, use the **dms-dev** skill; for the dashboard pages and blocks, **dms-pages**.
12
+
13
+ ## Look things up first
14
+
15
+ - `ajs dms <command> --help` gives every option, its variable and examples.
16
+ - `ajs dms help environment` lists every variable the CLI and the server read.
17
+ - `ajs dms workspaces` shows which workspaces exist, keyed on what, and how big.
18
+
19
+ ## Rules that bite
20
+
21
+ - **One workspace per backend URL**, under `~/.antelopejs/dms-frontend/<sha256>/`. `dev` without `-b` keys its own on the project directory instead, so `dev` then `build -b` builds a second workspace. `start` serves the workspace `build` made for the same URL, user and home directory.
22
+ - **Secrets.** `build` and `start` need the same `DMS_SESSION_SECRET` (32+ characters); `dev` generates one only when it is unset. `build` takes `DMS_BOOTSTRAP_SECRET` explicitly, never from `.antelope/dms-dev.json`. A build without it still succeeds, but loses the modules' private options, the manifest's backend proxies and the e-mail and OAuth secrets: `start` then warns that e-mail renders and OAuth sign-in will be refused.
23
+ - **`.env`** is read from the current directory only (shell, then `.env.local`, then `.env`). A `DMS_API_BASE_URL` in `.env` turns off backend discovery for `dev`. `clean` never reads it.
24
+ - **`prepare` never fails** without `--strict`: it warns and exits 0. Use `--strict` where the types are required.
25
+ - **Exit codes:** 0 success, 1 failure, 2 usage error, 130 Ctrl+C.
26
+
27
+ ## Writing a frontend module
28
+
29
+ - The entry is `dms.frontend.ts`, default-exporting `{ componentPrefix?, setup(sdk) }`. `setup` only registers: under `dev` it runs again on every layer change.
30
+ - Set a `componentPrefix`; the backend names a component by its full name (`CustomComponent("AcmeBanner")`). Replace another module's component with `registerComponent(fullName, c, { prefix: false })`.
31
+ - Nothing is auto-imported unless `dms.frontend.build.ts` declares it; `app/build/` never is.
32
+ - Server routes, auth hooks, route rules and backend proxies go in `dms.server.ts` (`defineDmsServer` from `#dms/server`). It is trusted Node.js code: import what it uses, never `.vue` files or `dms.frontend.ts`.
33
+ - The browser reaches the backend only through declared prefixes (`registerBackendProxy`, `backendProxies`, `DMS_BACKEND_PROXIES`). An undeclared backend route answers `404`; in development, the `x-dms-route-owner` header names who answered.
34
+ - Declare `engines["@antelopejs/dms-frontend"]`, capped below the next minor (`">=0.6.0 <0.7.0"`); a range that excludes the installed loader stops `dev`, `build` and `verify-source`.
35
+ - Give every server-rendered `useDmsAsyncData` / `useDmsFetch` call a key, and replace `data` instead of mutating it (shallow by default).
36
+ - Check a module with `ajs dms verify-source` from the project: no backend needed.
37
+
38
+ ## Where the details are
39
+
40
+ | Task | Page |
41
+ | --- | --- |
42
+ | Compatibility with DMS releases | `/docs/frontend/introduction#compatibility` |
43
+ | One command in depth | `/docs/frontend/cli/<command>` |
44
+ | Variables and secrets | `/docs/frontend/configuration` |
45
+ | Module files and SDK | `/docs/frontend/frontend-modules/anatomy`, then `server-routes`, `sessions-and-sign-in`, `route-rules`, `emails` |
46
+ | SSR, navigation, data, icons | `/docs/frontend/rendering/model`, `navigation`, `data-loading`, `icons` |
47
+ | Production, proxy, security | `/docs/frontend/deployment/production`, `routing-and-proxy`, `public-endpoints`, `security` |
48
+ | An error message | `/docs/frontend/troubleshooting` |
49
+
50
+ The same pages ship in this package's repository under `docs/`.
@@ -384,6 +384,14 @@ export interface DmsMiddlewareRegistrationOptions {
384
384
  }
385
385
  export interface DmsPluginRegistrationOptions {
386
386
  clientOnly?: boolean;
387
+ /**
388
+ * When the plugin runs, as Nuxt's `enforce`: every module's `"pre"`
389
+ * plugins first, then the `"default"` ones, then the `"post"` ones, each
390
+ * group in the modules' priority order. A plugin that the others depend on
391
+ * whatever their module's priority, such as the one setting the async data
392
+ * scope (`setDmsAsyncDataScope`), is `"pre"`.
393
+ */
394
+ enforce?: "pre" | "default" | "post";
387
395
  }
388
396
  export type DmsComponentPreloader = () => Promise<unknown>;
389
397
  export interface DmsComponentRegistrationOptions {
@@ -494,8 +502,11 @@ interface DmsAsyncComponent {
494
502
  interface DmsPluginRegistration {
495
503
  setup: DmsPluginSetup;
496
504
  clientOnly: boolean;
505
+ enforce: NonNullable<DmsPluginRegistrationOptions["enforce"]>;
497
506
  }
498
507
 
508
+ const PLUGIN_ORDER = { pre: 0, default: 1, post: 2 } as const;
509
+
499
510
  /**
500
511
  * What the frontend modules registered through their SDK. Modules are set up
501
512
  * by descending priority, so the first one to claim a name keeps it.
@@ -2263,7 +2274,9 @@ export async function refreshDmsPageData(): Promise<void> {
2263
2274
  * show their default until they load again. Call it on both sides before
2264
2275
  * the page renders, with the same value, so the browser finds the data the
2265
2276
  * server transferred; the scope is written in the page with each key, so a
2266
- * digest rather than an identifier.
2277
+ * digest rather than an identifier. Call it from a plugin registered with
2278
+ * `{ enforce: "pre" }`: a plugin of another module that reads data at setup
2279
+ * would otherwise run first, and the new scope abort its read.
2267
2280
  *
2268
2281
  * @param scope The scope, or `undefined` for none
2269
2282
  */
@@ -3220,6 +3233,7 @@ function createSdk(
3220
3233
  target.pluginSetups.push({
3221
3234
  setup,
3222
3235
  clientOnly: options.clientOnly ?? false,
3236
+ enforce: options.enforce ?? "default",
3223
3237
  }),
3224
3238
  registerMiddleware: (name, handler, options = {}) => {
3225
3239
  if (!target.namedMiddleware.has(name))
@@ -3361,7 +3375,11 @@ export async function installDmsPlugins(
3361
3375
  registry.plugins.forEach((plugin) => {
3362
3376
  app.use(plugin);
3363
3377
  });
3364
- for (const registration of registry.pluginSetups) {
3378
+ // Stable: within a group, the modules' priority order stays.
3379
+ const pluginSetups = [...registry.pluginSetups].sort(
3380
+ (left, right) => PLUGIN_ORDER[left.enforce] - PLUGIN_ORDER[right.enforce],
3381
+ );
3382
+ for (const registration of pluginSetups) {
3365
3383
  if (registration.clientOnly && typeof window === "undefined") continue;
3366
3384
  await runtime.scope.run(() =>
3367
3385
  app.runWithContext(() => registration.setup(appContext)),
@@ -57,27 +57,40 @@ if (import.meta.env.DEV) adoptViteDevStyles();
57
57
  // the generated server answers those queries from the installed collections.
58
58
  addAPIProvider("", sameOriginIconProvider(location.origin));
59
59
 
60
- await setupFrontendModules(frontendModules, appConfigs);
61
- // A layer's app config edited during development reaches the page without a
62
- // reload. A server render imports the new configs instead.
63
- syncAppConfigs(applyLayerAppConfigs);
60
+ /**
61
+ * Sets the modules up, then starts the app.
62
+ *
63
+ * A function rather than top-level statements: Rollup keeps apart every module
64
+ * the entry shares with a dynamic import when the entry awaits at its top level
65
+ * (merging them could deadlock that await). Every page and component is such
66
+ * an import, so the bundle split the entry into a hundred one-module chunks the
67
+ * browser requested one by one.
68
+ */
69
+ async function startDmsApp(): Promise<void> {
70
+ await setupFrontendModules(frontendModules, appConfigs);
71
+ // A layer's app config edited during development reaches the page without a
72
+ // reload. A server render imports the new configs instead.
73
+ syncAppConfigs(applyLayerAppConfigs);
74
+
75
+ createInertiaApp({
76
+ resolve: resolveDmsInertiaPage,
77
+ async setup({ App, el, props, plugin }) {
78
+ const root = { render: () => h(App, props) };
79
+ const app = el.hasAttribute("data-server-rendered")
80
+ ? createSSRApp(root)
81
+ : createApp(root);
82
+ const configured = await configureDmsApp({
83
+ app,
84
+ head: createHead(),
85
+ initialPageProps: props.initialPage.props,
86
+ initialPageUrl: props.initialPage.url,
87
+ inertiaPlugin: plugin,
88
+ });
89
+ app.mount(el);
90
+ markDmsReady();
91
+ await configured.mounted();
92
+ },
93
+ });
94
+ }
64
95
 
65
- createInertiaApp({
66
- resolve: resolveDmsInertiaPage,
67
- async setup({ App, el, props, plugin }) {
68
- const root = { render: () => h(App, props) };
69
- const app = el.hasAttribute("data-server-rendered")
70
- ? createSSRApp(root)
71
- : createApp(root);
72
- const configured = await configureDmsApp({
73
- app,
74
- head: createHead(),
75
- initialPageProps: props.initialPage.props,
76
- initialPageUrl: props.initialPage.url,
77
- inertiaPlugin: plugin,
78
- });
79
- app.mount(el);
80
- markDmsReady();
81
- await configured.mounted();
82
- },
83
- });
96
+ void startDmsApp();
@@ -0,0 +1,48 @@
1
+ // The built files the production server hands to a browser: what the client
2
+ // build wrote to `dist/client`, except a file or directory whose name starts
3
+ // with a dot. Vite keeps its build metadata there (`.vite/manifest.json`, the
4
+ // source path of every module file), and a module's `public/` may hold dot
5
+ // files too. `/.well-known/`, which RFC 8615 reserves for files meant to be
6
+ // public, stays served.
7
+
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { relative, resolve, sep } from "node:path";
10
+
11
+ const PUBLIC_DOT_DIRECTORY = ".well-known";
12
+
13
+ /**
14
+ * Whether a path relative to the build root may be served: no segment starts
15
+ * with a dot, but a leading `.well-known`. Split on both separators: on
16
+ * Windows, `relative` answers with backslashes, and a decoded `%5C` is one.
17
+ */
18
+ export function isPublicBuildPath(relativePath) {
19
+ return relativePath
20
+ .split(/[\\/]/)
21
+ .every(
22
+ (segment, index) =>
23
+ !segment.startsWith(".") ||
24
+ (index === 0 && segment === PUBLIC_DOT_DIRECTORY),
25
+ );
26
+ }
27
+
28
+ /**
29
+ * The file under `root` a request path names, or `undefined` when the server
30
+ * serves none there: the path is malformed, leaves `root`, names a dot file or
31
+ * directory, or is not a regular file. Decoded first, as the browser encoded
32
+ * it, so the file checked is the file served.
33
+ */
34
+ export function publicBuildFile(root, pathname) {
35
+ let decoded;
36
+ try {
37
+ decoded = decodeURIComponent(pathname);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ const base = resolve(root);
42
+ const file = resolve(base, `.${decoded}`);
43
+ // Native separator: `resolve` answers with backslashes on Windows.
44
+ if (!file.startsWith(`${base}${sep}`)) return undefined;
45
+ if (!isPublicBuildPath(relative(base, file))) return undefined;
46
+ if (!existsSync(file) || !statSync(file).isFile()) return undefined;
47
+ return file;
48
+ }
@@ -6,23 +6,63 @@ import {
6
6
  constants as zlibConstants,
7
7
  } from "node:zlib";
8
8
 
9
- const MINIMUM_COMPRESSION_BYTES = 1_024;
9
+ /** Below this size, compressing saves less than it costs. */
10
+ export const MINIMUM_COMPRESSION_BYTES = 1_024;
10
11
  const DYNAMIC_BROTLI_QUALITY = 4;
11
12
 
12
- function responseEncoder(request, content) {
13
- if (Buffer.byteLength(content) < MINIMUM_COMPRESSION_BYTES) return undefined;
13
+ /**
14
+ * A compressor for a body generated or relayed now, in the encoding the client
15
+ * accepts (Brotli first), or `undefined` when it accepts neither. `streaming`
16
+ * flushes what each write gives at once, for a body written over time: the
17
+ * client gets each part when it is written, not when a block fills.
18
+ */
19
+ export function acceptedEncoder(request, { streaming = false } = {}) {
14
20
  const accepted = request.headers["accept-encoding"] ?? "";
15
- if (accepted.includes("br"))
21
+ const qualities = new Map();
22
+ for (const entry of accepted.split(",")) {
23
+ const [token, ...parameters] = entry.trim().toLowerCase().split(";");
24
+ if (!token) continue;
25
+ const parameter = parameters.find((value) => /^\s*q\s*=/.test(value));
26
+ const value = parameter?.split("=")[1].trim();
27
+ const quality =
28
+ value === undefined
29
+ ? 1
30
+ : /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(value)
31
+ ? Number(value)
32
+ : 0;
33
+ qualities.set(
34
+ token.trim(),
35
+ Math.min(qualities.get(token.trim()) ?? 1, quality),
36
+ );
37
+ }
38
+ const quality = (token) => qualities.get(token) ?? qualities.get("*") ?? 0;
39
+ const br = quality("br");
40
+ const gzip = quality("gzip");
41
+ // Implicit identity is a fallback, not a preference over compression.
42
+ const identity = qualities.get("identity") ?? 0;
43
+ if (identity > Math.max(br, gzip)) return undefined;
44
+ if (br > 0 && br >= gzip)
16
45
  return {
17
46
  encoding: "br",
18
47
  stream: createBrotliCompress({
48
+ ...(streaming && { flush: zlibConstants.BROTLI_OPERATION_FLUSH }),
19
49
  params: {
20
50
  [zlibConstants.BROTLI_PARAM_QUALITY]: DYNAMIC_BROTLI_QUALITY,
21
51
  },
22
52
  }),
23
53
  };
24
- if (accepted.includes("gzip"))
25
- return { encoding: "gzip", stream: createGzip() };
54
+ if (gzip > 0)
55
+ return {
56
+ encoding: "gzip",
57
+ stream: createGzip(
58
+ streaming ? { flush: zlibConstants.Z_SYNC_FLUSH } : {},
59
+ ),
60
+ };
61
+ }
62
+
63
+ function responseEncoder(request, content) {
64
+ if (Buffer.byteLength(content) < MINIMUM_COMPRESSION_BYTES) return undefined;
65
+ return acceptedEncoder(request);
26
66
  }
27
67
 
28
68
  /** Send a generated body, compressed when the client accepts it. */
@@ -4,7 +4,9 @@
4
4
  // dev server does and follows every template edit the layer watchers mirror
5
5
  // into the workspace. It lives in its own process so its builds never hold the
6
6
  // dev server's event loop, and it reports each build over IPC so the server
7
- // renders from a finished bundle only.
7
+ // renders from a finished bundle only. A build empties `dist/server` first, so
8
+ // it asks the server before it starts and waits for the go-ahead, which the
9
+ // server gives once no render reads the previous bundle any more.
8
10
 
9
11
  import { fileURLToPath } from "node:url";
10
12
  import { build } from "vite";
@@ -15,17 +17,36 @@ const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
15
17
  // server ended, there is nobody left to build for.
16
18
  process.on("disconnect", () => process.exit(0));
17
19
 
20
+ let startBuild;
21
+ process.on("message", (message) => {
22
+ if (message?.state !== "build") return;
23
+ startBuild?.();
24
+ startBuild = undefined;
25
+ });
26
+
27
+ /** Holds each build until the server lets it replace the bundle. */
28
+ const serverHandoff = {
29
+ name: "dms-email-bundle-handoff",
30
+ buildStart() {
31
+ if (!process.send) return;
32
+ return new Promise((start) => {
33
+ startBuild = start;
34
+ process.send({ state: "building" });
35
+ });
36
+ },
37
+ };
38
+
18
39
  const watcher = await build({
19
40
  root: PROJECT_ROOT,
20
41
  configFile: fileURLToPath(
21
42
  new URL("../vite.email.config.ts", import.meta.url),
22
43
  ),
23
44
  logLevel: "warn",
45
+ plugins: [serverHandoff],
24
46
  build: { watch: {} },
25
47
  });
26
48
 
27
49
  watcher.on("event", (event) => {
28
- if (event.code === "START") process.send?.({ state: "building" });
29
- else if (event.code === "END") process.send?.({ state: "ready" });
50
+ if (event.code === "END") process.send?.({ state: "ready" });
30
51
  else if (event.code === "ERROR") process.send?.({ state: "failed" });
31
52
  });
@@ -10,8 +10,7 @@ const EMAIL_BUNDLE_WATCH_URL = new URL(
10
10
  "./email-bundle-watch.mjs",
11
11
  import.meta.url,
12
12
  );
13
- // A render waits this long for a build in progress, then tries the bundle
14
- // already on disk rather than holding the backend's request indefinitely.
13
+ // Bound the request's wait, but never read a bundle being replaced.
15
14
  const EMAIL_BUILD_WAIT_MS = 60_000;
16
15
 
17
16
  // Settled whenever no email build is running. The version changes with each
@@ -20,6 +19,11 @@ const EMAIL_BUILD_WAIT_MS = 60_000;
20
19
  let emailBuild = Promise.resolve();
21
20
  let settleEmailBuild;
22
21
  let emailBundleVersion = 0;
22
+ // A build empties dist/server before it writes the new bundle, so it never
23
+ // starts while a render still imports from the previous one: the builder asks
24
+ // first, and gets the go-ahead once the renders in progress have finished.
25
+ let rendersInProgress = 0;
26
+ let startPendingBuild;
23
27
 
24
28
  function emailBuildStarted() {
25
29
  if (settleEmailBuild) return;
@@ -30,10 +34,24 @@ function emailBuildStarted() {
30
34
 
31
35
  function emailBuildSettled() {
32
36
  emailBundleVersion += 1;
37
+ startPendingBuild = undefined;
33
38
  settleEmailBuild?.();
34
39
  settleEmailBuild = undefined;
35
40
  }
36
41
 
42
+ function startBuildOnceRendersFinish(start) {
43
+ if (rendersInProgress) startPendingBuild = start;
44
+ else start();
45
+ }
46
+
47
+ function renderFinished() {
48
+ rendersInProgress -= 1;
49
+ if (rendersInProgress || !startPendingBuild) return;
50
+ const start = startPendingBuild;
51
+ startPendingBuild = undefined;
52
+ start();
53
+ }
54
+
37
55
  /**
38
56
  * Build the email bundle now and again on every template change, for
39
57
  * development: only the production build otherwise produces it. The builder
@@ -51,8 +69,14 @@ export function watchEmailBundle() {
51
69
  // Not permitted on this platform; the build only runs at normal priority.
52
70
  }
53
71
  builder.on("message", (message) => {
54
- if (message?.state === "building") emailBuildStarted();
55
- else emailBuildSettled();
72
+ if (message?.state !== "building") {
73
+ emailBuildSettled();
74
+ return;
75
+ }
76
+ emailBuildStarted();
77
+ startBuildOnceRendersFinish(() => {
78
+ if (builder.connected) builder.send({ state: "build" });
79
+ });
56
80
  });
57
81
  builder.on("exit", (code) => {
58
82
  emailBuildSettled();
@@ -61,15 +85,25 @@ export function watchEmailBundle() {
61
85
  return builder;
62
86
  }
63
87
 
64
- async function loadEmailRenderer() {
65
- let timer;
66
- await Promise.race([
67
- emailBuild,
68
- new Promise((settle) => {
69
- timer = setTimeout(settle, EMAIL_BUILD_WAIT_MS);
70
- }),
71
- ]);
72
- clearTimeout(timer);
88
+ async function waitForEmailBuild() {
89
+ const deadline = Date.now() + EMAIL_BUILD_WAIT_MS;
90
+ // Another build may start while a render waits for the previous one.
91
+ while (settleEmailBuild && Date.now() < deadline) {
92
+ let timer;
93
+ await Promise.race([
94
+ emailBuild,
95
+ new Promise((settle) => {
96
+ timer = setTimeout(settle, deadline - Date.now());
97
+ }),
98
+ ]);
99
+ clearTimeout(timer);
100
+ }
101
+ if (settleEmailBuild) {
102
+ throw new Error("Timed out waiting for the email bundle build");
103
+ }
104
+ }
105
+
106
+ function loadEmailRenderer() {
73
107
  const url = new URL(EMAIL_RENDERER_URL);
74
108
  if (emailBundleVersion) url.searchParams.set("v", emailBundleVersion);
75
109
  return import(url.href);
@@ -84,6 +118,15 @@ async function loadEmailRenderer() {
84
118
  * @param options `locale`, the language to render in
85
119
  */
86
120
  export async function renderEmailTemplate(template, props, options = {}) {
87
- const { renderEmail } = await loadEmailRenderer();
88
- return renderEmail(template, props, options);
121
+ await waitForEmailBuild();
122
+ // Counted from here, with no await in between, so a build the builder asks
123
+ // for now waits for this render, which imports templates and reads catalogs
124
+ // from the bundle until it ends.
125
+ rendersInProgress += 1;
126
+ try {
127
+ const { renderEmail } = await loadEmailRenderer();
128
+ return await renderEmail(template, props, options);
129
+ } finally {
130
+ renderFinished();
131
+ }
89
132
  }
@@ -0,0 +1,101 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { acceptedEncoder, MINIMUM_COMPRESSION_BYTES } from "./content.mjs";
4
+ import { writeProxyHeaders } from "./proxy-headers.mjs";
5
+
6
+ /**
7
+ * Asks the backend for its answer uncompressed. fetch decodes a compressed
8
+ * answer but keeps its Content-Encoding and Content-Length, which then
9
+ * described bytes the client never got: the proxy compresses for the client
10
+ * itself (see proxyEncoder).
11
+ */
12
+ export const PLAIN_ANSWER_HEADERS = { "accept-encoding": "identity" };
13
+
14
+ // Text the backend sends uncompressed: JSON above all, which the antelope API
15
+ // module never compresses. An event stream is left as it is: its events are
16
+ // small and many, and proxies between here and the browser expect it plain.
17
+ const COMPRESSIBLE_TYPE =
18
+ /^(?:text\/(?!event-stream)[\w.+-]+|application\/(?:[\w.-]+\+)?(?:json|xml|javascript|ecmascript)|image\/svg\+xml)\s*(?:;|$)/i;
19
+ // Statuses that carry no body to compress.
20
+ const BODILESS_STATUSES = new Set([204, 205, 304]);
21
+
22
+ /** Whether the backend answer is one to compress on its way to the client. */
23
+ function isCompressible(request, upstream) {
24
+ if (request.method === "HEAD" || !upstream.body) return false;
25
+ if (BODILESS_STATUSES.has(upstream.status)) return false;
26
+ const headers = upstream.headers;
27
+ // Already encoded, or a range of the bytes: compressing would change what
28
+ // the encoding or the range describes.
29
+ const encoding = headers.get("content-encoding");
30
+ if (encoding && encoding.toLowerCase() !== "identity") return false;
31
+ if (upstream.status === 206 || headers.has("content-range")) return false;
32
+ if (
33
+ /(?:^|,)\s*no-transform\s*(?:,|$)/i.test(headers.get("cache-control") ?? "")
34
+ )
35
+ return false;
36
+ if (!COMPRESSIBLE_TYPE.test(headers.get("content-type") ?? "")) return false;
37
+ const length = headers.get("content-length");
38
+ return length === null || Number(length) >= MINIMUM_COMPRESSION_BYTES;
39
+ }
40
+
41
+ /**
42
+ * The compressor for a backend answer the proxy relays, in the encoding the
43
+ * client accepts, or `undefined` to relay it as it came.
44
+ */
45
+ function proxyEncoder(request, upstream) {
46
+ // Streaming: an answer of unknown length (chunked JSON, streamed text)
47
+ // reaches the client part by part, as the backend writes it.
48
+ return isCompressible(request, upstream)
49
+ ? acceptedEncoder(request, { streaming: true })
50
+ : undefined;
51
+ }
52
+
53
+ /** Relays a backend answer to the client, compressed when it should be. */
54
+ export async function relayAnswer(
55
+ request,
56
+ response,
57
+ upstream,
58
+ { fetched = false } = {},
59
+ ) {
60
+ // Only native fetch bodies are decoded. Synthetic/opaque responses may
61
+ // still contain the encoded bytes, and unknown coding chains stay opaque.
62
+ const codings = (upstream.headers.get("content-encoding") ?? "")
63
+ .toLowerCase()
64
+ .split(",")
65
+ .map((coding) => coding.trim());
66
+ if (
67
+ fetched &&
68
+ request.method !== "HEAD" &&
69
+ upstream.body &&
70
+ !BODILESS_STATUSES.has(upstream.status) &&
71
+ codings.every((coding) =>
72
+ ["gzip", "x-gzip", "deflate", "br"].includes(coding),
73
+ )
74
+ ) {
75
+ // A decoded partial representation cannot retain the encoded byte offsets.
76
+ // Do not manufacture a complete 200 response from a partial 206 either.
77
+ if (upstream.status === 206 || upstream.headers.has("content-range")) {
78
+ await upstream.body.cancel();
79
+ response.writeHead(502);
80
+ response.end();
81
+ return;
82
+ }
83
+ const headers = new Headers(upstream.headers);
84
+ headers.delete("content-encoding");
85
+ headers.delete("content-length");
86
+ const etag = headers.get("etag");
87
+ if (etag && !etag.startsWith("W/")) headers.set("etag", `W/${etag}`);
88
+ upstream = { status: upstream.status, headers, body: upstream.body };
89
+ }
90
+ const encoder = proxyEncoder(request, upstream);
91
+ writeProxyHeaders(
92
+ response,
93
+ upstream,
94
+ encoder?.encoding,
95
+ isCompressible(request, upstream),
96
+ );
97
+ if (!upstream.body || request.method === "HEAD") return response.end();
98
+ const body = Readable.fromWeb(upstream.body);
99
+ if (encoder) await pipeline(body, encoder.stream, response);
100
+ else await pipeline(body, response);
101
+ }