@antelopejs/dms-frontend 0.6.1 → 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 CHANGED
@@ -1,978 +1,94 @@
1
- # @antelopejs/dms-frontend
2
-
3
1
  <div align="center">
4
- <a href="./LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-blue?style=for-the-badge&labelColor=000000"></a>
5
- <a href="https://discord.gg/sjK28QHrA7"><img src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000" alt="Discord"></a>
6
- <a href="https://antelopejs.com"><img src="https://img.shields.io/badge/Docs-18181B?style=for-the-badge&color=000000" alt="Documentation"></a>
7
- </div>
8
2
 
9
- Frontend-agnostic loader for AntelopeJS DMS. The backend serves a frontend
10
- manifest and the matching frontend-module archives; the `ajs dms` CLI
11
- materializes them into a generated workspace for one renderer, builds it, and
12
- runs its Node frontend server. The `vue` renderer — Vue 3, Vite, Inertia, and
13
- SSR — is the one shipped today. The package itself installs a single executable,
14
- `ajs-dms`; `ajs dms` is the core CLI delegating to it, and is the name every
15
- project, script and document uses.
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/AntelopeJS/dms/main/packages/dms/frontend-vue/layers/dms-layout/public/images/antelope-logo/dark.svg">
5
+ <img alt="Antelope DMS" src="https://raw.githubusercontent.com/AntelopeJS/dms/main/packages/dms/frontend-vue/layers/dms-layout/public/images/antelope-logo/light.svg" height="72">
6
+ </picture>
16
7
 
17
- ## Renderers
18
-
19
- A renderer is the target framework a generated workspace is built for. The
20
- loader's contract with a renderer has three parts:
21
-
22
- - **Manifest negotiation.** `prepare`, `dev`, and `build` request the versioned
23
- `/dms/frontend` manifest with `renderer=vue&rendererVersion=3`
24
- (`src/manifest.ts`) and reject every module whose `renderer` does not match.
25
- The backend side is renderer-keyed too: `AddFrontendModule` takes a
26
- `renderer: { name, version }`, and each manifest module carries it back
27
- (`ManifestModule.renderer` in `src/workspace.ts`).
28
- - **Workspace templates.** Every file of the generated workspace comes from
29
- `templates/<renderer>/` — `templates/vue/` today. `TEMPLATE_FILES` in
30
- `src/config.ts` lists what is copied verbatim, and `src/materialize.ts`
31
- resolves the template root, copies it, materializes the frontend modules
32
- under `frontend-modules/`, and writes the files derived from them
33
- (`src/derived-outputs.ts`), `generated-frontend-modules.json` first, in
34
- manifest-priority order.
35
- - **Generated server.** `templates/vue/server.mjs` and `templates/vue/server/`
36
- become the Node server that `ajs dms start` runs from the built workspace:
37
- Inertia visits, backend proxying, sessions, and email rendering.
8
+ # @antelopejs/dms-frontend
38
9
 
39
- `vue` (version `3`) is the only renderer this package ships, and the loader
40
- rejects a manifest that declares any other. Renderers for other frameworks —
41
- React, Svelte, Solid — are a direction, not a promise: nothing in the package
42
- implements them yet. Adding one means a new `templates/<renderer>/` tree, plus
43
- making the template root (`src/materialize.ts`) and the manifest query
44
- (`src/manifest.ts`) renderer-aware instead of hardcoding `vue`. There is no
45
- `--renderer` flag and no renderer registry; the single-renderer assumption is
46
- deliberate until a second renderer exists. Whatever a renderer names its module
47
- entry is its own convention: `dms.frontend.ts` and the
48
- `#dms/frontend-module` alias belong to the Vue renderer, not to the
49
- loader.
10
+ <a href="https://www.npmjs.com/package/@antelopejs/dms-frontend"><img alt="npm" src="https://img.shields.io/npm/v/@antelopejs/dms-frontend?style=for-the-badge&labelColor=000000&color=000000"></a>
11
+ <a href="./LICENSE"><img alt="License" src="https://img.shields.io/npm/l/@antelopejs/dms-frontend?style=for-the-badge&labelColor=000000&color=000000"></a>
12
+ <a href="https://discord.gg/sjK28QHrA7"><img alt="Discord" src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000"></a>
13
+ <a href="https://dms.antelopejs.com/docs/frontend"><img alt="Documentation" src="https://img.shields.io/badge/Docs-18181B?style=for-the-badge&color=000000"></a>
50
14
 
51
- ## Application ownership
15
+ **The frontend of the AntelopeJS DMS: the `ajs dms` CLI, the Vue renderer and the frontend-module SDK.**
52
16
 
