@antelopejs/dms-frontend 0.6.0 → 0.6.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.
- package/README.md +55 -939
- package/dist/config.js +1 -0
- package/dist/module-set.js +5 -1
- package/package.json +17 -5
- package/skills/dms-frontend/SKILL.md +50 -0
- package/templates/vue/frontend-module.ts +20 -2
- package/templates/vue/main.ts +36 -23
- package/templates/vue/server/built-files.mjs +48 -0
- package/templates/vue/server/content.mjs +46 -6
- package/templates/vue/server/email-bundle-watch.mjs +24 -3
- package/templates/vue/server/email.mjs +58 -15
- package/templates/vue/server/proxy-compression.mjs +101 -0
- package/templates/vue/server/proxy-headers.mjs +29 -2
- package/templates/vue/server/static-files.mjs +109 -0
- package/templates/vue/server.mjs +26 -50
- package/templates/vue/ui-request-state.mjs +53 -0
- package/templates/vue/vite.config.ts +11 -2
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/dist/module-set.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.MODULE_SET_POLL_INTERVAL_MS = 3000;
|
|
|
10
10
|
const READY_ENDPOINT = "/dms/frontend/ready";
|
|
11
11
|
const READY_TIMEOUT_MS = 5000;
|
|
12
12
|
const JSON_CONTENT_TYPE = "application/json";
|
|
13
|
+
const OWN_RELEASE = require("../package.json");
|
|
13
14
|
function moduleSetFingerprint(modules) {
|
|
14
15
|
return JSON.stringify([...modules]
|
|
15
16
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
@@ -69,7 +70,10 @@ async function reportFrontendReady(backendUrl, moduleNames, bootstrapSecret) {
|
|
|
69
70
|
...(0, workspace_1.bootstrapHeaders)(bootstrapSecret),
|
|
70
71
|
"content-type": JSON_CONTENT_TYPE,
|
|
71
72
|
},
|
|
72
|
-
body: JSON.stringify({
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
modules: moduleNames,
|
|
75
|
+
version: OWN_RELEASE.version,
|
|
76
|
+
}),
|
|
73
77
|
signal: AbortSignal.timeout(READY_TIMEOUT_MS),
|
|
74
78
|
});
|
|
75
79
|
return response.ok;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-frontend",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -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
|
-
|
|
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)),
|
package/templates/vue/main.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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 (
|
|
25
|
-
return {
|
|
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 === "
|
|
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
|
-
//
|
|
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
|
|
55
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
+
}
|