@dokebi/template-inertia 0.4.2 → 0.4.4

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/AI.md CHANGED
@@ -1,6 +1,6 @@
1
- # AI guide — @dokebi/template-inertia 0.4.2
1
+ # AI guide — @dokebi/template-inertia
2
2
 
3
- An Inertia (v3) server adapter. The first request is answered with HTML (plus `script[data-page]` JSON), later `X-Inertia` requests with page JSON. It reads the Vite manifest to fill in scripts, styles and the version, and serves the build files itself. Optionally it server-renders the first HTML in the same process (SSR, D-0039). Vue and Vite are not server dependencies (they are the app's dev dependencies). Node >=24.0.0, core ^0.4.0, @dokebi/web ^0.4.2 (peer). Details are in the [README](README.md).
3
+ An Inertia (v3) server adapter. The first request is answered with HTML (plus `script[data-page]` JSON), later `X-Inertia` requests with page JSON. It reads the Vite manifest to fill in scripts, styles and the version, and serves the build files itself. Optionally it server-renders the first HTML in the same process (SSR, D-0039). Vue and Vite are not server dependencies (they are the app's dev dependencies). Node >=24.0.0, core ^0.4.0, @dokebi/web ^0.4.3 (peer). Details are in the [README](README.md).
4
4
 
5
5
  ## 1. Install and project layout
6
6
 
@@ -13,7 +13,9 @@ npm run build # dist/ (browser) + dist/ssr
13
13
  > `npx dokebi` is the @dokebi/cli installed in the project (`dokebi init` adds it to devDependencies). The npm package `dokebi` is not this CLI, so never use `npx -y dokebi`. Without an installed CLI use `npx -y @dokebi/cli …`.
14
14
 
15
15
  - `export async function shared(request)` of `src/infra/inertia/shared.ts` is merged into every page's props (the call's props win).
16
+ - Plugins add shared props through `dokebi-web:view-data` (D-0076 §5, for example `auth` of an auth plugin): they come first, then `shared` of the entry, then the scope's `shared` (@dokebi/app adds `app`, `sealed`), then the page props. Partial reloads filter them like other props. A plugin key `errors` or `flash`, or one key from two plugins, refuses the setup (`VIEW_DATA_CONFLICT`).
16
17
  - app.ts of an Inertia app: `inertia: { title, vite: { manifest: 'dist/.vite/manifest.json', entry: 'src/apps/<app>/views/app.js', base: '/build/' }, ssr?: { bundle: 'dist/ssr/<app>/ssr.js' } }`.
18
+ - The project's `vite.config.ts` (written once by `app add --inertia`) has `plugins: [vue(), dokebiInertia()]` (§4.1). Keep `dokebiInertia()` in a config of your own.
17
19
 
18
20
  ## 2. Options `options.dokebi-template-inertia` (per app, `scope` overrides them)
19
21
 
@@ -21,12 +23,19 @@ npm run build # dist/ (browser) + dist/ssr
21
23
  |---|---|---|---|
22
24
  | `title` | string | `''` | document title of the first HTML |
23
25
  | `rootId` | string | `app` | id of the root element, `^[A-Za-z][A-Za-z0-9_-]*$` |
24
- | `version` | string | development | asset version; a client with another one reloads (409). Unset: `INERTIA_VERSION_DEFAULT` warning |
26
+ | `version` | string | development | asset version; a client with another one reloads (409). Up to 128 printable ASCII characters, no leading or trailing space (the client sends it back in the `X-Inertia-Version` header; anything else could never match). Unset (and no `vite`): `INERTIA_VERSION_DEFAULT` warning, written for each scope that has neither `vite` nor `version` itself (with `app`, the @dokebi/app app name) and once at the first render of the plugin's own renderer; nothing when every app sets its own |
25
27
  | `scripts` · `styles` | string[] | none | module scripts and stylesheets of the first HTML (local URLs starting with `/`) |
26
28
  | `vite: { manifest, entry, base? }` | object | none | computes scripts, styles and version from the manifest; `base` defaults to `/`. Refused together with version, scripts or styles |
27
- | `ssr: { bundle, failure?, entry? }` | object | none | server rendering. `failure`: `fallback` (default) or `error`. `entry` is the bundle's source module used in development mode (§5.1) |
29
+ | `ssr: { bundle, failure?, entry?, timeoutMs? }` | object | none | server rendering. `failure`: `fallback` (default) or `error`. `entry` is the bundle's source module used in development mode (§5.1). `timeoutMs` (1 to 600000, default 5000): how long one `render(page)` may take; past it the render fails with `SSR_TIMEOUT` and `failure` decides ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md)). Size it above your slowest page render (`ssrStats()` and the `INERTIA_SSR_FAILED` logs show cut renders) |
30
+ | `devHosts` | string[] | `[]` | development only (`DOKEBI_VITE_DEV=1`): host names, besides loopback names, IP addresses and @dokebi/app domains, that may load the Vite dev routes, for example a tunnel host. `.example.com` also allows its subdomains. At most 256 names; no `*`, no port ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md)) |
31
+ | `devDepsRepairIntervalMs` | integer 1000–3600000 | 10000 | development only (`dokebi dev`): the Vite dev host re-optimizes the pre-bundled dependencies (`node_modules/.vite/deps`) when they changed on disk under it, at most once per this many ms; a cache that keeps changing faster is reported (`INERTIA_VITE_DEPS_CHANGING`). Read by the host when `dokebi dev` starts (§5.1, [D-0128](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0128-vite-deps-cache-repair.md)) |
28
32
  | `encryptHistory` | `true` · `false` · `'sealed'` | `'sealed'` | Inertia history encryption; `'sealed'` turns it on for sealed requests (D-0062) |
33
+ | `routeIds: { assets?, viteDev? }` | strings | `template-inertia.assets`, `template-inertia.viteDev` | the route ids of the routes this plugin adds ([D-0142](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0142-plugin-route-ids.md)): `assets` every built file of `vite.manifest` (one route per file, one id), `viteDev` the `<base>*` routes of the Vite dev server (development only). `request.routeOptions.config.id` and `dokebi describe` `routes[].id` show them (describe serves no build, so it lists neither). An id taken by another route of the same app fails the start (`DUPLICATE_ROUTE_ID`) |
29
34
  | `errorPage` | string | none | page component of error answers (for example `Error`), rendered for Inertia visits and page loads (§3.3). Unset: JSON for visits, web's small HTML page for page loads |
35
+ | `bootFallback` | `{ enabled?, timeoutMs?, messages?, stylesheet?, logo? }` | `{ enabled: true, timeoutMs: 10000 }` | The root of the first HTML while the app loads (§3.4): a centered spinner, then "could not load" with a reload button. `enabled: false` leaves the root empty; `timeoutMs` 1000–120000; `messages` by language tag `{ loading?, failed?, reload? }` over the built-in Korean and English texts; `stylesheet` a local URL with the app's own look of the box; `logo` a local image above the spinner |
36
+ | `viteManifestMaxBytes` | integer 1024–67108864 | 4194304 | the largest Vite build manifest read at start; larger stops the start with `VITE_MANIFEST_INVALID`. Raise it for an app with thousands of chunks |
37
+ | `assetCacheMaxBytes` | integer 0–67108864 | 4194304 | built files up to this size are kept in memory after their first request; larger ones are streamed from disk every time (0: stream all). Memory is at most the sum of the served files at or below it |
38
+ | `logRepeatWindowMs` | integer 1000–3600000 | 10000 | window of the repeated per-request failure logs (`INERTIA_SSR_FAILED`, `INERTIA_PROP_RESCUED`, a failed shared-data or flash hook): the first is logged in full, the rest of the window are counted (`repeated`) |
30
39
 
31
40
  ## 3. Rendering pages
32
41
 