53
- The generated Inertia application owns page resolution, SSR, hydration, and the
54
- root Vue tree. Every frontend module explicitly owns its native
55
- `dms.frontend.ts` entry. That entry registers reusable pages, layouts,
56
- components, plugins, and middleware through the frontend SDK; the loader does
57
- not infer application ownership from other framework configuration.
17
+ [Documentation](https://dms.antelopejs.com/docs/frontend) · [DMS](https://github.com/AntelopeJS/dms) · [Modules](https://dms.antelopejs.com/docs/modules)
58
18
 
59
- ## Install
19
+ </div>
60
20
 
61
- The loader is an official AntelopeJS CLI plugin: install it next to
62
- `@antelopejs/core` and run it as `ajs dms <command>`. That is the only supported
63
- way to invoke it — in a shell, in a package script, in CI and in a container
64
- alike.
21
+ ![The browser talks to the frontend server, which renders pages, answers module routes and proxies declared backend prefixes to the DMS backend; at build time ajs dms fetches the manifest and writes a workspace](./docs/images/architecture.svg)
65
22
 
66
- ```bash
67
- # in a project (the usual case: both are already project dependencies)
68
- pnpm add @antelopejs/core @antelopejs/dms-frontend
23
+ ## What it does
69
24
 
70
- # or globally
71
- pnpm add -g @antelopejs/core @antelopejs/dms-frontend
72
- ```
25
+ - Asks a DMS backend which frontend modules it loads, materializes them into a generated workspace, builds it, and runs the frontend server.
26
+ - Renders the dashboard with Vue 3, Vite, Inertia and server-side rendering.
27
+ - Keeps the backend token on the server: the browser only holds a sealed, `HttpOnly` session cookie, and declared backend prefixes are proxied with the session's token.
28
+ - Lets a module extend the dashboard with pages, components and plugins (`dms.frontend.ts`), server routes (`dms.server.ts`) and build-time declarations (`dms.frontend.build.ts`).
29
+ - Serves development with hot reload, and production with pre-compressed assets.
73
30
 
74
- The plugin prints through the output module of `@antelopejs/core`
75
- (`@antelopejs/core/cli`), so it needs `@antelopejs/core` 1.13.2 or later.
76
- `npm install -g` works too; this repository and every generated workspace use
77
- pnpm. Inside a package script, `ajs` resolves from `node_modules/.bin`, and the
78
- `dms` command it delegates to resolves the project-local plugin, so a script
79
- never depends on a global install.
31
+ ## Install
80
32
 
81
- ## Commands
33
+ Add the package next to `@antelopejs/core`; the core `ajs` command runs it as `ajs dms`:
82
34
 
83
35
  ```bash
84
- ajs dms prepare -b http://localhost:5010
85
- ajs dms dev -b http://localhost:5010 -p 3001
86
- ajs dms build -b https://dms.example.com
87
- ajs dms start -b https://dms.example.com -p 3001
88
- ajs dms workspaces
89
- ajs dms workspaces --json
90
- ajs dms clean -b https://dms.example.com
91
- ajs dms clean --all
92
- ajs dms help environment
93
- ```
94
-
95
- `ajs dms <command> --help` shows what a command does, its options and a few
96
- examples. `ajs dms help environment` lists every variable the CLI and the
97
- generated server read, from the environment or the project's `.env`.
98
-
99
- `--help`, `--version`, `workspaces` and `clean` run from any directory. `build` and `start`
100
- need a backend URL through `-b` or `DMS_API_BASE_URL`, and exit 2 without one.
101
- `dev` also discovers it from the enclosing antelope project's
102
- `.antelope/dev.json`, and exits 1 when there is none to discover.
103
- `verify-source` needs no backend: it runs in the project that installs `@antelopejs/dms`.
104
-
105
- `prepare` runs anywhere: when it cannot prepare the workspace it warns on
106
- stderr and exits 0, so a frontend module's `postinstall` hook never fails an
107
- install. The warning says whether prepare was skipped or failed. Skipped means
108
- no backend is in reach (no URL, an unreachable backend, `--offline` without a
109
- cached manifest), as in CI: the generated types are refreshed later from a
110
- development environment. Failed means something a later run will not fix: a
111
- refused credential, an incompatible manifest or renderer range, an invalid URL.
112
- A step that needs the types, such as a CI type-check, passes `--strict` (or
113
- sets `DMS_PREPARE_STRICT=1`): prepare then exits 1 in every one of these cases,
114
- and 2 when the backend URL is missing or invalid. On success, it says how many
115
- modules it found and how long it took.
116
-
117
- `build` ends with where it wrote the production frontend (the `dist`
118
- directory of the workspace), how long it took, and the `ajs dms start` command
119
- to run next, with the same backend URL and `DMS_SESSION_SECRET`. Its output is
120
- deployed, so it never builds silently from the cache: when the backend cannot
121
- be reached or fails, `build` exits 1, says whether a cached manifest exists and
122
- how old it is, and builds from it only when asked with `--offline`. `dev` and
123
- `prepare` still fall back to the cache, with a warning.
124
-
125
- The commands follow the output contract of `ajs`: what the CLI itself prints
126
- on stdout is only the help and the version, and everything else (progress,
127
- warnings, errors) goes to stderr. The dependency install and each step of the
128
- production build run as one progress line each: their output is kept and its
129
- last lines are shown when they fail, with paths into the generated workspace
130
- pointing at the frontend-module sources instead. The server `dev` and `start`
131
- run keeps its own output. A command exits 0 on success, 1 on a failure, 2 on a
132
- usage error (an unknown command or option, a missing or invalid argument, a
133
- missing configuration value) and 130 when stopped with Ctrl+C. `--no-color` or
134
- `NO_COLOR=1` turns colors off, and terminals that cannot draw Unicode, such as
135
- `TERM=dumb`, get ASCII symbols. `--verbose` or `ANTELOPEJS_VERBOSE` streams
136
- the full `pnpm` and Vite output, each line behind the name of the step, and adds
137
- the stack trace of the underlying error to a failure; `--verbose=<channels>`
138
- turns all of it on too, whatever the channels. `-q`, `--quiet` or
139
- `ANTELOPEJS_QUIET=1` prints only results, warnings and errors: no command
140
- header, progress, summary or update notice, and of the block a ready `dev` or
141
- `start` server prints, only its Local URL. `ajs --no-color dms …`,
142
- `ajs --verbose dms …`, `ajs --verbose=<channels> dms …` and `ajs -q dms …` set
143
- those variables for the plugin; the same flags are also accepted after the
144
- command, as in `ajs dms build -q`.
145
-
146
- The CLI checks npm for a newer release at most once a day and prints a one-line
147
- notice on stderr, only to a terminal: as the last line of a command that
148
- succeeded, or under the ready block of `dev` and `start`. Help, `--version`,
149
- `--json`, failed commands, pipes and `TERM=dumb` never show it. The lookup starts
150
- with the command and never delays its exit; a lookup that comes back empty —
151
- offline or throttled — is retried after an hour instead of counting as the day's
152
- attempt. The throttle stamp lives at
153
- `~/.antelopejs/dms-frontend/update-check.json`. Set `NO_UPDATE_NOTIFIER=1`, pass
154
- `--no-update-check`, or run under `CI` to turn the check off.
155
-
156
- Manifest negotiation and module materialization are the renderer contract described in [Renderers](#renderers).
157
-
158
- The generated Vue application uses `@inertiajs/vue3`, `@nuxt/ui/vite` with `{ router: "inertia" }`, and `@nuxt/ui/vue-plugin`. The Node server resolves each Inertia visit through `/dms/page?path=…`; the site layout it returns comes as a once prop the browser reuses for five minutes per account and workspace (see [Navigation and the site layout](#navigation-and-the-site-layout)). It proxies backend routes and manages authentication through server-side sessions. `DMS_BOOTSTRAP_SECRET` is used only by the CLI's server-to-server frontend manifest and module archive requests and is never sent by, or exposed to, browser traffic.
159
-
160
- Vue modules register through `dms.frontend.ts` and import the SDK from the `#dms/frontend-module` alias, which the loader resolves to the generated `frontend-module.ts`: `defineDmsPlugin`, `useDmsRouter`, the page and session types, and everything else a module needs from the host. Email templates register separately through `dms.email.ts`.
161
-
162
- An email entry exports `serverEmailTemplates` and may export a plain `appConfig` object, such as shared branding defaults. Email rendering merges these configurations with the layers' [app configs](#app-config-appappconfigts) and provides the result to `useDmsAppConfig` per render: module by module, in manifest-priority order, a module's email `appConfig` first, then its layers' `app/app.config.ts`. A higher-priority module's layers thus override a lower one's email `appConfig`, and a module's email `appConfig`, the more specific of its two, keeps the values it gives emails over its own layers. Public runtime options come from the module manifest; `DMS_CLIENT_BASE_URL` overrides `public.dms.clientBaseUrl`. Email entries must not import the browser frontend module. The rendered HTML carries no Vue hydration comments (`<!--[-->`, `<!---->`, teleport anchors): an email is never hydrated, so the renderer strips them, and keeps every other comment, Outlook's conditional comments included.
163
-
164
- Email builds ship complete merged translation catalogs as JSON under `dist/server/locales/`, separate from executable JavaScript. Deploy the entire `dist/server` directory. Each render loads only its requested language and the English fallback; arbitrary translation keys and module overrides remain available. Unknown languages fall back to English, while missing or corrupt files for a supported language fail rendering rather than silently dropping translations. The source harness keeps the 256 KiB JavaScript ceiling and reports locale-data bytes separately.
165
-
166
- `ajs-dms dev` builds the email bundle too, once the dev server is ready, and rebuilds it whenever an email template changes. The build runs in a separate low-priority process, so page reloads and HMR never wait on it; a render requested while a build is running waits for that build to finish.
167
-
168
- ## Frontend modules
169
-
170
- Moving a module, or the DMS, from 0.5 to 0.6: see
171
- [Migrating to 0.6.0](docs/migration-0.6.md).
172
-
173
- A materialized module opts into the Vue adapter with a root `dms.frontend.ts`:
174
-
175
- ```ts
176
- import MyBlock from "./components/MyBlock.vue";
177
- import MyPage from "./pages/MyPage.vue";
178
- import type { DmsFrontendModule } from "#dms/frontend-module";
179
-
180
- const frontendModule: DmsFrontendModule = {
181
- setup(sdk) {
182
- sdk.registerComponent("MyBlock", MyBlock);
183
- sdk.registerPage("default", MyPage);
184
- },
185
- };
186
-
187
- export default frontendModule;
188
- ```
189
-
190
- The SDK also exposes `use` for Vue plugins. Entries execute by descending manifest priority, then stable module id. Modules without `dms.frontend.ts` are skipped by the generated loader. Registered page keys match a route's `fullSlug`, request path, or `default`; unregistered pages and components use the generic card renderer.
191
-
192
- Within one setup, the first module to register a name keeps it. `ajs-dms dev` sets every module up again each time a change to a layer reaches the server renderer, into empty registries, so the next server render uses the edited component without a restart. A `setup` may therefore run more than once in a process: it should only register, and keep no state of its own between runs.
193
-
194
- ### App config (`app/app.config.ts`)
195
-
196
- Each layer of a module, or the module root when it has no `layers/` directory, may hold an `app/app.config.ts` whose default export is a plain object: branding, Nuxt UI's `ui` (colors, icons, component themes), anything the modules read through `useDmsAppConfig()`.
197
-
198
- ```ts
199
- export default defineDmsAppConfig({
200
- branding: { logo: { light: "/branding/logo.svg" } },
201
- ui: { colors: { primary: "emerald" } },
202
- });
203
- ```
204
-
205
- The loader merges every one of them with `defu` (records key by key, arrays concatenated) before the first module is set up, so `useDmsAppConfig()` already holds the result when a `setup` runs: modules in manifest-priority order, and within a module its layers from the last by name to the first, the order in which a contested `public/` file is resolved. A project's layer therefore overrides the DMS's, and the DMS fills in everything the project leaves out. A `setup` may still read and extend the config. Nuxt UI's own app config is merged from the result, and so are emails' (see above). The file is TypeScript only, and is imported in Node.js by the server renderer and the email bundle as well as in the browser: it must not touch browser globals when it loads.
206
-
207
- `ajs-dms dev` applies an edit at once: the next server render uses it, and the open page updates `useDmsAppConfig()` and Nuxt UI's app config in place, colors included, without a reload. What the modules' `setup` changed in the config stays on top of the edited values; adding or removing a config file is applied the same way.
208
-
209
- Up to 0.5.6, the loader applied none of these files: an entry merged its own into `useDmsAppConfig()` in its `setup`, as the DMS up to 0.8 does (`Object.assign(useDmsAppConfig(), defu(useDmsAppConfig(), appConfig))`). Such an entry keeps working, its values unchanged, but its config is now merged twice, and `defu` concatenates arrays: every array it declares, Nuxt UI's `compoundVariants` among them, holds its entries twice. Nuxt UI resolves the repeated classes through `tailwind-merge` to the same ones, so nothing changes on screen, but an entry should stop merging its own config once it requires a release of the loader that applies it. The DMS does so in a release coordinated with this one. Until then, an edit to a config its entry imports reloads the page instead of updating it in place, since the entry imports it too.
210
-
211
- ### Component prefix
212
-
213
- Every registered component joins the application's global components and resolves by name anywhere, in templates and in backend page trees. To keep the components of two modules from colliding, a module declares a `componentPrefix`, put in front of the name of every component it registers:
214
-
215
- ```ts
216
- const frontendModule: DmsFrontendModule = {
217
- componentPrefix: "Acme",
218
- setup(sdk) {
219
- sdk.registerComponent("Banner", Banner); // registered as AcmeBanner
220
- },
221
- };
222
- ```
223
-
224
- The full name, prefix included, is the one a template uses (`<AcmeBanner>`) and a backend sends (`CustomComponent("AcmeBanner")`, or `acme-banner`). Names are compared ignoring case, a leading `lazy` and non-alphanumeric characters; a leading `Dms` is no longer dropped, since it is the DMS module's own prefix.
225
-
226
- When two modules register the same name, the module of higher priority keeps it and a warning names both. A module that means to replace another's component registers its full name without its own prefix, which is not reported:
227
-
228
- ```ts
229
- sdk.registerComponent("AcmeBanner", MyBanner, { prefix: false });
230
- ```
231
-
232
- A module's `app/build/` directory is never auto-imported (see below): its components are registered so backend pages can name them, but they are the module's internals, not a surface other modules should build on.
233
-
234
- ### Auto-imports (`dms.frontend.build.ts`)
235
-
236
- Nothing a module ships is auto-imported unless the module declares it, in an optional `dms.frontend.build.ts` next to its `dms.frontend.ts`:
237
-
238
- ```ts
239
- import { defineDmsFrontendBuild } from "#dms/frontend-build";
240
-
241
- export default defineDmsFrontendBuild((build) => {
242
- build.registerAutoImports([
243
- "layers/*/app/composables",
244
- "layers/*/app/utils",
245
- "layers/*/app/types",
246
- ]);
247
- });
248
- ```
249
-
250
- Each entry is a directory relative to the module root, glob patterns allowed; every export of the files under it becomes available to the whole application without an import, in scripts and templates. A module without the file has nothing auto-imported. `app/build/` holds a module's private code and is never auto-imported: it is left out of what a broader entry covers, and an entry naming it, or a directory outside the module, is ignored with a warning when Vite starts.
251
-
252
- The file runs in Node.js when Vite starts, under `ajs dms dev` and `ajs dms build`, not in the application: it may import `#dms/frontend-build` and Node.js built-ins, never the application's code. Vite restarts when one changes.
253
-
254
- Up to 0.3, every module's `app/composables`, `app/types`, `app/utils`, `app/build/composables` and `app/build/types` were auto-imported. A module that relies on auto-imports declares its public directories in `dms.frontend.build.ts`, and imports its `app/build/` code by path.
255
-
256
- ### Server routes (`dms.server.ts`)
257
-
258
- A module serves routes from the frontend server, next to the session, in an optional `dms.server.ts` beside its `dms.frontend.ts`: a sign-in flow that writes cookies, a provider redirect, a render a backend asks for.
259
-
260
- ```ts
261
- import { defineDmsServer } from "#dms/server";
262
- import { ping, startExport } from "./server/routes";
263
-
264
- export default defineDmsServer((server) => {
265
- server.registerRoute("GET", "/api/_acme/ping", ping);
266
- server.registerRoute("POST", "/api/_acme/exports/:kind", startExport);
267
- });
268
- ```
269
-
270
- ```ts
271
- // server/routes.ts
272
- import type { DmsServerEvent } from "#dms/server";
273
-
274
- export function ping() {
275
- return { ok: true };
276
- }
277
-
278
- export async function startExport(event: DmsServerEvent) {
279
- const input = await event.readJson<{ from: string }>();
280
- return event.backend(`/api/acme/exports/${event.params.kind}`, {
281
- method: "POST",
282
- body: input,
283
- token: event.session.read()?.accessToken,
284
- });
285
- }
286
- ```
287
-
288
- A handler takes the request and answers with a Web-standard `Response`, or with a value sent as JSON (`null` or nothing is an empty `204`). The event it receives carries:
289
-
290
- - `request`, `url`, `params` (the `:name` segments, decoded) and `readJson()`;
291
- - `session`: `read()`, `write()` (stamped with `updatedAt`, ends with the browser unless `persistent`), `clear()`, `useForRequest()`;
292
- - `cookies`: sealed cookies by logical name (`readSealed`, `writeSealed` with an optional `maxAge` and `path`, `clear`). The server scopes the name to its backend and sets `HttpOnly`, `SameSite=Lax` and `Secure`, so a module never depends on how cookies are named; the session cookie is only reachable through `session`. The `dms_*` names belong to the module that owns sign-in (the one whose `registerAuth` is used): another module naming one gets an error, and its own cookies are stored as `<module id>.<name>`, so no module reads or overwrites another's;
293
- - `backend(path, init)`: a JSON call to the backend that always carries the client's address and the headers the proxy relays; a refusal throws an error with the status and the backend's public `error.*` message, which reaches the client as is when the handler lets it through;
294
- - `backendUrl(path)`: the backend URL of a path, always on the backend's origin, for a call `backend` cannot make (a raw response);
295
- - `clientIp()`, `renderEmail(template, props, { locale })`, and `module`: this module's id, its options and its own `privateOptions`, never another module's.
296
-
297
- Every route gets the same defaults: a request with a state-changing method must carry the frontend's own `Origin` (`{ sameOrigin: false }` only for a route authenticated otherwise, such as by a service token), its body stops at 64 KiB (`bodyLimit`) with a `413`, and the answer is `Cache-Control: no-store` unless the handler sets its own. An error the handler lets through is answered like a backend failure: its status, and only a public `error.*` message.
298
-
299
- Paths are literal segments and `:name` parameters. Module routes are tried after the renderer's own (`/api/_dms/`, `/@…`, `/assets/`, which no module can declare) and before the backend proxy, literal paths before parameters. A route under `/api/` or `/dms/` outside a module namespace (`/api/_<module>/`) hides what the backend serves there, so the server warns unless the route passes `{ overridesBackend: true }`. When two modules declare the same method and path, the module of higher priority keeps it and a warning names both, the module id settling a tie; a module route answering a [public endpoint](#public-endpoints) is reported too. A malformed declaration is ignored with a warning naming its module.
300
-
301
- `dms.server.ts` runs in the frontend server's Node.js process, next to the session secret: it is trusted code, as the module's backend half is, not a sandbox. Every route reads the session (`event.session.read()`, access and refresh tokens included) and may write it; only install a module whose `dms.server.ts` you trust as much as the DMS's own. It may import `#dms/server`, Node.js built-ins, npm dependencies and the module's own server files (a `server/` directory is copied into the workspace for that), never the application's code (`.vue` files, `dms.frontend.ts`); nothing it imports reaches the browser bundle, and `verify-source` checks both. Under `ajs dms dev` the server loads it through Vite, so an edited handler answers on the next request; `ajs dms build` bundles it into `dist/server-modules`, and a workspace built without that bundle simply serves no module route. Import what a handler uses: auto-imports do not apply to server code.
302
-
303
- A module tests its routes through the same router, event and cookie jar with `serveModuleRoutes` from `@antelopejs/dms-frontend/templates/vue/server/module-testing.mjs`, which serves the default export of its `dms.server.ts` on a local port.
304
-
305
- #### Session hooks
306
-
307
- The server also runs session logic outside any route: it renews a session the backend refused on a page visit, puts the `user` and `session` page props together, and sends a signed-out visit to sign in. The module that owns sign-in provides it:
308
-
309
- ```ts
310
- server.registerAuth({
311
- signInPath: "/auth",
312
- signInExempt: ["/auth/**", "/onboarding"],
313
- refreshSession: renewSession, // (event, session) => the new session, or undefined
314
- publicSession: (session) => ({
315
- user: session.user,
316
- session: { accountId: session.accountId, activeTenantId: session.activeTenantId },
317
- }),
318
- });
36
+ pnpm add -D @antelopejs/core @antelopejs/dms-frontend
319
37
  ```
320
38
 
321
- `refreshSession` is called once when the backend answers `401` to a signed-in page visit, which is then retried with the session it wrote (`event.session.write`). `publicSession` gives the page props of a signed-in session; the server adds `session.updatedAt`, the stamp of its last write, which the browser compares to drop a page restored from its cache (see [Interface language](#interface-language)), and `event.session.view()` returns the same props to a route. `signInExempt` patterns are `/x`, `/x/*` (one segment below) or `/x/**` (the page and everything below); left out, they are `<signInPath>/**` and `/onboarding`, so a custom `signInPath` is exempt on its own. The highest-priority module that registers them provides all of them; another one is reported and ignored.
322
-
323
- #### Route rules
324
-
325
- A module also declares how its pages are rendered and cached, by page pattern (`/x`, `/x/*` one segment below, `/x/**` the page and everything below):
326
-
327
- ```ts
328
- server.registerRouteRules({
329
- "/terms-of-use": { cache: { public: true, maxAge: 300, staleWhileRevalidate: 3600 } },
330
- "/builder/**": { ssr: false },
331
- "/reports/**": { headers: { "x-robots-tag": "noindex" } },
332
- });
333
- ```
334
-
335
- - `cache`: page documents and Inertia answers default to `private, no-cache`, since they carry the visitor's session. A public `cache` replaces it, with `Vary: Cookie`, only for a `GET` of a page the backend serves to anyone (`publicAccess`), answered `200` to a request without any cookie and without `x-content-language`: such an answer holds nobody's data. Any other request keeps the private default. The frontend server itself stores nothing; the rule is for a shared cache or a CDN in front of it. `cache: false` keeps the default under a broader rule.
336
- - `ssr: false`: a document request gets the client-rendered shell instead of a server render; Inertia visits are unchanged.
337
- - `headers`: added to the page's document and Inertia answers; `cache-control`, `vary`, `set-cookie` and the content and Inertia headers stay the server's.
338
-
339
- The rules matching a page merge from the least to the most specific pattern; for one pattern, the highest-priority module's rule wins, with a warning. A rule never changes who may see a page.
340
-
341
- The renderer serves no route of its own besides its files and icons: the session, sign-in, OAuth and e-mail render routes are the DMS's (`dms.server.ts`), the API tester is the DMS API's. Without a module that registers auth hooks, pages carry no `user` and `session` and a refused session is not renewed.
342
-
343
- A module that ships `dms.server.ts` raises its [loader range](#declaring-the-loader-releases-a-module-supports) floor to the release that serves it: an older one never reads the file, so the routes would silently be missing.
344
-
345
- ### Page loading state
346
-
347
- After a client navigation, the page renders under a `<Suspense>` until its chunk and any async `setup` resolve. `useDmsPageLoading()` (auto-imported) is `true` meanwhile, so a layout can show its own placeholder instead of an empty page body:
348
-
349
- ```vue
350
- <script setup lang="ts">
351
- const pageLoading = useDmsPageLoading();
352
- </script>
353
-
354
- <template>
355
- <slot />
356
- <PageSkeleton v-if="pageLoading" />
357
- </template>
358
- ```
359
-
360
- It stays `false` on the server and during hydration, which render the page resolved, for a page that does not suspend, and once a pending page is left.
361
-
362
- ### Interface language
363
-
364
- The interface language follows the session (`useDmsUserSession().user.language`), not the props of the page on screen: the first render takes it from the session the page arrived with, and it changes when the session's language does, after `useDmsUserSession().fetch()` or a visit with a newer session. A page Inertia restores from history or serves from its prefetch cache carries the session it was fetched with: the frontend server stamps every session it writes (`session.updatedAt`), and a page carrying an older stamp of the same account updates neither the session nor the language. A session without a language, such as a signed-out one, keeps the language on screen; `$i18n.setLocale()` still switches it at any time. It also remembers a language the application offers in the `dms_locale` cookie (one year, `SameSite=Lax`), and a page without a signed-in user's language, such as a public sign-in page, first renders in that one: a visitor's choice survives a reload, while a signed-in user's own language still comes first.
365
-
366
- ### Declaring the loader releases a module supports
39
+ ## Quick start
367
40
 
368
- A frontend module names the `@antelopejs/dms-frontend` releases it runs on in its own `package.json`, the way a package names the Node.js versions it supports:
369
-
370
- ```json
371
- {
372
- "name": "@acme/dms-billing-frontend-vue",
373
- "engines": {
374
- "@antelopejs/dms-frontend": ">=0.6.0 <0.7.0"
375
- }
376
- }
377
- ```
378
-
379
- `prepare`, `dev`, `build` and `verify-source` read it before they generate anything, whether the manifest was just fetched or replayed from the cache, and stop when this release falls outside a module's range: `dev`, `build` and `verify-source` exit 1, and `prepare` warns and exits 0, as it does for any setup it cannot complete, unless `--strict` is given. The message names each such module, its range and this release, rather than letting the build, the type-check or a render fail later on an API the release no longer has, or does not have yet. A prerelease is checked as the release it leads to: `0.4.0-next.1` is outside `<0.4.0`. A module that declares no range still loads, and a warning names it once. `start` checks nothing: it runs what `build` produced.
380
-
381
- Cap the range below the next breaking release, which is the next minor release while this package is 0.x, and widen it once the module has been checked against that release. A range left open to `<1.0.0` admits the very release that removes something the module calls.
382
-
383
- The range is an engine rather than a peer dependency: the generated workspace installs every module as a workspace package, and pnpm installs a workspace package's peer dependencies, optional ones included, so a peer on the loader would pull a copy of it into every workspace. Package managers ignore an engine they do not know, and so does a loader released before this check.
384
-
385
- ### Redirecting typed access refusals
386
-
387
- A backend can refuse a whole surface with a typed 403 whose body is a machine-readable code, such as a tenant access gate blocking a suspended workspace. A module that owns such a code registers where a refused page visit goes instead of the error page:
388
-
389
- ```ts
390
- sdk.registerAccessRedirect(
391
- "saas.errors.workspace.access_blocked",
392
- "/workspace-suspended",
393
- );
394
- ```
395
-
396
- The server answers the refused visit, document or Inertia, with a redirect to that path before anything renders, so a reload lands there directly. The body may be the bare code or JSON (`"code"` or `{ "message": "code" }`). A destination refused in turn keeps the error page instead of looping. The first registration of a code wins, in manifest-priority order.
397
-
398
- ### Components rendered inside `<svg>`
399
-
400
- Modules usually register their components lazily, with `defineAsyncComponent`, and the page is rendered under a `<Suspense>`. Vue mounts an async component that resolves under a `<Suspense>` with the `<Suspense>`'s element namespace rather than the namespace of the place it is rendered: a component resolved by name (`resolveComponent` or a template tag) whose root is an SVG element (`<path>`, `<g>`, …) is created as an HTML element inside the `<svg>`, and the browser draws nothing ([vuejs/core#15639](https://github.com/vuejs/core/issues/15639)). Import such components directly instead, for example the node and edge types passed to Vue Flow:
401
-
402
- ```ts
403
- import { markRaw } from "vue";
404
- import RelationEdge from "./RelationEdge.vue";
405
-
406
- const edgeTypes = markRaw({ relation: RelationEdge });
407
- ```
408
-
409
- ### Layer aliases outside the workspace
410
-
411
- Every layer a module ships under `layers/<name>/` answers to `#<name>` inside
412
- the generated workspace. That convention is exported, so a module's own tooling
413
- can reuse it instead of restating it — a unit test runs the module's Vue
414
- sources outside the workspace, where nothing otherwise supplies `#dms-core` or
415
- `#dms-ui`:
416
-
417
- ```ts
418
- // vitest.config.ts, in a DMS frontend module
419
- import { frontendLayerAliases } from "@antelopejs/dms-frontend/common";
420
-
421
- export default defineConfig({
422
- resolve: {
423
- alias: frontendLayerAliases(
424
- resolve(__dirname, ".antelope/cache/@antelopejs/dms/frontend-vue"),
425
- ),
426
- },
427
- });
428
- ```
429
-
430
- `frontendLayerAliases` reads a module root on disk and needs no backend,
431
- manifest or materialized workspace. `frontendLayerTypePaths` returns the same
432
- layers in tsconfig `paths` shape, and `resolveFrontendLayers` is the primitive
433
- behind both. Pass several module roots in increasing precedence to overlay
434
- them: when two modules expose a layer of the same name, the last one wins,
435
- which is the order the loader itself materializes them in.
436
-
437
- ## Discovery, caching, and security
438
-
439
- In development, `ajs dms` discovers the backend from the nearest live `.antelope/dev.json`. It reads the local bootstrap credential from `.antelope/dms-dev.json` only when that discovered backend matches the destination URL. For production and CI, set `DMS_API_BASE_URL` and `DMS_BOOTSTRAP_SECRET` in the environment, or in the project's `.env`, rather than passing credentials on the command line.
440
-
441
- Each canonical backend URL gets an owner-only workspace under `~/.antelopejs/dms-frontend` (see [Workspaces](#workspaces) for how the key is derived). Manifest caches, private module configuration, and extracted archives retain restrictive permissions. `--offline` reuses the last successful manifest and archive; `dev` and `prepare` also fall back to them when the backend cannot be reached or fails, and `build` only with `--offline`. An authorization failure never falls back to privileged cached data.
442
-
443
- ## Configuration
444
-
445
- Every command loads `.env.local` then `.env` from the **current working
446
- directory** before it parses its options, so a project can keep its
447
- configuration in a file instead of exporting variables by hand:
41
+ In a DMS project started with `ajs project run -w`, serve the dashboard with hot reload:
448
42
 
449
43
  ```bash
450
- # .env
451
- DMS_API_BASE_URL=http://localhost:5010
452
- DMS_CLIENT_BASE_URL=http://localhost:3001
453
- DMS_BOOTSTRAP_SECRET=replace_with_a_strong_random_value
454
- DMS_SESSION_SECRET=replace_with_at_least_32_characters
455
- ```
456
-
457
- Precedence is environment, then `.env.local`, then `.env`: a variable already
458
- present in the environment is never overwritten, so `DMS_API_BASE_URL=… ajs dms
459
- build` and a CI job's injected secrets always win over a file left in the
460
- checkout. A variable exported as an empty string counts as set. Only the
461
- current directory is read — never a parent directory, and never the generated
462
- workspace under `~/.antelopejs/dms-frontend`, which is this tool's own output
463
- and is handed its environment explicitly by the command that spawns it. The
464
- values loaded here reach the workspace build started by `build`, the dev server
465
- started by `dev`, and the production server started by `start`, because those
466
- child processes inherit the environment. A missing file is not an error; an
467
- unreadable one is reported and skipped.
468
-
469
- `ajs dms dev` generates a fresh ephemeral 32-byte secret when
470
- `DMS_SESSION_SECRET` is absent; all sessions are invalidated when that dev
471
- server restarts. An explicit value is preserved, but empty or shorter than 32
472
- characters is rejected. `build` and `start` require a configured secret and
473
- never generate one, which is required for production so sessions survive
474
- restarts. The generated server also validates the value as a safety net.
475
- Generate one with `openssl rand -hex 32`.
476
-
477
- ### Opening a session from a module flow
478
-
479
- `/auth/login`, `/auth/signup` and `/auth/verify-2fa` are not the only ways a
480
- visitor becomes authenticated: a module can own a flow that ends in an
481
- authenticated user — a self-service registration completing after payment, an
482
- invitation being redeemed — and needs the session cookie opened at the end of
483
- it. `POST /auth/establish` is the generic form of those three routes.
484
-
485
- The browser names a backend endpoint and the payload to send it:
486
-
487
- ```ts
488
- await $fetch("/auth/establish", {
489
- method: "POST",
490
- body: {
491
- endpoint: "/api/saas/register/finalize",
492
- payload: { /* whatever that backend route expects */ },
493
- },
494
- });
44
+ pnpm exec ajs dms dev
495
45
  ```
496
46
 
497
- The frontend server calls that endpoint itself over its own server-to-server
498
- channel to `DMS_API_BASE_URL`, exactly as it calls `/api/auth/login`, and
499
- writes the session from the token pair the backend answers with. The browser
500
- never sends a token and never receives one: it gets back the same
501
- `{ user, account }` body the login route returns, and the two-factor and
502
- tenant-assignment outcomes are handled identically.
503
-
504
- Because the route turns a backend endpoint into a login, it only calls the
505
- endpoints that were declared for it.
506
-
507
- Backend modules declare their own. A module registering its frontend names the
508
- routes that finish its flow, the DMS aggregates them into the frontend manifest
509
- it serves with the bootstrap secret, and `dev`/`build` write them into the
510
- workspace as it is materialized:
511
-
512
- ```ts
513
- // in the backend module
514
- await AddFrontendModule({
515
- name: "@antelopejs/dms-saas-frontend-vue",
516
- sourcePath: path.join(__dirname, "../frontend-vue"),
517
- renderer: { name: "vue", version: "3" },
518
- authEstablishEndpoints: ["/api/saas/register/finalize"],
519
- });
520
- ```
521
-
522
- A standard deployment therefore needs no configuration at all. The environment
523
- variable stays as an override, for a backend route no module declares — or one
524
- declared by a DMS too old to carry the field:
47
+ For production, build against the backend, then start the frontend server, with `DMS_SESSION_SECRET` and `DMS_BOOTSTRAP_SECRET` set:
525
48
 
526
49
  ```bash
527
- # .env
528
- DMS_AUTH_ESTABLISH_ENDPOINTS=/api/invites/redeem
50
+ pnpm exec ajs dms build -b https://dms.example.com
51
+ pnpm exec ajs dms start -b https://dms.example.com -p 3001
529
52
  ```
530
53
 
531
- The effective allow-list is the union of the two. Every entry, from either
532
- source, is matched verbatim against absolute `/api/…` paths — no prefixes, no
533
- query strings, no traversal — so no backend route that happens to mint a token
534
- pair can be turned into a login by a request from the browser. An undeclared
535
- endpoint is answered `403` and never called. The route is `POST`-only and
536
- same-origin, like every other auth action.
537
-
538
- ### Routing and the backend proxy
539
-
540
- The frontend server answers each path with one owner, whoever asks and
541
- whatever the method, in this order:
542
-
543
- 1. **The renderer**: its icon endpoint (`/api/_dms/icons`), Vite's URLs, the
544
- built assets, and any file it has (a development source, a built asset).
545
- 2. **The module routes** of the modules' `dms.server.ts`, for every method: a
546
- method the module does not declare on its path answers `405`, and a `HEAD`
547
- reaches the `GET` route.
548
- 3. **The backend proxies** the modules declare. The renderer proxies no path on
549
- its own: the DMS declares `/api/**` and `/dms/**` in its `dms.server.ts`.
550
- 4. **The pages**, for everything else; a method other than `GET` and `HEAD`
551
- there is a `404`. A backend route outside every declared prefix (such as a
552
- page's `<page>/pagelayout`) is never reached from the browser.
54
+ `ajs dms --help` lists every command, and `ajs dms help environment` every environment variable.
553
55
 
554
- A frontend module declares a prefix in its `dms.server.ts`:
555
-
556
- ```ts
557
- server.registerBackendProxy("/api/**"); // auth: "session", the default
558
- server.registerBackendProxy("/media/**", { auth: "none" });
559
- ```
560
-
561
- A backend module declares one in the manifest, next to its `publicEndpoints`:
562
-
563
- ```ts
564
- await AddFrontendModule({
565
- // …
566
- backendProxies: [{ path: "/webhooks/acme/**", auth: "none" }],
567
- });
568
- ```
56
+ ## Compatibility
569
57
 
570
- - `auth: "session"` adds the session's access token to the request
571
- (`Authorization: Bearer`), server-side: the token never reaches the browser,
572
- which only holds the sealed, `HttpOnly` session cookie. A request with a
573
- state-changing method must carry the frontend's own `Origin`, except a
574
- public endpoint (below).
575
- - `auth: "none"` never sends the session, so it needs no `Origin`: for media,
576
- or a webhook that authenticates its caller itself.
58
+ | `@antelopejs/dms` | `@antelopejs/dms-frontend` |
59
+ | --- | --- |
60
+ | 0.9.1 and later | `>=0.6.1 <0.7.0` |
61
+ | 0.9.0 | `>=0.6.0 <0.7.0` |
62
+ | 0.6.0 – 0.8.3 | 0.5 (exact floor per release in the full table) |
577
63
 
578
- A pattern is `/x`, `/x/*` (one segment below) or `/x/**` (the path and
579
- everything below), its first segment literal, outside the renderer's own paths.
580
- Of two patterns covering a path, the more specific wins; for one pattern, the
581
- highest-priority frontend module's, then the manifest's, with a warning.
582
- `DMS_BACKEND_PROXIES` adds entries of the manifest's shape, as a JSON array,
583
- for an operator. The server lists the backend proxies, their owner and their
584
- auth when it starts, and the development server names the owner of each answer
585
- in an `x-dms-route-owner` header (`renderer`, `module:<id>`,
586
- `backend:<pattern>`, `page` or `not-found`).
64
+ The 0.6 line needs Node.js 20.12 or later and `@antelopejs/core` `>=1.13.2 <2`. The [compatibility table](https://dms.antelopejs.com/docs/frontend/introduction#compatibility) lists every release.
587
65
 
588
- ### Public endpoints
66
+ ## Documentation
589
67
 
590
- Every state-changing request the server proxies with the session must carry
591
- an `Origin` matching the frontend's own, and goes out with the signed-in
592
- user's session. A route meant for callers outside the dashboard — a mail provider's
593
- delivery webhook, a payment provider's callback — fails both ways: the caller
594
- sends no `Origin`, and its secret header is not among the few the proxy
595
- relays.
68
+ - [Introduction](https://dms.antelopejs.com/docs/frontend/introduction) and [Installation](https://dms.antelopejs.com/docs/frontend/installation)
69
+ - [CLI reference](https://dms.antelopejs.com/docs/frontend/cli/overview)
70
+ - [Configuration and environment variables](https://dms.antelopejs.com/docs/frontend/configuration)
71
+ - [Writing a frontend module](https://dms.antelopejs.com/docs/frontend/frontend-modules/anatomy)
72
+ - [Rendering model](https://dms.antelopejs.com/docs/frontend/rendering/model)
73
+ - [Deploying to production](https://dms.antelopejs.com/docs/frontend/deployment/production)
74
+ - [Troubleshooting](https://dms.antelopejs.com/docs/frontend/troubleshooting)
596
75
 
597
- A backend module declares such a route public when it registers its
598
- frontend:
76
+ The guide's sources live in [`docs/`](./docs). The package also ships the `dms-frontend` skill for coding agents in [`skills/`](./skills), declared under `antelopeJs.skills`.
599
77
 
600
- ```ts
601
- await AddFrontendModule({
602
- name: "@antelopejs/dms-mailing-frontend-vue",
603
- sourcePath: path.join(__dirname, "../frontend-vue"),
604
- renderer: { name: "vue", version: "3" },
605
- publicEndpoints: [
606
- {
607
- path: "/api/mailing/events/*/*",
608
- methods: ["POST"],
609
- headers: ["x-mailing-webhook-secret"],
610
- },
611
- ],
612
- });
613
- ```
614
-
615
- A request to a public endpoint:
616
-
617
- - needs no `Origin`;
618
- - is never sent the session, even from a signed-in browser, so a cross-site
619
- form posting to it acts as nobody: the route authenticates its caller
620
- itself (a shared secret, a signature);
621
- - carries the module's declared headers on top of the usual ones.
622
-
623
- `path` is an absolute `/api/` path whose first segment is literal (the
624
- module's own prefix, never `*`) and whose later `*` segments match exactly one
625
- segment; nothing else matches (no prefix, no query string, no `.` or `..`
626
- segment). The proxy matches the request's normalized path, the one it sends
627
- the backend, so `..`, `%2e%2e` or `\` cannot reach another route through a
628
- public one. `methods` defaults
629
- to `["POST"]`. `headers` names the module's own `x-` headers; the ones a proxy
630
- or the DMS gives a meaning to (`x-forwarded-*`, `x-real-ip`, `x-dms-*`,
631
- `x-inertia*`, method overrides) are refused, as are `authorization` and
632
- `cookie`. A malformed declaration is dropped whole, with a warning, never
633
- widened. `DMS_PUBLIC_ENDPOINTS` adds entries of the same shape, as a JSON
634
- array, for a route no module declares.
635
-
636
- Every proxied request also relays `If-Match`, so a backend route can refuse a
637
- write made from a stale copy (`412` or `409`), and the client's `Origin` and
638
- `Referer` as received, so a public route can check where a beacon comes from.
639
- The proxy sets `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`
640
- itself, so a route can limit each client: the client's own `X-Forwarded-For`
641
- is extended with the socket's address only behind `DMS_TRUSTED_PROXY_HOPS`
642
- trusted proxies, and replaced by it otherwise.
643
-
644
- ### Workspaces
645
-
646
- Each canonical backend URL gets its own owner-only workspace under
647
- `~/.antelopejs/dms-frontend/<sha256>/`; `build`, `start`, `clean -b` and
648
- `dev -b` all key on that URL, so one backend means one workspace shared by
649
- every command.
650
-
651
- `dev` without `-b` is the deliberate exception. It discovers the backend from
652
- the enclosing antelope project's `.antelope/dev.json` and keys its workspace on
653
- the **project directory** instead, because a development backend can land on a
654
- different port between runs and re-keying on the URL would discard
655
- `node_modules`, the manifest cache and the client-side appId scope every time it
656
- does. The consequence is that `ajs dms dev` followed by `ajs dms build -b <url>`
657
- against the same backend creates two workspaces of their own — several hundred
658
- megabytes each. Pass `-b` to `dev` to share a single one. `workspaces` lists
659
- both, with the project a workspace is keyed on, its size and its last use;
660
- `clean -b <url>` only reaches the URL-keyed one. `clean` takes its target from
661
- `-b` or `--all` only: it never reads `DMS_API_BASE_URL` from the environment or
662
- `.env`, so running it without either is an error rather than a deletion.
663
-
664
- `workspaces` prints an aligned table on a terminal. Piped, it prints one
665
- tab-separated line per workspace, without a header: id, backend URL, key type
666
- (`url` or `project`), project directory, size in bytes, last use (ISO 8601) and
667
- workspace directory. `--json` prints one JSON document with the same fields;
668
- everything else the command says goes to stderr.
669
-
670
- `clean --all` lists what it removes and asks before removing it. Without a
671
- terminal to ask on (a pipe, CI), it exits 2 unless `--yes` is given. Both forms
672
- of `clean` say how much space they freed.
673
-
674
- A `DMS_API_BASE_URL` line in `.env` counts as an explicit backend, so a project
675
- that configures one gets the single shared workspace and gives up autodiscovery
676
- — including its tolerance for the backend moving to another port. Leave the
677
- variable out of `.env` to keep autodiscovery for `dev`.
678
-
679
- ## Rendering model
680
-
681
- The Node server renders full-page requests through the Vite SSR bundle and sends
682
- the matching Inertia page object to the browser for hydration. Subsequent
683
- `X-Inertia` visits receive page objects and render client-side. Client-only
684
- plugins and `DmsClientOnly` defer browser-only work while the same generated
685
- frontend-module registry drives server and client entries.
686
-
687
- Under `ajs dms dev`, an edit to a module's `i18n/locales/*.json` regenerates the
688
- merged catalogs the application imports: the next server render uses them, and
689
- the open page applies them in place, without a reload. Adding or removing a
690
- locale reloads the page instead.
691
-
692
- An edit to a layer's `app/app.config.ts` reaches the page the same way (see
693
- [App config](#app-config-appappconfigts)).
694
-
695
- The other files the workspace derives from every module follow their sources
696
- the same way. A file added to, changed in or removed from a module's `public/`
697
- is served, or no longer served, at once. Public files and catalogs are written
698
- before the other files saved with them, so an app config or a component that
699
- names a new logo or a new translation, saved at the same time, never finds it
700
- missing. Adding or removing an `app/config/shortcuts-registry.ts` or a
701
- `dms.frontend.ts` regenerates the aggregated shortcuts or the module loader,
702
- and the page reloads. Adding, changing or removing a `dms.frontend.build.ts`
703
- restarts Vite. A layer directory added to or removed from `layers/` is the
704
- exception: Vite reads the `#<layer>` aliases and the auto-imported directories
705
- at startup only, so the dev server says it needs a restart.
706
-
707
- A page whose render declares no icon, through `useHead`, gets an empty
708
- `<link rel="icon" href="data:,">`, which keeps the browser from asking for
709
- `/favicon.ico`. A page that declares its icons, one per color scheme for
710
- instance, gets only those.
711
-
712
- ### Navigation and the site layout
713
-
714
- Every visit's page object carries the page (`page`: its route and layout), the
715
- session (`user`, `session`) and the site layout (`shared`: the menu with each
716
- entry's access, quick actions, modules). The site layout is most of that
717
- object, and the same from page to page, so it is an Inertia once prop:
718
-
719
- - The server sends it when the browser does not hold it for the session's
720
- account and workspace, then asks the backend for the page alone
721
- (`/dms/page?path=…&shared=false`) while the browser's copy is less than five
722
- minutes old. Signing in, signing out, or switching account or workspace
723
- changes the key the copy is held under, so the next visit brings the new
724
- session's layout.
725
- - `router.reload({ only: ["shared"] })` reloads it at once, and `only` /
726
- `except` name any of `page`, `shared`, `user` and `session`: the server
727
- fetches only what is named. The DMS reloads the menu this way when the
728
- backend announces a change.
729
- - The access a menu entry shows is for display: the backend checks access on
730
- every request, whatever the menu says.
731
-
732
- A navigation that changes only the query or the hash of the page on screen
733
- (`useDmsRouter().push({ query })`, `navigateDms`, `DmsLink`) is an Inertia
734
- client-side visit: the URL and the history entry change, the page stays
735
- mounted with its props, and no request is made, since the page props depend
736
- on the path alone. Pass a server-only option (`only`, `data`, `headers`,
737
- `onStart`…) to make the request anyway; an error page is always left through
738
- the server.
739
-
740
- `DmsLink` prefetches the page it points to on hover; once Inertia has the
741
- props, the page, its layout and its block components start loading, so
742
- following the link waits for neither. A change of account or workspace, and
743
- signing out, drop the prefetched pages.
744
-
745
- ### Data loading
746
-
747
- `useDmsAsyncData(key, handler, options)` loads data once for every component
748
- that shows `key`, with the semantics of Nuxt 4's `useAsyncData` and the
749
- deviations listed below (calls of one key joined, the data of the last success
750
- kept after a failure, the first `lazy` run started after the caller's turn):
751
-
752
- - **A server render waits for it.** A component's data is awaited before the
753
- component renders, whether or not its setup awaits the call, and the browser
754
- takes the result over while it hydrates instead of fetching it again. Keys a
755
- setup does not await load in parallel. Data a plugin starts is awaited
756
- before the page renders.
757
- - **The result works with or without `await`.** Its refs are there at once;
758
- awaiting it waits for the data.
759
- - **`lazy: true`** spares client navigations the wait; a server render still
760
- waits.
761
- - **`server: false`** leaves the data to the browser: the server renders the
762
- default, and the browser fetches the data once the page is mounted.
763
- - **The handler receives `{ signal }`.** It is aborted when a newer run
764
- replaces this one (`dedupe: "cancel"`, the default; `"defer"` waits for the
765
- run in flight instead), when `clear()` resets the data, or when the last
766
- component showing the key unmounts.
767
- - **A page renders once its middleware is done.** On a server, the page that
768
- calls `defineDmsPageMeta` waits for the middleware it starts, so what a
769
- middleware loads or sets (with `transfer: true`) is what the HTML shows and
770
- the browser hydrates with; its redirect still answers the request. A
771
- middleware that throws fails the page: the error page shows in its place,
772
- and a server render answers with the error's status code. Middlewares run
773
- in order; those registered with `{ parallel: true }` next to each other
774
- start together (for checks that read nothing another middleware sets),
775
- their results applying in registration order.
776
- - **The browser hydrates with what the HTML shows.** Data that settles once
777
- the HTML is complete is left to the browser. A component receives the server's data when the server's
778
- render did: once its setup awaits the call, or right before it renders. A
779
- value its setup derives from data it did not await, in an immediate
780
- watcher for instance, is then the one the server rendered.
781
- - **A failure is transferred too,** so hydration does not run the handler
782
- again: an HTTP failure keeps its `statusCode`, `statusMessage`, `message`
783
- and `data`, any other failure becomes a `500` whose cause only the server
784
- logs. A fetch error's message names the backend's address on a server, so
785
- its status text replaces it.
786
- - **`useDmsState(key, init, { transfer: true })`** hands the server's value
787
- to the browser, which then starts from it instead of calling `init`. State
788
- is never transferred unless a key opts in, and must never hold a token or
789
- a secret: the value is written into the page.
790
- - One `<script id="dms-payload" type="application/json">` carries the data,
791
- the failures and the transferred state, in
792
- [devalue](https://github.com/sveltejs/devalue)'s format, which keeps a
793
- `Set`, a `Map` or a `Date`, escaped like Inertia's page object. A value it
794
- cannot write, such as a class instance, is left out and logged.
795
- - **Stale while revalidate.** In the browser, data loaded before shows at
796
- once, and is read again in the background on each mount of a component
797
- showing it and on each return of a reactive key to it; awaiting the call
798
- does not wait for that read. The first mount after a server render does not
799
- read again: its data is the server's, just read. Of the data no component
800
- shows any more, the browser keeps the 50 most recently shown across
801
- navigations, so a return to a page shows its data at once.
802
- - **`error` stays until the next answer.** A run in flight keeps the last
803
- failure; a success clears it, a failure replaces it, and a failed refresh
804
- keeps the data of the last success on screen.
805
- - **`key`** is the key the data is loaded under (a reactive key's current
806
- one). `transform(value, { key })` receives it, and `useDmsFetch`'s
807
- `transform(value, { key, url })` the URL the response answers too, even when
808
- a reactive URL changed since. While a reactive key shows the previous key's
809
- data, `placeholder` is `true`.
810
- - **`data` is a shallow ref** (`deep: false`, as in Nuxt 4): replace the value
811
- to update it, or pass `deep: true` to track changes inside it.
812
- - **`immediate: false`** waits for `execute()` (a dialog, a drawer), and
813
- **`refreshInterval: ms`** reads again on an interval in the browser, while a
814
- component shows the data and the page is visible: every read of a module can
815
- go through `useDmsAsyncData` or `useDmsFetch`. The interval may be a ref or
816
- a getter: the polling follows it, and `0`, `null` or `undefined` stops it.
817
- - **A call outside a component** (a plugin, also past its first `await`, a
818
- callback) belongs to the application: its watchers and its polling run for
819
- as long as the application does, so a plugin can poll a status with
820
- `refreshInterval` and stop it through a reactive interval.
821
- - **`useDmsPagedData(key, handler, { nextCursor, initialCursor })`** reads a
822
- list page by page under one key ("load more", an infinite list). The
823
- handler receives `{ cursor, index, signal }` and returns a page;
824
- `nextCursor(page, pages)` gives the next page's cursor, or `null` at the
825
- end. The first page loads like any data (a server render waits for it and
826
- transfers it); `loadMore()` appends the next one, `pages`, `hasMore` and
827
- `loadingMore` follow it, and a failure lands in `error` with the pages
828
- kept. A revalidation (a mount, `refresh()`, `refreshDmsPageData()`, the
829
- polling) reads every page loaded so far again and replaces them once all
830
- are in. Put what filters the list in the key: a new key starts again from
831
- its first page.
832
- - **`refreshDmsPageData()`** reads again the data the components on screen
833
- show, after a save or on a realtime event; a call opts out with
834
- `refreshOnPageRefresh: false`, and data waiting for `execute()` is left
835
- alone. `refreshDmsData(keys?)` reads given keys again.
836
- - **`setDmsAsyncDataScope(scope)`** puts the keys in a scope, such as a digest
837
- of the account and the workspace: the same key in another scope is another
838
- entry, and moving to another scope drops the data of every key. Set it on
839
- both sides before the page renders, with the same value: the scope is
840
- written in the page with each key.
841
- - **A call without a key** (`useDmsAsyncData(handler)`, or `useDmsFetch` with
842
- a body it cannot key, such as a `FormData`) cannot be transferred: the
843
- server leaves it to the browser, as `server: false` does, so the page
844
- hydrates with what it shows. A development render warns about it, and about
845
- a backend read made outside any handler on the server (an immediate
846
- watcher, a setup's own `await $fetch`), which the browser makes again.
847
- - **`useDmsFetch(url, options)`** is `useDmsAsyncData` around a request,
848
- keyed on its method, base URL, URL, query and body (not its headers): the
849
- callers of one request share it, and a server render transfers it. The URL,
850
- `method`, `query`, `params` and `body` may each be a ref or a getter, read
851
- again for each request: the caller moves to the key of each request, as in
852
- Nuxt's `useFetch`. A URL that is `null` or `undefined` requests nothing
853
- until it has a value, on the server as in the browser. `key` names a key
854
- instead (a ref or a getter too: `null` requests nothing), and `$fetch`
855
- replaces the fetch function, which receives the signal.
856
- - **`dmsRequestKey(url, { method, baseURL, query, params, body })`** is the
857
- key `useDmsFetch` gives a request, without the scope: for
858
- `refreshDmsData(keys)`, and for a test that looks for a response's entry
859
- instead of computing the digest itself.
860
- - **A key that is `null` or `undefined`** (a ref or a getter for it) has
861
- nothing to load: the caller holds its default, and runs nothing until the
862
- key has a value.
863
- - **A new key is read where the first one would have been.** On a server,
864
- `server: false` and `immediate: false` read nothing, whatever key the
865
- render gives them. Another read follows a key its setup sets: the
866
- component's render waits for it and transfers it, `lazy` or not. A key that
867
- changes once that wait has started (the answer of another read of the
868
- same component, a later component) is not read on the server: the HTML
869
- shows the default, and the browser reads it once the component is mounted.
870
- To render a read that depends on another one's answer, await the first one
871
- in the setup.
872
- - **`useDmsData(key)`** gives the `data` another caller loads under `key`,
873
- as Nuxt's `useNuxtData`: assigning it, or changing it in place (unless the
874
- key is `deep: false`), updates every component showing the key without
875
- reading it again. It is how a write's answer (an optimistic update, put
876
- back if the write fails) or a realtime event patches data, including the
877
- data of a key the component does not show. `data` is `undefined` until a
878
- caller loads the key, while a reactive key still shows the previous key's
879
- data, and once the browser lets the key go (past the 50 most recent keys
880
- it keeps) or the account or workspace changes; a write to a key
881
- nobody loaded is ignored. The key may be a ref or a getter, and a key
882
- loaded by `useDmsFetch` is the `key` option it was given. It loads
883
- nothing and keeps nothing alive: a component showing the data calls
884
- `useDmsAsyncData` with the key. A run in flight still writes its answer
885
- over an update, so a writer refreshes the key once the write is done.
886
- In a server render, a component called after the caller's call waits for
887
- its data, so it renders what the browser hydrates it with.
888
-
889
- ### Icons
890
-
891
- The browser never asks a third party for an icon, so a Content-Security-Policy
892
- needs no exception for the Iconify API. Icons reach the page two ways:
893
-
894
- - **Bundled.** Nuxt UI's client bundle holds every icon its scanner finds in
895
- the workspace's Vue, Markdown and YAML files, the TypeScript of each layer's
896
- `app/` directory — `app.config.ts` and its `ui.icons` mappings included — and
897
- Nuxt UI's own defaults.
898
- - **Served.** Any other icon, such as a page icon a backend module declares,
899
- is fetched from the frontend server itself: `main.ts` points `@iconify/vue`
900
- at `GET /api/_dms/icons/<prefix>.json?icons=<name>,…`, which answers in the
901
- Iconify API format from the collections installed in the workspace
902
- (`@iconify-json/<prefix>`, or `@iconify/json`). The workspace ships
903
- `@iconify-json/lucide` and `@iconify-json/ph`; a module using another
904
- collection depends on its `@iconify-json/*` package. An unknown collection
905
- answers `404`, and a name outside Iconify's naming rule `400`. `ajs dms dev`
906
- and `ajs dms start` run the same server, so both serve the route.
907
-
908
- The server render draws every icon, bundled or not: the frontend server loads
909
- each installed `@iconify-json/*` collection once and hands it to the renderer,
910
- and an icon is drawn in its first render as soon as its data is loaded, instead
911
- of after the component mounts. The data of each icon a render drew travels in
912
- the page, so the browser draws the same icons as it hydrates; an icon no
913
- installed collection holds stays empty on the server and is fetched in the
914
- browser as before.
915
-
916
- Each icon is drawn once per page: a hidden `<svg id="dms-icon-sprite">` holds
917
- one `<symbol>` per icon (and per flip or rotation), and every occurrence is an
918
- `<svg>` with a `<use>` of it, so an icon on each row of a table is sent once.
919
- The browser draws the same way, adding the symbols of the icons it draws later
920
- to the same sprite.
921
-
922
- ## Options
923
-
924
- | Option | Environment | Purpose |
925
- | --- | --- | --- |
926
- | `-b, --backend-url` | `DMS_API_BASE_URL` (except `clean`) | DMS backend URL, `http://` or `https://` |
927
- | `-p, --port` | `PORT` | Frontend port from 1 to 65535, default `3001`; `dev` moves to the next free port, `start` stops when it is in use |
928
- | `-f, --force` | | Reinstall workspace dependencies; `build` also extracts the layers archive from scratch |
929
- | `--offline` | `DMS_OFFLINE` | Reuse cached manifest and archives; the only way `build` uses them |
930
- | `--strict` | `DMS_PREPARE_STRICT` | Make `prepare` fail instead of warning; see [Commands](#commands) |
931
- | `--bootstrap-secret` | `DMS_BOOTSTRAP_SECRET` | Backend bootstrap credential |
932
- | | `HOST` | Address the frontend server binds to: `127.0.0.1` by default for `ajs dms dev`, so only this machine reaches the development server (set `HOST=0.0.0.0` to open it to the network); `0.0.0.0` for `ajs dms start` |
933
- | | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
934
- | | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
935
- | | `DMS_COUNTRY_HEADER` | Client country header the server relays to the backend, set by a trusted reverse proxy (default `CF-IPCountry`; empty relays none). Name the same header in the backend's `auth.signInCountry.header`. Relayed only when `DMS_TRUSTED_PROXY_HOPS` is above `0`, like the client address |
936
- | | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
937
- | | `DMS_HTML_RENDER_SECRET` | Secret that verifies the backend's signed HTML/email render requests; without it, e-mail renders are refused. The `htmlRender.serviceSecret` the backend publishes in the frontend manifest wins; the variable is the fallback for a manifest without one |
938
- | | `DMS_OAUTH_RELAY_SECRET` | Secret the server presents to the backend's OAuth endpoints; without it, the backend refuses OAuth sign-in. The `oauth.relaySecret` the backend publishes in the frontend manifest wins; the variable is the fallback for a manifest without one |
939
- | | `DMS_AUTH_ESTABLISH_ENDPOINTS` | Extra backend endpoints `/auth/establish` may open a session from, on top of those the backend's modules declare (comma-separated, empty by default) |
940
- | | `DMS_BACKEND_PROXIES` | Extra backend prefixes the server proxies, on top of those the modules declare: a JSON array of `{ path, auth? }` (see [Routing and the backend proxy](#routing-and-the-backend-proxy)) |
941
- | | `DMS_PUBLIC_ENDPOINTS` | Extra backend routes the proxy serves to anyone, on top of those the backend's modules declare: a JSON array of `{ path, methods?, headers? }` (see [Public endpoints](#public-endpoints)) |
942
- | | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
943
-
944
- All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
945
-
946
- `ajs dms dev` takes the two manifest secrets from the manifest it fetches, and `ajs dms start` from the manifest cached by `ajs dms build`. An explicitly set variable always wins over the manifest value. Before the server starts, both commands warn about each secret that is not set and what stops working without it; once the server is ready, they list where the others come from (the environment, the manifest or the build-time manifest), never their values. When several modules publish the same secret, the first one in manifest-priority order wins, and a module publishing a different value is named in a warning.
947
-
948
- Use pnpm for all repository and workspace operations.
949
-
950
- ## Verify local frontend modules
951
-
952
- Use the packaged source verifier to validate unpublished DMS frontend modules against this exact adapter version. Run it from the project:
953
-
954
- ```bash
955
- ajs dms verify-source
956
- ```
957
-
958
- The modules are verified on top of the DMS core layer, the `frontend-vue` directory of `@antelopejs/dms`. The verifier finds the `@antelopejs/dms` the project installs the way Node does, from the current directory, and names it before it starts: `Verifying against @antelopejs/dms 0.5.5 (installed in this project)`. When the project does not install it, the verifier stops with the command that adds it as a development dependency (`pnpm add -D @antelopejs/dms`, or the equivalent for the project's package manager).
959
-
960
- Without `--module`, the verifier takes the current directory when it is a frontend module (it contains `dms.frontend.ts`), and its `frontend-vue` directory otherwise. `--module` names other ones and is repeatable, as is `--local-package`, which binds a local build of a package into the generated workspace. All supplied frontend packages are copied into the generated workspace and bound with `workspace:*`.
961
-
962
- ```bash
963
- ajs dms verify-source --module ./admin-vue --module ./shop-vue
964
- ```
78
+ ## Contributing
965
79
 
966
- `--layer` is for the DMS core layer's own development: it verifies an unpublished core layer instead of the installed one, here against a local build of `@antelopejs/dms`:
80
+ Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request.
967
81
 
968
82
  ```bash
969
- ajs dms verify-source \
970
- --layer /path/to/dms/frontend-vue \
971
- --local-package @antelopejs/dms=/path/to/dms
83
+ pnpm install
84
+ pnpm build
85
+ pnpm lint # oxlint, oxfmt, interface ranges and the docs reference check
86
+ pnpm typecheck
87
+ pnpm test
972
88
  ```
973
89
 
974
- The checks look for what the core layer ships: its lazily loaded chart and rich-text libraries, the pages every DMS serves and its e-mail templates. A `--layer` that holds no core layer, such as a project's own `frontend-vue`, is refused before anything is installed, with exit code 2 and the command that verifies the same folder with `--module` instead.
90
+ `pnpm docs:reference` regenerates the environment-variable table of `docs/4.configuration.md` from the list `ajs dms help environment` prints; `pnpm lint` fails when the table is stale.
975
91
 
976
- A supplied package whose declared range excludes this adapter version is refused before anything is installed (see [Declaring the loader releases a module supports](#declaring-the-loader-releases-a-module-supports)). The verifier generates a temporary workspace, installs it, builds the client, SSR and email bundles while `vue-tsc` runs alongside them (it does not compress the client assets, which only a served build needs), checks what the client bundle loads, renders eight DMS email templates, and checks that the email bundle excludes browser-only modules. Each check is one line that ends in ✔ or ✖, and the run ends with `Verified <n> modules` and its duration, or with the first failure: a type or Vite error points at the file in the package you passed (`./frontend-vue/app/components/Callout.vue:17:7`), not at its copy in the workspace, and the command exits 1. `--verbose` streams the pnpm, Vite and `vue-tsc` output. The workspace is removed when the run ends. It does not start a backend or publish packages. Repository development can invoke the same runner through `pnpm test:real-source`, with `DMS_LAYER_SOURCE` set to a core layer root and `DMS_MODULE_SOURCES` to a JSON array of module roots.
92
+ ## License
977
93
 
978
- Refresh rotation is single-flight within one frontend process. Horizontally scaled deployments must use sticky routing so a browser reaches the same process, or replace this process-local behavior with an external session adapter. It does not provide a distributed rotation guarantee.
94
+ [Apache-2.0](./LICENSE)