@@ -47,6 +56,7 @@ route.post('/orders', async (request, reply, page) => {
47
56
  - `render(request, reply, component, props?, { encryptHistory?, clearHistory? }?)`: GET/HEAD only. The fifth argument sets history handling for this response (otherwise the scope setting and the session signal). Return or await it in the handler. component is `/`-separated parts matching `[A-Za-z0-9][A-Za-z0-9_-]*`.
48
57
  - props: JSON values, async lazy functions or the helpers of §3.1. A partial reload (`only`, `except`) evaluates only the selected ones; `errors` is always included. Non-JSON values (`undefined`, functions, symbols, `NaN`, `Infinity`) fail with `TypeError: Page props must be JSON serializable`, BigInt with `JSON.stringify`'s `TypeError`, cycles with a `TypeError` `Converting circular structure to JSON` (outside production at once, naming the prop that closes the cycle; an object used twice without a cycle is fine). Development and tests (`NODE_ENV` not `production`) check every value of the page; production checks only the top-level props and leaves the rest to `JSON.stringify` (D-0068; a 30 KB page went from 112 to 24 µs), so this mistake is caught during development.
49
58
  - Prop types in TypeScript: describe props with `type` aliases (`type Post = { id: number; title: string }`), not `interface`. An interface has no index signature, so TypeScript refuses it where `Json` is required (`npm run typecheck` fails with "Index signature for type 'string' is missing"); a `type` with the same fields is accepted (AI-M04).
59
+ - The Vue side is type-checked too: `dokebi app add --inertia` makes `npm run typecheck` run `vue-tsc` after `tsc` (`node typecheck-vue.mjs -p tsconfig.vue.json`), which checks the `<script>` and the `<template>` of every `.vue` file. Write pages as `<script setup lang="ts">` with `defineProps<{ … }>()`; the shared props (`$page.props`, `usePage().props`) are typed in `src/infra/inertia/page.d.ts` (add a key there for what `src/infra/inertia/shared.ts` returns), and a prop only some pages get with `usePage<{ … }>()`. A Vite build is not a Vue type check.
50
60
  - `redirect(request, reply, localUrl)`: 302 for GET/HEAD, 303 for mutations; an Inertia `#` navigation gets 409 + `X-Inertia-Redirect`.
51
61
  - `location(request, reply, url)`: a local or external http(s) navigation. Inertia requests get 409 + `X-Inertia-Location`.
52
62
 
@@ -69,9 +79,9 @@ page.inertia!.render(request, reply, 'Orders/Index', {
69
79
  - `once(value | fn, { key?, ttlMs? })`: page `onceProps: { key: { prop, expiresAt } }`. When the client reports the keys it has with `X-Inertia-Except-Once-Props`, they are not computed on the first load. A partial reload that asks for that prop with `only` recomputes it.
70
80
  - The helpers work in `render` props and in `shared` props. Do not wrap one in another (combine through the `defer` options). No commas or spaces in group, key or matchOn. Wrong arguments are `INVALID_PROP_HELPER`. `isInertiaProp(value)` and the `InertiaProp` class identify helper values.
71
81
  - Partial reloads select with `only` and `except` as before and also compute the selected deferred and once props (`except` alone includes deferred ones). The notice fields list only the selected props.
72
- - A failing prop function that is not `rescue` fails the request as before (500; web logs the full error). A rescue logs `INERTIA_PROP_RESCUED` (error, once per 10 seconds with `repeated`). Debug: `Inertia props resolved` (evaluated, deferred, heldOnce, rescued), `Inertia prop failed`.
73
- - An Inertia request with an outdated version gets 409 + `X-Inertia-Location` without evaluating props (forcing a reload).
74
- - Responses carry `Vary: X-Inertia` and `Cache-Control: no-store`, and the JSON, title and URLs put into HTML are escaped.
82
+ - A failing prop function that is not `rescue` fails the request as before (500; web logs the full error). A rescue logs `INERTIA_PROP_RESCUED` (error, once per `logRepeatWindowMs`, 10 seconds, with `repeated`). Debug: `Inertia props resolved` (evaluated, deferred, heldOnce, rescued), `Inertia prop failed`.
83
+ - An Inertia GET (or HEAD) visit with an outdated version gets 409 + `X-Inertia-Location` without evaluating props (forcing a reload). Other methods are not version-checked (as in the official adapters): an error page of a POST stays that error page.
84
+ - Responses carry `Vary: X-Inertia` and `Cache-Control: no-store`, and the JSON, title and URLs put into HTML are escaped. With SSR, the text of a `<title>` head tag the bundle returns is made safe too: @inertiajs/vue3 escapes `<Head title="…">` but writes the text of a `<title>` child (`<Head><title>{{ product.name }}</title></Head>`) as it is, so every `<` inside a title is written as `&lt;`, whatever the case or spacing of a closing tag in it (a name holding `</TITLE><script>` stays text; already escaped text is not escaped twice). Prefer `<Head :title="product.name" />`: the browser keeps it escaped on client-side visits too. Other head tags with text (a `<script>` or `<style>` in `<Head>`) are yours: never put request or stored data into them.
75
85
 
76
86
  ### 3.2 Session flash (D-0055)
77
87
 
@@ -96,6 +106,7 @@ route.post('/orders', async (request, reply, page) => {
96
106
 
97
107
  - **With @dokebi/session**: the `errors` (`{ field: message }`) and the `old` input (the body's top-level strings, numbers and booleans, without password-, token-, secret- or card-like fields; left out above 8 KB) go into the session flash, and the answer is **303 back** to the `Referer` when it is this host (another origin is never followed, nor a path that reads as another host such as `//other.host/…`), otherwise to `/` for a POST/PUT/PATCH/DELETE. The next page gets the flashed `errors` as its **`errors` prop** (under errors the handler passed itself) and `old` stays in the page `flash` (`usePage().flash.old`). The client keeps the form's own state on a non-GET visit, so `useForm().errors.name` just shows the message. A GET refused by its query schema without a usable referer (a redirect to itself would loop) gets the JSON below. The target and the old input come from @dokebi/web's form helpers (`formBackTarget`, `formOldInput`, 0.4.1), the same rules @dokebi/app uses for HTML forms.
98
108
  - **Without a session** the answer is the 422 JSON with `errors`: Inertia hands a non-page answer to `onHttpException`, so read it there: `form.post(url, { onHttpException: (response) => { form.setError(response.data.errors); return false } })`.
109
+ - **Other 4xx answers that ask for it** (0.4.3, D-0076 §2.5): an error whose `data.formErrors` is a `{ field: message }` record of strings goes back the same way (303, `errors` flashed), for example @dokebi/csrf's expired token with `errors._form` (`_form` is the whole-form key: show `form.errors._form` or `usePage().props.errors._form` above the form). Without a session such an error gets the `errorPage` component (or its JSON), not a 422.
99
110
  - **Submit with `only`.** The GET after the 303 is a new visit, and a full visit must send every prop of the page again (Inertia replaces all props, so the server cannot trim it by itself). Pass the props the submit changes and Inertia keeps the partial headers across the redirect; `errors` always comes along:
100
111
 
101
112
  ```js snippet
@@ -106,11 +117,92 @@ form.post('/guestbook', { only: ['entries'], onSuccess: () => form.reset('text')
106
117
 
107
118
  `npx dokebi check` warns about an Inertia submit without `only` (`inertia-form-only`); a login or logout that really changes every prop takes `// dokebi-check: allow inertia-form-only <reason>`. Nothing on the server can make a full visit partial safely, so this is the client's part.
108
119
 
109
- **Error page (`errorPage`).** With `errorPage: 'Error'` (plugin option, or per app `defineApp({ inertia: { errorPage } })`, `false` turns it off for a scope) the other error answers of Inertia visits and page loads (403, 404, 500, 503 from a handler; an unknown route) render that component with the error status and the props `{ status, error, message, hint }` (and `fix` while developing; `message` and `hint` may be null). A page load gets the first HTML of the page (server rendered with SSR), a visit its JSON; sealed requests stay sealed. Scripts (`fetch`, Bearer) keep JSON, validation errors keep the redirect, and the page leaves the session flash for the next page. Without `errorPage` a visit gets the JSON error (Inertia shows it in its error modal while developing) and a page load web's small HTML page. dokebi-app asks the app's own scope first, so an app's page uses that app's assets.
120
+ **Error page (`errorPage`).** With `errorPage: 'Error'` (plugin option, or per app `defineApp({ inertia: { errorPage } })`, `false` turns it off for a scope) the other error answers of Inertia visits and page loads (403, 404, 500, 503 from a handler; an unknown route) render that component with the error status and the props `{ status, error, message, hint }` (and `fix` while developing; `message` and `hint` may be null). A page load gets the first HTML of the page (server rendered with SSR), a visit its JSON; sealed requests stay sealed. Scripts (`fetch`, Bearer) keep JSON, validation errors keep the redirect, and the page leaves the session flash for the next page. A **503** page (a store, database or Redis down or overloaded, [D-0102](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0102-dev-hosts-and-redis-overload.md)) gets only those props and `errors: {}`: the shared props (auth, csrf, i18n, the scope's `shared`) are not evaluated, because they may read the store that just failed; write the error page so it renders without them. Without `errorPage` a visit gets the JSON error (Inertia shows it in its error modal while developing) and a page load web's small HTML page. dokebi-app asks the app's own scope first, so an app's page uses that app's assets.
121
+
122
+ ### 3.4 Translations in Vue pages (`@dokebi/template-inertia/i18n`, D-0099)
123
+
124
+ The browser runtime of `@dokebi/i18n`: the same catalogs (`src/shared/locales/<locale>/<namespace>.json`) translate the server and the Vue pages with the same rules. With @dokebi/i18n installed and `options.dokebi-i18n.http.enabled: true`, every page gets the shared prop `i18n` (the page's namespaces, the lookup chain, the named formats; see the i18n AI.md). The `views/app.js` and `views/ssr.js` that `dokebi app add --inertia` writes install it, so pages only call `useI18n()`:
125
+
126
+ ```vue
127
+ <script setup lang="ts">
128
+ import { useI18n } from '@dokebi/template-inertia/i18n';
129
+ defineProps<{ posts: { id: number; title: string }[] }>();
130
+ const { t, n, d, locale, locales, switchLocale, localeName, timeZone } = useI18n();
131
+ </script>
132
+ <template>
133
+ <h1>{{ t('posts.title') }}</h1>
134
+ <p>{{ t('posts.count', { count: posts.length }) }}</p>
135
+ <p>{{ t('auth.password.minimum', 'At least {count} characters', { count: 8 }) }}</p>
136
+ <p>{{ n(12.5, 'usd') }} · {{ d(Date.now(), 'day') }} · {{ $t('common.nav') }}</p>
137
+ <button v-for="code in locales" :key="code" type="button" :aria-pressed="code === locale" @click="switchLocale(code)">
138
+ {{ localeName(code) }}
139
+ </button>
140
+ </template>
141
+ ```
142
+
143
+ - `t(key, parameters?)`, `t(key, defaultValue, parameters?)`, `n(value, format?)`, `d(value, format?)`, `has(key)`: the server translator's rules — `{name}` placeholders, `{{`/`}}` literal braces, cardinal plurals chosen by `Intl.PluralRules` of the locale that gave the message, the lookup chain selected → registered parents → default, an explicit default that wins over the chain but not over the locale's own message, the `missing` policy in effect on the server (`error` throws `MISSING_MESSAGE` naming the namespace to send; `key` returns the key; dokebi-i18n's default `auto` is `key` under `npx dokebi dev` and `error` elsewhere), `maxOutputLength`, `maxKeyLength` and `maxBranchLength` of the server's settings (defaults 256 and 16384 when the server sends none), the named number/date formats and the application's time zone of the page's settings. A parity test runs the server translator and this runtime on one corpus.
144
+ - `timeZone()` is that zone: it follows `setTimeZone` on the server from the next page on (D-0178). Pass it to `@dokebi/i18n/calendar` so a page counts days as the server does (`dayOf(timeZone(), Date.now())` is the site's today; `presetRange(timeZone(), 'last7')` a filter's days).
145
+ - `locale` and `locales` are read-only refs (destructure them; templates unwrap them). The functions are bound, so destructuring keeps them working. Templates also have `$t`, `$n`, `$d` and `$i18n`.
146
+ - `switchLocale(locale)` writes the preference cookie of dokebi-i18n (`http.cookieName`) and reloads the page's props through Inertia's router (no document reload): every prop comes back translated by the server, plus the messages this browser does not hold. `<html lang>` follows. `localeName(code)` is the language's own name for a switch (`Intl.DisplayNames`). The generated `views/default/Layout.vue` shows such a switch when there are two or more locales.
147
+ - `useI18n({ optional: true })` returns undefined without the prop (the generated layout uses it); plain `useI18n()` throws `UNAVAILABLE` then.
148
+ - `pageI18n(page, { router?, logger?, storage? })` is the Vue plugin of an Inertia app: `views/app.js` passes `props.initialPage` and `router` (`@inertiajs/vue3`), `views/ssr.js` passes the page of the request (no router, a new instance per render). Messages come in bundles (page locale × namespace) named by content tokens. With the router, every visit names the bundles this document holds (`X-Dokebi-I18n: v=<version>; h=<token><token>…`, within the server's `maxHeaderBytes`), each new page's bundles replace older ones of the same namespace, and a page restored from history whose bundles are gone is taken from the browser's store or completed with `router.reload({ only: ['i18n'] })`. A new catalog version brings new settings; unchanged bundles stay. Without the `i18n` prop it installs nothing, so the entry works before @dokebi/i18n is added.
149
+ - **Kept between page loads (D-0126).** In a browser, when the server allows it (`settings.cache`, dokebi-i18n `http.pageCache`), the bundles a page receives are written to localStorage when the page is idle (one item per bundle: `dokebi-i18n:1:<locale>:<namespace>:<token>:…`, one version per locale and namespace, within `maxBundles` and `maxStoreCharacters`, the oldest bundles of other locales evicted first, also when the quota is full) and the cookie `dokebi-i18n-held` names them (newest of the shown locale first, within `maxCookieBytes`). The next full page load leaves them out of the HTML (`held`) and the runtime reads them from the store before the first render; the kept bundles of the shown locale are loaded into memory when idle, so visits name them too, and `switchLocale` names the kept bundles of the target language. **When the store lacks a bundle the server left out** (storage cleared, a changed cookie, a full quota, another tab evicted it), the runtime rewrites the cookie from what is really kept and fetches the missing messages with an Inertia partial request for `i18n` **before `app.mount` runs** (pageI18n defers the app's `mount`, so neither a client render nor a hydration ever draws without them; an SSR page stays visible meanwhile). If that request gets no Inertia answer, the cookie is deleted and the document is loaded again (at most once a minute per tab; after that the page mounts and the failure is logged at `error` as `I18N_PAGE_RECOVERY_FAILED`). On the server, SSR takes the left-out bundles from the catalogs of @dokebi/i18n in the same process, so the first paint has them; a renderer in another process cannot, and `t()` throws `MISSING_MESSAGE` (the SSR failure policy applies). `storage: false` keeps nothing. No app code changes: the generated `views/app.js` and `views/ssr.js` are unchanged.
150
+ - A project made before this entry existed: `views/app.js` and `views/ssr.js` are yours, so add the two lines by hand — `import { pageI18n } from '@dokebi/template-inertia/i18n'`, then `.use(pageI18n(props.initialPage, { router }))` after `.use(plugin)` in `setup` (`router` from `@inertiajs/vue3`), and `withApp: (vue) => vue.use(pageI18n(page))` in the `createInertiaApp` options of `views/ssr.js`.
151
+ - `createI18n({ locale, messages, defaultLocale?, fallback?, missing?, timeZone?, maxOutputLength?, maxKeyLength?, maxBranchLength?, numberFormats?, dateFormats?, logger? })` makes an instance from given messages (flat dot keys or nested namespace objects), for plain Vue apps and unit tests: `app.use(createI18n(...))`.
152
+ - Debug logs (D-0047) go to the optional `logger` (`{ isEnabled?, log(level, message, fields) }`): `Missing translation returned as key` (`I18N_MISSING_KEY`), `Page translations are off` (`I18N_PAGE_OFF`), `Page translations reloaded` (`I18N_PAGE_RELOAD`), `Page language switch` (`I18N_PAGE_SWITCH`); D-0126: `I18N_PAGE_INCOMPLETE` (a page lacks bundles), `I18N_PAGE_RECOVER`, `I18N_PAGE_RECOVERED`, `I18N_PAGE_RECOVERY_RELOAD`, `I18N_STORE_COOKIE`, `I18N_STORE_LOADED`, `I18N_STORE_FULL`, `I18N_STORE_TRIMMED`, `I18N_STORE_SKIPPED`, `I18N_STORE_INVALID` (an item that is not the server's shape is removed), `I18N_STORE_UNAVAILABLE` (storage refused: nothing is kept and the cookie is deleted), `I18N_STORE_OFF`.
153
+ - Types: `page.d.ts` of the scaffold types `$page.props.i18n` (`PageI18nPayload`) and `locale`; importing the module also types `$t`, `$n`, `$d` and `$i18n` for vue-tsc. Vue is an optional peer dependency (`^3.5.0`), used only by this entry.
154
+ - Do not add `vue-i18n` or another translation runtime, import the catalogs into the browser bundle, cache messages yourself or build a route that serves them: the prop already carries what the page needs, once per browser and bundle version.
155
+
156
+ <!-- docs-check: complete-error-codes PageI18nErrorCode -->
157
+ | Code | When |
158
+ |---|---|
159
+ | `UNAVAILABLE` | `useI18n()` on a page without the `i18n` prop (no @dokebi/i18n, or `http.enabled` off) |
160
+ | `INVALID_CATALOG` | a message or explicit default with invalid syntax, or a malformed `i18n` prop |
161
+ | `INVALID_LOCALE` | an invalid BCP 47 tag; `switchLocale` without a router to a locale without messages |
162
+ | `INVALID_FORMAT` | an unknown or invalid named number/date format |
163
+ | `MISSING_MESSAGE` | an invalid key, or no message in the lookup chain with `missing: error` |
164
+ | `INVALID_PARAMETER` | a missing parameter, a non-finite number, an invalid date or plural count |
165
+ | `OUTPUT_LIMIT` | the rendered message (or a returned missing key) exceeds `maxOutputLength` |
166
+ | `RECOVERY_FAILED` | the fetch of a first page's missing bundles got no Inertia answer, or one without them (handled: the document is loaded again without the cookie; it is the `error` of `I18N_PAGE_RECOVERY_FAILED`) |
167
+
168
+ ### 3.4 While the app loads (`bootFallback`)
169
+
170
+ A page whose entry script cannot be fetched (a `dokebi dev` restart, a deploy in progress) used to stay white: no line of the app ran to say why. The root of the first HTML now holds a short text: "Loading…" until the app mounts (Vue's `mount` empties the root), then "The app could not be loaded. Reload" — after `timeoutMs` (10 s), or at once when a module script of the page fails to load. The language follows `<html lang>` (the `Content-Language` of @dokebi/i18n): Korean for `ko`, English otherwise; `messages` sets other languages or other words (`{ 'pt-BR': { loading: 'Carregando…' } }`, exact tag first, then its primary subtag). "Reload" is a plain link to the same page. The switch after the timeout is CSS only (an animation delay), so it works where scripts do not; the early switch is a small inline script in the head, before the module scripts. A server-rendered page (§5) has its markup and gets neither.
171
+
172
+ - The built-in look: the box fills the window and centers its line; "loading" is a spinner (`[data-dokebi-boot-loading]::before`; the text stays for screen readers at font size 0), "could not load" its text over the reload link drawn as a button. Neutral greys that read on a light and a dark page.
173
+ - The built-in look follows the browser: a dark colour scheme (`prefers-color-scheme: dark`) gets a dark box over the whole window (the page behind it is white without the app's CSS), a light one leaves the page's background; reduced motion (`prefers-reduced-motion: reduce`) stops the spinner. `logo` (a local URL such as `/assets/logo.svg`) shows an image above the spinner and the "could not load" text, decorative (`alt=""`, `[data-dokebi-boot-logo]`, at most 160 × 64 px by default).
174
+ - Selectors for your stylesheet: `[data-dokebi-boot]` (the box, `role="status"`, `data-dokebi-boot="failed"` once it failed early), `[data-dokebi-boot-loading]`, `[data-dokebi-boot-failed]`. Never set `visibility`, `position` or `animation` on the two lines (their switch is those); animate a pseudo-element instead.
175
+ - **Your own look: `bootFallback.stylesheet`** (a local URL such as `/assets/boot.css`, a plain CSS file of the app's `public/` that @dokebi/app serves) is linked right after the built-in style, so it applies before any script of the app runs. Do not style the box from the Vite bundle (`main.css`): under `dokebi dev` that CSS comes with the scripts, so the box shows unstyled while they load and stays unstyled when they fail. Keep the file small and self-contained (no Tailwind, no tokens of the bundle: they are not loaded yet).
176
+ - CSP: web's default policy allows both inline blocks (`'unsafe-inline'`). A policy without it needs their hashes in `style-src` and `script-src` — the debug records `Inertia error answers` and `Inertia scope created` carry `bootFallback.cspHashes` (fixed per root id and timeout) — or `bootFallback: { enabled: false }`.
177
+ - A scope (`scope({ bootFallback })`, an app's inertia options) has its own; `false` there turns it off for that scope.
110
178
 
111
179
  ## 4. Serving build files
112
180
 
113
- With `vite`, the manifest (at most 4 MiB) is read once in setup, and only the files reachable from the entry (static and dynamic chunks, CSS, assets) are served at the exact path `<base><file>` (`cache-control: public, max-age=31536000, immutable`). Each file is read on its first request and kept in memory (files above 4 MiB are streamed per request), so rebuild with new hashed names and restart instead of changing a served file in place. The manifest itself, files outside that list and `dist/ssr/` are never served. A missing manifest fails the start (`Vite manifest not found: … Build the views first (npm run build).`), and so does a missing entry.
181
+ With `vite`, the manifest (at most `viteManifestMaxBytes`, 4 MiB) is read once in setup, and the files reachable from the entry (static and dynamic chunks, CSS, assets) are served at the exact path `<base><file>` (`cache-control: public, max-age=31536000, immutable`). Each file is read on its first request and kept in memory (files above `assetCacheMaxBytes`, 4 MiB, are streamed per request), so rebuild with new hashed names and restart instead of changing a served file in place. A missing manifest fails the start (`Vite manifest not found: … Build the views first (npm run build).`), and so does a missing entry.
182
+
183
+ **Rolling deploys (two builds at once).** During `rollingRestart` (or any deploy that replaces workers one by one) old and new workers answer side by side: a page of the new build loads its chunks from workers that read the old manifest, and the other way round. So every worker also serves **the other files of the folders its manifest's files sit in** (`<base>assets/*` for Vite's default `assets/`), streamed from disk: regular files only, inside that folder (no `..`, no hidden name, no symbolic link out of it), never a source map (`*.map`: the manifest names none, and `build.sourcemap: 'hidden'` writes them there for error tracking only); a content-hashed name (`app-3fa8c1d2.js`) gets the immutable header, any other `no-cache`; a name that is not there is 404. The manifest, `.vite/`, `dist/ssr/` and files directly in `outDir` are never served. For this to work the deploy must **keep the chunks of the previous build**: build with `build.emptyOutDir: false` (Vite's default `true` deletes them, and pages of the old build then miss their chunks on every worker). Remove the old chunks with `pruneBuildFiles` of `@dokebi/template-inertia/prune`, after a deploy or daily, on every host that has its own `dist/`: `node -e "import('@dokebi/template-inertia/prune').then(async (m) => console.log(await m.pruneBuildFiles({ manifest: 'dist/.vite/manifest.json' })))"` (also as a `prune:build` script in package.json). The current manifest decides, not the age alone: every file it names (and the source map of one) stays however old it is; a content-hashed file of the manifest's folders that it does not name goes once it is unchanged for `olderThanMs` (default 7 days, the time a page of an earlier build may stay open; 0 removes them all). Unhashed names (`robots.txt`), hidden names, links and the files directly in `outDir` (`.vite/`, `dist/ssr/`) are never touched. A manifest that is missing (`VITE_MANIFEST_MISSING`), not an object of chunks, larger than `maxManifestBytes` (4 MiB) or naming no file (`VITE_MANIFEST_INVALID`) removes nothing. `dryRun: true` lists what would go; the result is `{ folders, removed, kept }`. Do not use an age-only cleanup such as `find dist/assets -type f -mtime +7 -delete`: when no deploy comes for a week it removes the current build's files too (a page then misses its chunks: 404). Server rendering stays on the manifest and bundle a worker read at start: an old worker renders the old build until it is replaced. Its SSR bundle may still import chunks of its own lazily (a dynamic `import()`, a non-eager `import.meta.glob`) at its first render of a page, so build the SSR side with `emptyOutDir: false` too; an emptied `dist/ssr/` makes that render fail (`INERTIA_SSR_FAILED`, the client-rendered fallback). Debug logs: `Vite build folder served` (url, folder), per request `Vite built file served from its folder`.
184
+
185
+ ### 4.1 The Vite plugin (`@dokebi/template-inertia/vite`, D-0062 addendum 2026-10-20)
186
+
187
+ The seal client (`@dokebi/seal/http`) keeps its seal (the bridge, the clock offset) in module state, so the `sealInertia(http)` bootstrap of `views/app.js`, `sealedFetch` and `currentSealBridge()` must be **one module instance**: the file the app serves (`/dokebi/seal/http.mjs`, @dokebi/seal). A second, bundled copy has no bridge: it sends plain bodies (415 `SEAL_REQUIRED`) and throws `SEAL_BRIDGE_MISSING` on sealed answers. `dokebiInertia()` makes every import of `@dokebi/seal/http` and `@dokebi/seal/forms` (`SERVED_MODULES`) in the app's browser code load the served file:
188
+
189
+ - `vite build` (client): the import stays an import of `/dokebi/seal/http.mjs` (an absolute external). The bundle carries no copy, and no import map is needed.
190
+ - dev server (`dokebi dev`): Vite would prefix an absolute import with `base`, so the import goes to `<base>@dokebi-served/seal/http.mjs` (`DEV_MODULE_PREFIX`), which the plugin answers before Vite with `export * from "/dokebi/seal/http.mjs"`: the browser still loads the served file, once.
191
+ - server rendering (SSR builds, development SSR): untouched; Node resolves the package as usual (sealed pages are never server rendered, §8).
192
+ - Vite's dependency optimizer (dev only): the plugin adds `dokebi-inertia:served-deps` to `optimizeDeps.rolldownOptions.plugins`, which keeps the served names and `@dokebi-served` ids external there. The scan pre-bundles the app's other dependencies as usual, and a pre-bundled dependency that imports `@dokebi/seal/http` uses the served module too. No `optimizeDeps` setting of your own is needed for it.
193
+
194
+ So a Vue page writes a plain static import, and the dynamic import of the generated bootstrap uses the same name:
195
+
196
+ ```js snippet
197
+ import { sealedFetch } from '@dokebi/seal/http'; // the served instance sealInertia opened
198
+ const answer = await sealedFetch('/posts/preview', { method: 'POST', body: { title } });
199
+ ```
200
+
201
+ - `dokebiInertia({ logger? })` returns a Vite plugin (`name: 'dokebi-inertia'`, `enforce: 'pre'`). Debug logs (D-0047, `DOKEBI_LOG_LEVEL=debug`, or the given `logger`): `Inertia Vite plugin keeps served modules out of the bundle` (command, base), per resolution `Inertia Vite plugin resolved a served module`, per dev answer `Inertia Vite plugin answered a served module re-export`, and in the dev server's dependency optimizer `Inertia Vite plugin keeps served modules out of dependency pre-bundling` (once) and `Inertia Vite plugin kept a served module out of dependency pre-bundling` (per answer).
202
+ - Without @dokebi/seal in the project the bootstrap's import is never executed (plain pages skip it), and a page that imports `@dokebi/seal/http` gets a 404 for `/dokebi/seal/http.mjs`: install @dokebi/seal (`npx dokebi add @dokebi/seal`).
203
+ - `dokebi app add --inertia` keeps an existing vite config and warns `VITE_PLUGIN_MISSING` when it does not mention `dokebiInertia`.
204
+ - `dokebiInertia({ allow?, deny? })` also carries the dev file policy (§5.2): `allow` adds files or folders the browser loads (relative to the Vite root), `deny` adds Vite globs that are never served. Do not set `server.fs` in vite.config: the plugin forces `strict: true` and keeps its deny list anyway, and `dokebi check` warns (`vite-dev-files`).
205
+ - Never load `/dokebi/seal/http.mjs` by URL (`import(/* @vite-ignore */ …)`) next to an import of `@dokebi/seal/http`: with the plugin there is no need, and without it they are two instances.
114
206
 
115
207
  ## 5. SSR (D-0039)
116
208
 
@@ -124,22 +216,38 @@ With `vite`, the manifest (at most 4 MiB) is read once in setup, and only the fi
124
216
  ## 5.1 Development mode (HMR, D-0045)
125
217
 
126
218
  - With the environment variable `DOKEBI_VITE_DEV=1` (set by `dokebi dev`) **the project's Vite in middleware mode** serves the pages without a manifest or a build (one per dev session, below). Page scripts are `<base>@vite/client` and `<base><vite.entry>`, there are no style links, and the version is `dev`. Vite answers `<base>*` requests.
127
- - **One Vite per `dokebi dev` session (D-0045 addendum 3)**: `dokebi dev` starts the Vite host (`@dokebi/template-inertia/vite-host`) once, before the app, and passes `DOKEBI_VITE_DEV_URL` and `DOKEBI_VITE_DEV_KEY`. Every process and cluster worker forwards `<base>*` to it and asks it for development SSR. The host outlives app restarts (no new pre-bundling; open pages keep HMR). Without `DOKEBI_VITE_DEV_URL` (an older CLI, or `DOKEBI_VITE_DEV=1` set by hand) the process starts a host of its own, so each cluster worker then has its own Vite: run cluster apps through `dokebi dev`.
128
- - **Loopback only**: the host listens on one free port on `127.0.0.1` and `::1` (only `127.0.0.1` without an IPv6 loopback). HTTP there needs the key (`x-dokebi-vite-key`, otherwise 403), so a page cannot read sources from it. HMR works only for browsers on this computer (`localhost`, `*.localhost`, app domains that point at `127.0.0.1` through the hosts file). Pages opened from other devices or through tunnels work without HMR (reload to see changes).
129
- - The browser's HMR socket connects to the host's port on the page's host name (Vite 8 `server.ws`), so every worker advertises the same port and token.
219
+ - **One Vite per `dokebi dev` session (D-0045 addendum 3)**: `dokebi dev` starts the Vite host (`@dokebi/template-inertia/vite-host`) once, before the app, and passes `DOKEBI_VITE_DEV_URL` and `DOKEBI_VITE_DEV_KEY`. Every process and cluster worker forwards `<base>*` to it and asks it for development SSR. The host outlives app restarts (no new pre-bundling; open pages keep HMR). It does not outlive `dokebi dev`: when dev goes away without stopping it (killed, crashed) and its IPC channel closes, also while Vite is still starting, the host stops by itself (warn `INERTIA_VITE_HOST_ORPHANED`, exit code 0), freeing its port and its cache marker. Without `DOKEBI_VITE_DEV_URL` (an older CLI, or `DOKEBI_VITE_DEV=1` set by hand) the process starts a host of its own, so each cluster worker then has its own Vite: run cluster apps through `dokebi dev`.
220
+ - **Loopback only**: the host listens on one free port on `127.0.0.1` and `::1` (only `127.0.0.1` without an IPv6 loopback). HTTP there needs the key (`x-dokebi-vite-key`, otherwise 403), so a page cannot read sources from it. The browser never connects to this port.
221
+ - **HMR through the page's own origin (2026-10-28)**: Vite's client connects its HMR socket to `<base>` on the origin the page came from — `ws://localhost:<app port>/build/`, or `wss://<tunnel host>/build/` through a tunnel (Vite 8 `server.ws` without a port: the client advertises `hmrPort = null`). The app joins that upgrade (subprotocol `vite-hmr`) to the host on loopback: the browser's handshake goes on as it came, then bytes flow both ways. So HMR works locally, through tunnels (Cloudflare Tunnel, ngrok) and from other devices allowed by `devHosts`, with nothing to configure. Every process and cluster worker does this; next to @dokebi/websocket the upgrade is joined in the `<base>*` dev route (@fastify/websocket routes every upgrade through Fastify), otherwise by an upgrade listener of this plugin, which in development answers other upgrades 404. Before, the client connected to `<page host>:<host loopback port>`, which no tunnel reaches (`WebSocket closed without opened`, `[vite] failed to connect to websocket`).
222
+ - The same host check as the dev routes runs first (loopback names, IPs, @dokebi/app domains, `devHosts`; others get `403`). Behind a tunnel set dokebi-web `trustProxy` so the tunnel's host is seen (@dokebi/web AI.md §2.6). A host that cannot reach Vite is logged at error `INERTIA_VITE_DEV_FAILED` once per window.
130
223
  - Vite's token check (protection against cross-site WebSocket hijacking) stays. Upgrades that are not Vite's subprotocols are cut. Closing the host closes its port and connections, and a host whose Vite fails to start closes its port.
131
- - SSR: the host runs `ssrLoadModule(<source entry>)` and `render(page)` per request. The source entry is `ssr.entry` (for example `src/apps/admin/views/ssr.js`), otherwise `ssr.js` in the folder of `vite.entry`. When neither exists the start fails (`SSR_ENTRY_MISSING`).
224
+ - **Late file events are no edits (D-0045 addendum 4, 0.4.3)**: on macOS, FSEvents also delivers events for writes made before the watch started (`dokebi init`, `dokebi add`, `npm install`, a checkout), minutes late when fseventsd is busy. The host records each module file's state (device, inode, size, modification time) when Vite first loads it in an environment (before Vite reads it) and the `vite.config.*` files before Vite starts, and drops a watcher `change`, `add` or `unlink` whose file state equals every recorded one, so such an event invalidates nothing, sends no reload or update (a reload was buffered for the first page that connected) and restarts nothing. Any other event goes on at once. Files Vite never loaded through its plugin pipeline are not filtered. A save that changes nothing but the modification time is still an edit.
225
+ - **Pre-bundled dependencies that load (D-0128, 0.4.4)**: Vite keeps what its dependency optimizer wrote (each dependency's file under `node_modules/.vite/deps` and the `?v=` hash pages get) in memory, and at start trusts `_metadata.json` whenever the lockfile and config hashes match; it never looks at the files again, and it answers a file that is gone with 504 "Outdated Optimize Dep". A folder changed under it (two Vite optimizers on one folder — two `dokebi dev` sessions, a host still closing while the next starts — or a reinstall) therefore left every page blank through app restarts and dev restarts, until `rm -rf node_modules/.vite`. The host now compares its optimizer's files and metadata hashes with the folder when it starts, whenever an app process connects (every app or worker start) and after it answered a 504; when they differ it removes the deps folder and restarts its Vite (warn `INERTIA_VITE_DEPS_STALE` with `trigger` `start`, `client` or `outdated`, `drift` `missing` or `rewritten`, and a sample of the missing files), and the app process is answered after the repair. Open pages reconnect HMR and reload; a new page load gets the new hashes. At most one repair per `devDepsRepairIntervalMs`; faster changes are reported with warn `INERTIA_VITE_DEPS_CHANGING`. The host records its pid in `<cacheDir>/.dokebi/vite-host.json` and warns `INERTIA_VITE_HOST_SHARED` when another live host recorded itself there (stop one of the two `dokebi dev`). App processes pass Vite's status text on (`504 Outdated Optimize Dep`).
226
+ - SSR: the host runs `ssrLoadModule(<source entry>)` and `render(page)` per request. The app process asks on connections of its own (a render that waits never holds the connections module requests are forwarded on) and cuts the request at `ssr.timeoutMs`, so renders that never finish do not pile up on the host. The source entry is `ssr.entry` (for example `src/apps/admin/views/ssr.js`), otherwise `ssr.js` in the folder of `vite.entry`. When neither exists the start fails (`SSR_ENTRY_MISSING`).
132
227
  - Without Vite in the project the start fails (`VITE_DEV_MISSING`; `npm install --save-dev vite`).
133
- - Hosts: Vite's own check (`allowedHosts`) is off; `<base>*` is answered only for loopback names (`localhost`, `*.localhost`, 127.x, ::1), the `domains` of @dokebi/app and hosts this process has answered with an Inertia page (others get 403). So you can develop on app domains from the hosts file or a tunnel (such as `a.com`) while DNS rebinding is blocked.
228
+ - Hosts: Vite's own check (`allowedHosts`) is off; `<base>*` is answered only for loopback names (`localhost`, `*.localhost`), IP addresses (127.x, ::1, a LAN address), the `domains` of @dokebi/app and the `devHosts` option (others get 403). A page of another site cannot take over a name of that list through DNS rebinding, so it never reads the sources; a host is never added because the app answered it ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md) §5: before 0.4.4 every host served a page was added for good). A tunnel or another name goes into `devHosts` (`.example.com` for its subdomains).
134
229
  - Cluster apps run one worker under `dokebi dev` (@dokebi/cluster); `DOKEBI_DEV_WORKERS` asks for more, which also works on a cold cache.
135
230
  - When a build marks absolute-path imports as `external`, the dev server prefixes those paths with `<base>`. In the dev server use a Vite plugin that resolves them to real files (see the playground's `vite.config.ts`).
136
231
  - Production (no `DOKEBI_VITE_DEV`) is unaffected. Never set this variable on a production server.
232
+ - **Content-Security-Policy** (dokebi-web security headers, [D-0149](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0149-security-headers.md)): in development this plugin adds `ws://*:* wss://*:*` to `connect-src` for Vite's HMR socket, which connects to the page's own origin and `<base>` (`ws://localhost:<app port>/build/`, or `wss://<public host>/build/` through a tunnel; §5.1), and `worker-src 'self' blob:` for the `blob:` SharedWorker Vite's client pings a restarted dev host from (without it the page never reconnects); production adds nothing. Pages load their scripts from `'self'` (the page data is JSON, not a script); keep `'unsafe-inline'` in a policy of your own when a Vite framework plugin adds inline scripts in development.
137
233
  - The host answers a Vite middleware error with 500 (error log `INERTIA_VITE_HOST_FAILED`); 404 only when Vite has nothing for the URL. A host that does not answer is a 502 with error `INERTIA_VITE_DEV_FAILED` (once per 10 seconds, `repeated`); a host that cannot be reached at start is `VITE_DEV_UNAVAILABLE`.
138
- - Debug logs (D-0047): `Inertia settings resolved`, `Vite dev server ready` (ms, `shared`), `Vite dev route registered`, `Vite manifest read`, `Inertia SSR bundle loaded`, `Inertia SSR renders the source through Vite` (the source used), `Inertia scope created`, `Vite dev uses the host of dokebi dev`, `Vite dev starts a host in this process`, `Vite dev host connected` / `disconnected`; in the host `Vite dev host listening` (`port`, `hosts`), `Vite dev host started` (ms), `Vite dev host closed` (`connections`), `Vite dev host listens on IPv4 loopback only`, `Vite dev host port is taken on ::1, trying another`. Per request, only when debug is on: `Vite dev route refused a host`, `Vite dev host closed an upgrade that is not Vite`, `Vite dev host refused a request without its key`, `Vite dev request ended by the browser`. Host listener and request failures are error `INERTIA_VITE_HOST_FAILED`.
234
+ - Debug logs (D-0047): `Inertia settings resolved`, `Vite dev server ready` (ms, `shared`), `Vite dev route registered`, `Vite manifest read`, `Inertia SSR bundle loaded`, `Inertia SSR render deadline` (`timeoutMs`), `Inertia SSR render finished after its deadline` (a cut render that ended later), `Inertia SSR renders the source through Vite` (the source used), `Inertia scope created`, `Vite dev uses the host of dokebi dev`, `Vite dev starts a host in this process`, `Vite dev host connected` / `disconnected`; in the host `Vite dev host listening` (`port`, `hosts`), `Vite dev host started` (ms), `Vite dev host closed` (`connections`), `Vite dev host listens on IPv4 loopback only`, `Vite dev host port is taken on ::1, trying another`, `Vite dev host filters watcher events of unchanged files`, `Vite dev host ignored an event for a file that did not change` (the first and every 100th, `dropped`); `Vite dev host settings read` (`depsRepairIntervalMs`), `Vite dev host recorded itself in the cache folder` (`shared`), `Vite dev host re-optimized its dependencies` (`trigger`, `ms`, `repairs`), and per check only when debug is on `Vite dev host dependency cache matches` (`trigger`, `files`); per real file event, only when debug is on, `Vite dev host passes a file change`. A file whose state cannot be read is warn `INERTIA_VITE_WATCH_STAT_FAILED` (the event goes on). Per request, only when debug is on: `Vite dev route refused a host`, `Vite dev host closed an upgrade that is not Vite`, `Vite dev host refused a request without its key`, `Vite dev request ended by the browser`. Host listener and request failures are error `INERTIA_VITE_HOST_FAILED`.
235
+
236
+ ## 5.2 What the dev server serves (D-0129)
237
+
238
+ - Before this, Vite's default `server.fs.allow` (the workspace root) made `dokebi dev` answer **every file of the project**: `GET /build/dokebi.yaml?raw` returned the configuration with its secrets, `/build/src/main.ts?raw` the server code, `/build/@fs/<project>/tests/…` the tests, and through a `devHosts` tunnel all of it was public. Now the dev server serves only what the browser loads. `dokebiInertia()` enforces it, and the Vite host of `dokebi dev` adds the same policy (`dokebiDevFiles()`, also exported from `@dokebi/template-inertia/vite`) to a vite.config without `dokebiInertia()`, so upgrading this package is enough.
239
+ - **Allowed**: the views folders of the apps (`src/apps/<app>/views/`, also one added while dev runs), the folder of each browser entry (`build.rollupOptions.input` / `rolldownOptions.input`, literal `optimizeDeps.entries`; an entry that sits in the project root, `src/`, `src/apps/<app>/`, `src/shared/`, `src/infra/`, `tests/`, `scripts/` or `tools/` allows only itself), every `node_modules` from the Vite root up, Vite's cache and client, the files the served modules import (a shared pure function imported by a view), and `allow` of the plugin options. Without a views folder and without an entry the Vite root is served, minus the deny list (warn `INERTIA_DEV_FILES_ROOT`).
240
+ - **Never served** (`DEV_FILES_DENY`, letter case ignored, in every folder, also behind a symlink): `.env`, `.env.*`, `dokebi.yaml`, `dokebi.*.yaml`, `dokebi.yml`, `dokebi.*.yml`, `dokebi.json`, `.dokebi/**`, `.git/**`, `.ssh/**`, compose files (`compose*.yaml`, `docker-compose*.yml`), keys and certificates (`*.{crt,pem,key,p12,pfx,cer,der,p8,jks,keystore,csr,kdbx}`, `id_rsa*` and the other SSH key names), `.npmrc`, `.yarnrc`, `.yarnrc.yml`, `.netrc`, `.pgpass`, `.htpasswd`, `*.{sqlite,sqlite3,db}`, `*.backup`, plus `deny` of the plugin options and the app's own `server.fs.deny`.
241
+ - **Merging with the app's config**: the app may add to `server.fs.allow` and `server.fs.deny`; it cannot remove the framework's entries or turn `strict` off. `strict: false` is warn `INERTIA_DEV_FILES_STRICT` (strict stays on); a `server.fs.deny` of the app is warn `INERTIA_DEV_FILES_DENY` with `kept` (the framework's entries stay); entries a later plugin removed are put back, warn `INERTIA_DEV_FILES_DENY` with `restored`; an allow entry that names the project root or a server folder is warn `INERTIA_DEV_FILES_ALLOW` (it is the app's choice, the deny list still holds).
242
+ - **Every request is judged again** before Vite (a middleware of the plugin), whatever its method (Vite's file middlewares answer POST, PUT and the others too, and check a symlink's own path only); only Vite's HMR ping (`Accept: text/x-vite-ping`, answered 204 before any file) is not: the path decoded until it no longer changes (`%2e`, `%252e`, `%2f`, `%5c`), dot segments resolved, `/@fs/` and `/@id/` included, the deny list on the path and on the real path of a symlink, the real path inside the allow list. `?raw`, `?import`, `?url`, `.map` and Vite's virtual URLs (`@vite/client`, `@id/__x00__…`, `@dokebi-served/…`) follow the same rule. Vite's `__open-in-editor` answers loopback only. The host's own paths (`/__dokebi/vite-host`) never answer a forwarded browser request.
243
+ - **403**: a remote client gets `403 Forbidden` and nothing else (and no `__open-in-editor`), also in place of Vite's own 403 page (which names the absolute path and the allow list). A request is local, and keeps the details (the path, why, and the served folders), only when its socket peer is loopback, it carries no forwarding header (`Forwarded`, `X-Forwarded-*`, `X-Real-IP`, `CF-Connecting-IP`, `True-Client-IP`, `Via` and the others of `FORWARDING_HEADERS`) and its host name is a loopback name (D-0129 §12): a tunnel client connects from loopback and may rewrite Host to localhost, so neither the Host header nor trustProxy decides alone. The app process judges the browser's socket and passes `x-dokebi-dev-client`; the Vite host judges again. A check that fails is error `INERTIA_DEV_FILES_FAILED` and refuses (fail closed).
244
+ - Debug logs: `Inertia dev file policy prepared`, `Inertia dev file policy active` (allow, deny count), `Inertia dev file policy comes from dokebiInertia() of the config`, `Inertia dev file policy allows a new views folder`, per refused request `Inertia dev server refused a file` (path, reason, rule, remote), `Inertia dev file check cannot read a path`.
245
+ - Server rendering in development (`ssrLoadModule`) is not restricted: it runs on the server.
246
+ - `dokebi doctor` reports an installed template-inertia without this policy as `DEV_FILES_UNGUARDED` (an error when `devHosts` is set).
139
247
 
140
248
  ## 6. Scopes
141
249
 
142
- `await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared?, sealed?, encryptHistory?, errorPage? })` → a renderer with those settings (`InertiaScopeOptions`). Unset values come from the plugin options; `shared` is merged after the plugin's shared props. @dokebi/app calls it per app. Every renderer also has `answerError(response)` (D-0074, §3.3): the validation redirect or the error page for a dokebi-web error answer, false when it wrote nothing; the plugin registers the root renderer's as a web error responder, and @dokebi/app calls the app's.
250
+ `await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared?, sealed?, app?, encryptHistory?, errorPage? })` → a renderer with those settings (`InertiaScopeOptions`). Unset values come from the plugin options; `shared` is merged after the plugin's shared props. @dokebi/app calls it per app (`app` is the app's name, used only in log records: 1–64 printable ASCII characters, otherwise `INVALID_SCOPE`). Every renderer also has `answerError(response)` (D-0074, §3.3): the validation redirect or the error page for a dokebi-web error answer, false when it wrote nothing; the plugin registers the root renderer's as a web error responder, and @dokebi/app calls the app's.
143
251
 
144
252
  ## 6a. View engine contribution and errors (D-0063)
145
253
 
@@ -156,34 +264,39 @@ With `vite`, the manifest (at most 4 MiB) is read once in setup, and only the fi
156
264
  | `SSR_BUNDLE_MISSING` | `InertiaError` | the `ssr.bundle` file does not exist (start failure) |
157
265
  | `SSR_BUNDLE_INVALID` | `InertiaError` | the bundle is not a file or does not export `render(page)` |
158
266
  | `SSR_ENTRY_MISSING` | `InertiaError` | development-mode SSR cannot find the source module (`ssr.entry`) |
267
+ | `SSR_TIMEOUT` | `InertiaError` | one `render(page)` took longer than `ssr.timeoutMs`; handled as `failure` says (`data`: `component`, `timeoutMs`) |
159
268
  | `VITE_MANIFEST_MISSING` | `InertiaError` | the `vite.manifest` file does not exist |
160
- | `VITE_MANIFEST_INVALID` | `InertiaError` | not a file or larger than 4 MB, or `vite.entry` is not in the manifest |
269
+ | `VITE_MANIFEST_INVALID` | `InertiaError` | not a file, larger than `viteManifestMaxBytes` (4 MiB), not a JSON object (the parse error is the `cause`), or `vite.entry` is not in the manifest |
161
270
  | `VITE_DEV_MISSING` | `InertiaError` | `DOKEBI_VITE_DEV=1` but the project has no vite |
162
271
  | `VITE_DEV_UNAVAILABLE` | `InertiaError` | The Vite host of `dokebi dev` (`DOKEBI_VITE_DEV_URL`) cannot be reached or refuses the key, the URL is not loopback, or the key is missing |
163
272
  | `SEAL_SSR_CONFLICT` | `InertiaError` | SSR in a seal-host scope or for a sealed request (D-0062) |
164
273
  | `METHOD_NOT_ALLOWED` | `InertiaError` | `render` for a request that is not GET or HEAD |
165
274
  | `CLOSED` | `InertiaError` | `render` after the app stopped |
166
275
  | `INVALID_ENTRY` | `InertiaUsageError` | the entry exports something other than the `shared` function |
167
- | `INVALID_SCOPE` | `InertiaUsageError` | bad `scope()` settings (`sealed`, `shared`, `vite`, `ssr`, asset URLs) |
276
+ | `INVALID_SCOPE` | `InertiaUsageError` | bad `scope()` settings (`app`, `sealed`, `shared`, `vite`, `ssr`, asset URLs) |
168
277
  | `INVALID_RENDER` | `InertiaUsageError` | bad component name or render options |
169
278
  | `INVALID_PROPS` | `InertiaUsageError` | `Page props must be JSON serializable` |
170
- | `INVALID_URL` | `InertiaUsageError` | a redirect, location or asset that is not a local absolute URL |
279
+ | `INVALID_URL` | `InertiaUsageError` | a redirect or asset that is not a local absolute URL; a location that is neither that nor an http(s) URL |
171
280
  | `INVALID_PROP_HELPER` | `InertiaUsageError` | bad arguments to `defer`, `merge` or `once` |
172
281
  | `SSR_INVALID_RESULT` | `InertiaUsageError` | the SSR `render` did not return `{ head, body }` or the body has no root element |
282
+ | `INVALID_PRUNE` | `InertiaUsageError` | `pruneBuildFiles` without a manifest path, or an `olderThanMs`, `maxManifestBytes` or `now` that is not a whole number from 0 |
173
283
 
174
284
  ## 7. Cautions
175
285
 
176
- - Never put secrets or personal data into props (they go into the HTML as they are). Authentication, authorisation and CSRF are the app's job.
177
- - Build data query and update APIs as plain JSON routes (REST), not Inertia responses.
286
+ - Never put secrets or personal data into props (they go into the HTML as they are). Login and permissions come from @dokebi/auth (its shared prop `auth.user` holds only what `viewUser` returns) and CSRF from @dokebi/csrf (the Inertia client sends its `XSRF-TOKEN` cookie as `X-XSRF-TOKEN` by itself for Inertia navigation/forms; separate API calls still need the token header).
287
+ - Build data query and update APIs as JSON routes (REST), not Inertia responses. Browser API adapters use `sealedFetch` from `@dokebi/seal/http` (with `dokebiInertia()` the same served instance as `sealInertia(http)`, §4.1) and the current CSRF token. The web seal layer encodes JSON on sealed domains; JSON routes do not imply unsealed wire data.
178
288
  - Always use a per-build version (or `vite`) in deployments. With the default version, clients do not notice changed assets.
179
289
  - Precognition, `scrollProps` (infinite scrolling), `sharedProps` and dotted-path partial reloads (`only: ['user.name']`) do not exist. Do not assume them. Session flash exists only with @dokebi/session (§3.2).
180
290
  - A new plugin per app instance. The responder closes after the whole drain.
181
291
 
182
292
  ## 8. Seal hosts and browser history (D-0062, 0.2.0)
183
293
 
184
- - For requests to a seal host (@dokebi/app `domains.<host>.seal: true`) the first HTML's `data-page` is one sealed frame (`data-sealed="true"`, `E:<ts>:<b64>`), and dokebi-web seals visit (JSON) responses. The decision is dokebi-web's `sealOf(request)`. The browser opens it in `views/app.js` with `await sealInertia(http)` (`@dokebi/seal/http`; the CLI template imports `/dokebi/seal/http.mjs` dynamically only when `data-sealed` is set) before `createInertiaApp`.
294
+ - For requests to a seal host (@dokebi/app `domains.<host>.seal: true`) the first HTML's `data-page` is one sealed frame (`data-sealed="true"`, `E:<ts>:<b64>`), and dokebi-web seals visit (JSON) responses. The decision is dokebi-web's `sealOf(request)`. The browser opens it in the generated `views/app.js` entry (reusable browser helpers belong in `views/runtime/`) with `await sealInertia(http)` (`@dokebi/seal/http`; the CLI template imports it dynamically only when `data-sealed` is set, and `dokebiInertia()` maps it to the served `/dokebi/seal/http.mjs`, §4.1) before `createInertiaApp`.
185
295
  - **Never read `data-page` at the top of a page module**: pages and modules loaded through `import.meta.glob(..., { eager: true })` run before `sealInertia`, so they would parse the still-sealed text as JSON and the app stops. Read it through `usePage()` or when connecting.
296
+ - Testing from Node: `SealedClient` of `@dokebi/seal/testing` opens the sealed first page (`navigate`) and sends Inertia visits as `sealInertia(http)` does (`inertia(path, { method, data })`: `X-Inertia`, the version, `X-XSRF-TOKEN`, the data sealed, the page opened); use one client per simulated user for concurrent HTTP tests (seal AI.md §5.5).
186
297
  - It cannot be combined with SSR: `scope({ sealed: true, ssr })` fails the start with `SEAL_SSR_CONFLICT` (dokebi-app passes `sealed` for apps on seal domains), and a sealed request to an SSR scope fails that request with `SEAL_SSR_CONFLICT`.
187
298
  - Pages with `encryptHistory` on (default `'sealed'`) are stored encrypted in the browser history (only in secure contexts: HTTPS and `*.localhost`). `clearHistory` is set on the page when @dokebi/session 0.2's `historyReset()` is true (`destroy` or `regenerate` in this request); for a request that renders no page (a login POST → 303) it is carried to the **next page** with the cookie `dokebi.history-reset` (10 minutes). After logout, "back" cannot show the old props and fetches them from the server again.
188
299
 
189
- References: `examples/24-template-inertia` (real Chrome: CSR, SSR, cluster, measurements), `examples/26-scaffold` (build and server render of a CLI-generated SSR app, its Error page), `examples/25-playground` (the guestbook form: `only`, `form.errors`), `tests/flash.test.ts` (session flash), `tests/errors.test.ts` (validation redirect, error page), D-0027, D-0031, D-0039, D-0045, D-0052, D-0055, D-0062, D-0074.
300
+ References: `examples/48-inertia-i18n` (real Chromium: Korean and English pages from one catalog, CSR and SSR, visits that carry only new messages, language switch without a reload; D-0126: 85 bundles kept between page loads, a reload from the store, cleared storage, a changed and a garbage cookie, ko→en→ko), `tests/i18n.test.ts`, `tests/i18n-store.test.ts` and `tests/i18n-page.test.ts` (the runtime, the store and recovery before mounting, parity with the server translator, SSR through @inertiajs/vue3 with bundles the HTML left out), `examples/24-template-inertia` (real Chrome: CSR, SSR, cluster, measurements), `examples/26-scaffold` (build and server render of a CLI-generated SSR app, its Error page), `examples/25-playground` (the guestbook form: `only`, `form.errors`), `tests/flash.test.ts` (session flash), `tests/errors.test.ts` (validation redirect, error page), `tests/vite-plugin.test.ts` (the Vite plugin: dev host, client and SSR builds), `examples/28-recipes` (a CLI-generated sealed app in real Chromium: a Vue page's static `sealedFetch` import seals its body and opens the answer), D-0027, D-0031, D-0039, D-0045, D-0052, D-0055, D-0062, D-0074.
301
+
302
+ From 0.4.4, a single language tag in the response's `Content-Language` header is used for the generated document's `<html lang>` attribute. This applies to ordinary SSR/CSR and sealed HTML. Multiple-language headers leave the attribute unset. It requires no HTML rewriting hook. The server adapter does not import an i18n package; the browser entry `./i18n` (§3.4) reads the prop @dokebi/i18n contributes.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @dokebi/template-inertia 0.4.2
1
+ # @dokebi/template-inertia 0.4.3
2
2
 
3
3
  An optional package for Node >=24.0.0 and core ^0.4.0 (public on npm). Its ID is `dokebi-template-inertia` and it depends on `dokebi-web`. `@dokebi/web` is a peer dependency (not the pinned dependency it used to be). EJS, Vue and Vite are not installed as required dependencies of the server package.
4
4
 
@@ -19,9 +19,9 @@ const pages = createInertia({
19
19
  // context.get(pages).render(req, reply, 'Home', { counter: { value: 0 } }) });
20
20
  ```
21
21
 
22
- The x-user header above only illustrates the API; it is not authentication. Build user props from the app's authentication result. Call `render(request, reply, component, props?)` only for GET/HEAD and always return or await it in the handler. The first visit is answered with HTML and a JSON page script, later X-Inertia requests with page JSON, in Inertia v3's `script[data-page="app"]` format. `rootId` defaults to app, title to an empty string and version to development. Without a configured version a warn log (`code: INERTIA_VERSION_DEFAULT`) is written once in setup: clients then cannot notice changed assets, so real deployments set a per-build version and that manifest's scripts and styles. With a Vite build, `vite: { manifest, entry, base? }` does this instead. Setup reads the manifest (`.vite/manifest.json`, relative to the working folder or absolute, at most 4 MiB) once and fills in the entry's script, the CSS of the entry and the chunks it imports, and a version from the manifest's content hash (the first 32 characters of SHA-256). `base` (default `/`) is the URL prefix of the build files. `vite` together with `version`, `scripts` or `styles` is refused. When the entry is not in the manifest the start fails and the error names the entry ([ISSUE-0005](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/issues/ISSUE-0005-playground-findings.md) PG-07). A missing manifest file fails with `Vite manifest not found: <path>. Build the views first (npm run build).`.
22
+ The x-user header above only illustrates the API; it is not authentication. Build user props from the app's authentication result. Call `render(request, reply, component, props?)` only for GET/HEAD and always return or await it in the handler. The first visit is answered with HTML and a JSON page script, later X-Inertia requests with page JSON, in Inertia v3's `script[data-page="app"]` format. `rootId` defaults to app, title to an empty string and version to development. Without a configured version a warn log (`code: INERTIA_VERSION_DEFAULT`) is written for each scope (app) that has neither `vite` nor `version` of its own, naming the app, and once at the first render of the plugin's own renderer; when every app sets its own there is none: clients then cannot notice changed assets, so real deployments set a per-build version and that manifest's scripts and styles. With a Vite build, `vite: { manifest, entry, base? }` does this instead. Setup reads the manifest (`.vite/manifest.json`, relative to the working folder or absolute, at most 4 MiB) once and fills in the entry's script, the CSS of the entry and the chunks it imports, and a version from the manifest's content hash (the first 32 characters of SHA-256). `base` (default `/`) is the URL prefix of the build files. `vite` together with `version`, `scripts` or `styles` is refused. When the entry is not in the manifest the start fails and the error names the entry ([ISSUE-0005](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/issues/ISSUE-0005-playground-findings.md) PG-07). A missing manifest file fails with `Vite manifest not found: <path>. Build the views first (npm run build).`.
23
23
 
24
- This plugin serves the build files itself through dokebi-web ([D-0031](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0031-project-layout.md), supplement 1). Only files reachable from the entry are served: static and dynamic import chunks, CSS and assets. Each file is registered as a GET route at the exact path `<base><file>`; no wildcard routes are used, so the manifest itself and files outside the list are never served. When several apps use the same chunk it is registered once. The files are in outDir when the manifest is `<outDir>/.vite/manifest.json`, otherwise in the manifest's folder. Responses get a content type per extension and `cache-control: public, max-age=31536000, immutable`, because build files are expected to have a hash in their names. Each file is read on its first request and kept in memory (one read shared by concurrent requests; a failed read is not kept); files above 4 MiB are streamed on every request instead. To serve them from a CDN or another static server, put them at the same paths.
24
+ This plugin serves the build files itself through dokebi-web ([D-0031](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0031-project-layout.md), supplement 1). The files reachable from the entry (static and dynamic import chunks, CSS and assets) are served first: each file is registered as a GET route at the exact path `<base><file>`. When several apps use the same chunk it is registered once. For rolling deploys (two builds answering at once) every worker also serves the other files of the folders those files sit in (`<base>assets/*`), streamed from disk: regular files inside the folder only, never a hidden name, a symbolic link out of it or a source map; only content-hashed names get the immutable header, others `no-cache`. The manifest, `.vite/`, `ssr/` and files directly in outDir are never served; build with `build.emptyOutDir: false` so the previous build's chunks stay (AI.md §4). The files are in outDir when the manifest is `<outDir>/.vite/manifest.json`, otherwise in the manifest's folder. Responses get a content type per extension and `cache-control: public, max-age=31536000, immutable`, because build files are expected to have a hash in their names. Each file is read on its first request and kept in memory (one read shared by concurrent requests; a failed read is not kept); files above 4 MiB are streamed on every request instead. To serve them from a CDN or another static server, put them at the same paths.
25
25
 
26
26
  ```yaml
27
27
  dokebi-template-inertia:
@@ -31,11 +31,13 @@ dokebi-template-inertia:
31
31
 
32
32
  Invalid options are `INVALID_PLUGIN_OPTIONS` in the factory. Asset URLs must be local absolute URLs starting with `/`; serving files other than the Vite build is the app's or a static server's job.
33
33
 
34
- props are JSON values, async lazy functions or the deferred/merge/once helpers (see below). shared(request) and the call's props are merged per request, and the call's props win. A partial-data or partial-except request for the same component evaluates only the selected props (except wins). Selected lazy functions run concurrently with `Promise.all`, and the result keeps the original key order. errors is always included. Non-JSON values and cycles fail instead of answering. JSON, the title and asset attributes put into HTML are escaped, and Vary: X-Inertia and Cache-Control: no-store are set. An outdated version gets 409 + X-Inertia-Location/Version without evaluating props.
34
+ props are JSON values, async lazy functions or the deferred/merge/once helpers (see below). shared(request) and the call's props are merged per request, and the call's props win. A partial-data or partial-except request for the same component evaluates only the selected props (except wins). Selected lazy functions run concurrently with `Promise.all`, and the result keeps the original key order. errors is always included. Non-JSON values and cycles fail instead of answering. JSON, the title and asset attributes put into HTML are escaped, and Vary: X-Inertia and Cache-Control: no-store are set. An outdated version of a GET visit gets 409 + X-Inertia-Location/Version without evaluating props (other methods are not version-checked, as in the official adapters).
35
35
 
36
36
  `redirect(request, reply, localUrl)` is 302 for GET/HEAD and 303 for mutations; an Inertia fragment navigation is 409 + X-Inertia-Redirect. `location(request, reply, url)` is a local or HTTP(S) navigation: 409 + X-Inertia-Location for Inertia, 302 for normal requests. Choose external destinations in trusted app code.
37
37
 
38
- Use Inertia for Vue page navigation and initial props, and `fetch('/api/...')` REST for later queries and updates. API routes return JSON and status codes directly and are not wrapped in Inertia responses. Pages and APIs share business services and authentication and authorisation, and the app checks CSRF for cookie-authenticated mutations. When servers must share state, connect the optional DB or Redis.
38
+ Translations in Vue pages come from the same catalogs as the server: with `@dokebi/i18n` (`http.enabled`) every page has the shared prop `i18n`, the generated `views/app.js` and `views/ssr.js` install `pageI18n` from `@dokebi/template-inertia/i18n`, and pages call `useI18n()` for `t`, `n`, `d` and `switchLocale` (D-0099, [AI.md §3.4](AI.md#34-translations-in-vue-pages-dokebitemplate-inertiai18n-d-0099)). The server sends each bundle (locale × namespace, named by a content hash) once per browser: visits name what the document holds, and the browser keeps the bundles it received between page loads, so a reload's HTML leaves them out (D-0126); a browser that lost them fetches them before the app mounts. No third-party translation package is needed.
39
+
40
+ Use Inertia for Vue page navigation and initial props, and `sealedFetch` from `@dokebi/seal/http` for later REST queries and updates. Use the same Seal module instance as `sealInertia(http)`, initialize it before API calls, and supply the current CSRF token for unsafe calls; `sealedFetch` does not add that header. API routes return JSON and status codes directly and are not wrapped in Inertia responses. Pages and APIs share business services and authentication and authorisation, and the app checks CSRF for cookie-authenticated mutations. When servers must share state, connect the optional DB or Redis.
39
41
 
40
42
  There is no shared Template base class. The EJS string engine and this HTTP response adapter are independent classes. The responder closes after the whole request drain, and each Application gets a new instance. Without `options`, the `dokebi-template-inertia` value of app.config is used.
41
43
 
@@ -47,7 +49,7 @@ Supported: the initial HTML, page navigation, shared/lazy/partial props, deferre
47
49
 
48
50
  ## Forms and validation
49
51
 
50
- Validation follows [D-0074](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0074-error-responses.md). A refused route schema and `throw validationError({ field: 'message' })` (from `@dokebi/web`) are the same 422 `VALIDATION_FAILED` answer. For an Inertia request with [@dokebi/session](https://git.nyx-zone.com/dokebi/framework/-/blob/main/plugins/dokebi-session/README.md) this plugin flashes `errors` (and `old` input without password-like fields) and answers 303 back to the same-origin `Referer`; the next page gets them in its `errors` prop, so `useForm().errors.name` shows the message. Without a session the answer is the 422 JSON with `errors` (read it in `onHttpException`). The target and the old input follow @dokebi/web's form helpers (shared with @dokebi/app's HTML forms; 0.4.2 refuses a Referer path that reads as another host, such as `//other.host/…`), so this version needs @dokebi/web ^0.4.2.
52
+ Validation follows [D-0074](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0074-error-responses.md). A refused route schema and `throw validationError({ field: 'message' })` (from `@dokebi/web`) are the same 422 `VALIDATION_FAILED` answer. For an Inertia request with [@dokebi/session](https://git.nyx-zone.com/dokebi/framework/-/blob/main/plugins/dokebi-session/README.md) this plugin flashes `errors` (and `old` input without password-like fields) and answers 303 back to the same-origin `Referer`; the next page gets them in its `errors` prop, so `useForm().errors.name` shows the message. Without a session the answer is the 422 JSON with `errors` (read it in `onHttpException`). Another 4xx whose `data.formErrors` is a `{ field: message }` record (0.4.3, D-0076 §2.5: @dokebi/csrf's expired token, `errors._form`) goes back the same way. The target and the old input follow @dokebi/web's form helpers (shared with @dokebi/app's HTML forms; 0.4.2 refuses a Referer path that reads as another host, such as `//other.host/…`), so this version needs @dokebi/web ^0.4.3.
51
53
 
52
54
  Submit forms with `only` (the props the submit changes, for example `form.post('/posts', { only: ['posts'] })`): the GET after the redirect is a new visit, and a full visit must send every prop again because Inertia replaces all props, so the server cannot trim it by itself. Inertia keeps the partial headers across the redirect, and `errors` is always included. `dokebi check` warns about a submit without `only` (`inertia-form-only`).
53
55
 
@@ -75,7 +77,8 @@ With `ssr: { bundle, failure? }` the first HTML of a page is rendered on the ser
75
77
 
76
78
  - `bundle`: a build file that exports `render(page) → Promise<{ head: string[]; body: string }>` (relative to the working folder or absolute). Inertia v3's `createInertiaApp` returns a `(page, renderToString)` function when called on the server without a `page`, so `export const render = async (page) => (await app)(page, renderToString)` is enough (`client/ssr.js` of example 24).
77
79
  - A missing bundle or one without `render` **fails the start**, so turning SSR on never silently falls back to CSR.
78
- - When `render` throws during a request, returns the wrong shape (`head` a string array, `body` a string) or the `body` has no `id="<rootId>"`, `failure` decides. `fallback` (default) answers that request with the CSR page and logs an `INERTIA_SSR_FAILED` error; `error` fails the request (to see it at once during development).
80
+ - `timeoutMs` (default 5000, 1 to 600000) bounds one `render(page)`: a render still running then fails with `SSR_TIMEOUT` (its late result is only logged), so a hung render never hangs the request. Only a render that waits (a fetch, the Vite host) can be cut; synchronous work blocks the process until it returns ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md)).
81
+ - When `render` throws during a request, returns the wrong shape (`head` a string array, `body` a string), passes `timeoutMs` or the `body` has no `id="<rootId>"`, `failure` decides. `fallback` (default) answers that request with the CSR page and logs an `INERTIA_SSR_FAILED` error; `error` fails the request (to see it at once during development).
79
82
  - `head` goes before `</head>`. When it contains a `<title>` (the page's `<Head>`), the configured `title` is left out. `body` replaces the default `<script data-page>` and the empty root.
80
83
  - The browser entry hydrates with `createSSRApp` when `el.hasAttribute('data-server-rendered')`.
81
84
  - `ssrStats()` is the number of renders and failures since start (for health checks and dashboards).
@@ -87,10 +90,23 @@ Measured (example 24, same process, 400 sequential requests): CSR 0.20–0.24 ms
87
90
 
88
91
  With `DOKEBI_VITE_DEV=1` (set by `dokebi dev`) the project's Vite starts in middleware mode without a build; Vite answers `<base>*`, and pages load `@vite/client` and the source entry. Editing a `.vue` file updates the page without a reload (one Vite per `dokebi dev` session runs in a host process; every process and cluster worker forwards to it, and the HMR socket is the host's loopback port on `127.0.0.1` and `::1`, D-0045 addendum 3). SSR renders the source (`ssr.entry`, otherwise the `ssr.js` next to `vite.entry`) through Vite per request. Production is unaffected.
89
92
 
93
+ The dev server serves only what the browser loads (D-0129): the views folders of the apps, the folders of the browser entries, `node_modules` and the files those modules import. `dokebi.yaml`, `.env`, `.dokebi/`, keys, compose files and the server code (`src/` outside `views/`, `tests/`, `scripts/`) answer 403 by every spelling (`?raw`, `/@fs/`, encoded, other letter cases, symlinks), and a remote host such as a `devHosts` tunnel gets a short 403 without paths. `dokebiInertia()` enforces this and the Vite host of `dokebi dev` adds it to a config without the plugin; the app may add to `server.fs.allow` and `deny` (or `dokebiInertia({ allow, deny })`) but never remove the framework's entries. Details: AI.md §5.2.
94
+
90
95
  ## Seal hosts and history (D-0062, 0.2.0)
91
96
 
92
97
  [D-0062](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0062-http-seal.md). The first HTML of a sealed request (dokebi-web `sealOf`) carries only `<script data-page="app" type="application/json" data-sealed="true">E:…</script>` (the title is the configured one), and dokebi-web seals visit JSON. The browser opens it with `sealInertia(http)` of `@dokebi/seal/http`. Combined with SSR it is `SEAL_SSR_CONFLICT`.
93
98
 
99
+ The seal client keeps its seal in module state, so the bootstrap and every `sealedFetch` must be one module instance: the one the app serves (`/dokebi/seal/http.mjs`, from @dokebi/seal). The Vite plugin `dokebiInertia()` of `@dokebi/template-inertia/vite` (in the generated `vite.config.ts`) makes every `import … from '@dokebi/seal/http'` (and `@dokebi/seal/forms`) of the app's browser code load that served file: the client build keeps it as an import of `/dokebi/seal/http.mjs` (no bundled copy, no import map), the dev server answers `<base>@dokebi-served/seal/http.mjs` with `export * from "/dokebi/seal/http.mjs"`, Vite's dependency optimizer keeps them external (so pre-bundling of the other dependencies works), and server rendering is untouched. A Vue page then simply writes `import { sealedFetch } from '@dokebi/seal/http'`.
100
+
101
+ ```ts snippet
102
+ // vite.config.ts
103
+ import { dokebiInertia } from '@dokebi/template-inertia/vite';
104
+ import vue from '@vitejs/plugin-vue';
105
+ import { defineConfig } from 'vite';
106
+
107
+ export default defineConfig({ base: '/build/', plugins: [vue(), dokebiInertia()] });
108
+ ```
109
+
94
110
  - `encryptHistory: true | false | 'sealed'` (option and `scope`, default `'sealed'`) and `render(..., props, { encryptHistory, clearHistory })`.
95
111
  - The page after a login or logout (@dokebi/session 0.2 `historyReset()`) carries `clearHistory: true`. When it happened in a request that rendered no page, the cookie `dokebi.history-reset` (Path=/, 10 minutes, HttpOnly, SameSite=Lax, Secure on https) carries it to the next page, which clears the history.
96
112
 
@@ -0,0 +1,61 @@
1
+ /** The three texts of one language. */
2
+ export interface InertiaBootMessages {
3
+ readonly loading?: string;
4
+ readonly failed?: string;
5
+ readonly reload?: string;
6
+ }
7
+ /** `bootFallback` of the plugin options or a scope; `false` turns it off. */
8
+ export interface InertiaBootFallback {
9
+ /** Default true. */
10
+ readonly enabled?: boolean;
11
+ /** How long "loading" shows before "could not load" (1000–120000, default 10000). */
12
+ readonly timeoutMs?: number;
13
+ /** Texts by language tag (`ko`, `en`, `pt-BR`), over the built-in Korean and English ones. */
14
+ readonly messages?: Readonly<Record<string, InertiaBootMessages>>;
15
+ /**
16
+ * A local stylesheet (`/assets/boot.css`) linked right after the built-in style: the app's own
17
+ * look of the box (its `[data-dokebi-boot…]` selectors), there before any script runs.
18
+ */
19
+ readonly stylesheet?: string;
20
+ /** A local image (`/assets/logo.svg`) above the spinner and the "could not load" text. */
21
+ readonly logo?: string;
22
+ }
23
+ export interface NormalizedBootFallback {
24
+ readonly enabled: boolean;
25
+ readonly timeoutMs: number;
26
+ readonly messages: Readonly<Record<string, InertiaBootMessages>>;
27
+ readonly stylesheet?: string;
28
+ readonly logo?: string;
29
+ }
30
+ /** Checked and filled; undefined when the input is not a valid setting. */
31
+ export declare function normalizeBootFallback(input: InertiaBootFallback | false | undefined): NormalizedBootFallback | undefined;
32
+ /**
33
+ * The head part (a style and a script, before the module scripts so a failed load is heard) and
34
+ * the root's content. The head part depends on the root id and the timeout only, so its hashes are
35
+ * fixed for a scope (logged at debug for a CSP that lists hashes).
36
+ */
37
+ export declare function bootMarkup(settings: NormalizedBootFallback, rootId: string, language: string | undefined): {
38
+ readonly head: string;
39
+ readonly root: string;
40
+ };
41
+ /** The style and the script of the head (fixed per root id and timeout). */
42
+ export declare function bootHead(rootId: string, timeoutMs: number): string;
43
+ /**
44
+ * "Loading" turns to "could not load" after the delay, or at once once the script marks it. The
45
+ * look: the box fills the window and centers its line; "loading" is a spinner (its text, at size 0,
46
+ * is still read out), "could not load" its text over a reload button, a `logo` above both. Neutral
47
+ * greys; a dark colour scheme gets a dark box (the page behind it is white without the app's CSS),
48
+ * and reduced motion a spinner that stands still (Studio v2 Z1). An app restyles it with
49
+ * `stylesheet`.
50
+ */
51
+ export declare function bootStyle(timeoutMs: number): string;
52
+ /** A module script of the page that fails to load marks the boot text failed (capture phase). */
53
+ export declare function bootScript(rootId: string): string;
54
+ /**
55
+ * The CSP sources of the head's style and script (`'sha256-…'`), for an app whose CSP has no
56
+ * 'unsafe-inline': add them to style-src and script-src, or turn bootFallback off.
57
+ */
58
+ export declare function bootHashes(rootId: string, timeoutMs: number): {
59
+ style: string;
60
+ script: string;
61
+ };