@dokebi/template-inertia 0.4.4 → 0.4.6

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
@@ -12,8 +12,8 @@ npm run build # dist/ (browser) + dist/ssr
12
12
 
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
- - `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`).
15
+ - `export async function shared(request)` of `src/infra/inertia/shared.ts` is merged into every page's props (the call's props win): the visitor's own (the user, preferences). `export function publicShared(request)` (0.4.6, D-0195) holds what is the same for every visitor (the site's name, its menu); it also goes into the first HTML of public pages (§3.5). Any other export is `INVALID_ENTRY`.
16
+ - Plugins add shared props through `dokebi-web:view-data` (D-0076 §5, for example `auth` of an auth plugin): they come first, then `publicShared` and `shared` of the entry, then the scope's `publicShared` (@dokebi/app adds `app`, `sealed`) and `shared`, 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`). View data a plugin calls `public` (@dokebi/i18n's `locale` and `i18n`) is the same for every visitor; the rest (`auth`, `csrf`) is the visitor's own.
17
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
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.
19
19
 
@@ -53,7 +53,7 @@ route.post('/orders', async (request, reply, page) => {
53
53
  });
54
54
  ```
55
55
 
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_-]*`.
56
+ - `render(request, reply, component, props?, { encryptHistory?, clearHistory?, public?, head? }?)`: GET/HEAD only. The fifth argument sets history handling for this response (otherwise the scope setting and the session signal), whether the page is public and its head tags (§3.5). Return or await it in the handler. component is `/`-separated parts matching `[A-Za-z0-9][A-Za-z0-9_-]*`. Unknown option keys are `INVALID_RENDER`.
57
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.
58
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
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.
@@ -176,6 +176,34 @@ A page whose entry script cannot be fetched (a `dokebi dev` restart, a deploy in
176
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
177
  - A scope (`scope({ bootFallback })`, an app's inertia options) has its own; `false` there turns it off for that scope.
178
178
 
179
+ ### 3.5 Public pages and head tags (0.4.6, D-0195)
180
+
181
+ A public page's first HTML is the same for every visitor — a crawler, a guest, a signed-in member — so a search engine reads it, a shared cache may keep it, and on a **seal host it is plain and server rendered** too (the other pages of that host stay sealed, §8).
182
+
183
+ ```ts snippet
184
+ route.get('/notices/:id', async (request, reply, page) =>
185
+ page.inertia!.render(request, reply, 'Notice', { notice: await findNotice(request.params.id) }, {
186
+ public: { cacheControl: 'public, max-age=60' }, // or public: true (no-store stays)
187
+ head: {
188
+ title: notice.title, // escaped; replaces the configured and the SSR title
189
+ description: notice.summary,
190
+ canonical: `https://gaonjs.dev/notices/${notice.id}`,
191
+ alternates: [{ hreflang: 'en', href: `https://gaonjs.dev/en/notices/${notice.id}` }, { hreflang: 'x-default', href: `https://gaonjs.dev/notices/${notice.id}` }],
192
+ meta: [{ property: 'og:title', content: notice.title }],
193
+ },
194
+ }),
195
+ );
196
+ ```
197
+
198
+ - **What the first HTML holds**: the render's props and the **public** shared props only — view data its plugin calls public (@dokebi/i18n `locale`, `i18n`), the entry's `publicShared`, the scope's `publicShared` (@dokebi/app `app`, `sealed`). The visitor's own — other view data (`auth` of @dokebi/auth, `csrf` of @dokebi/csrf), the entry's `shared`, the scope's `shared` — are left out and named in the deferred group **`dokebi-visitor`** (`VISITOR_GROUP`). The Inertia client fetches that group right after the page starts (a partial reload: a visit, sealed on a seal host). The flash and the history reset are not read; the next page gets them (the visitor fetch is a deferred fetch and leaves them too). A key both public and the visitor's counts as the visitor's.
199
+ - **Write the layout for the moment before**: `'auth' in usePage().props` is false until the group arrives, on the server and while hydrating alike (same markup, no hydration warning). `<Deferred data="auth">` of `@inertiajs/vue3` does the same for a part of a page.
200
+ - **Your props are yours**: only public data in the props of a public page. The framework keeps the shared props out; it cannot know what the handler put in.
201
+ - **On a seal host** the first HTML is plain with `<meta name="dokebi-seal" content="host">` (`SEAL_HOST_META`) and server rendered when the scope has `ssr`. `sealInertia(http)` of `@dokebi/seal/http` sees the marker, leaves `data-page` as it is and seals every visit, form and `sealedFetch` after it; the `views/app.js` of `dokebi app add --inertia` calls it for `script[data-page][data-sealed="true"], meta[name="dokebi-seal"][content="host"]` (a project made before 0.4.6 changes that one line by hand). `encryptHistory` stays on there.
202
+ - **Cache-Control**: `no-store` unless `public.cacheControl` (1–256 printable ASCII characters) is given; an answer that sets a cookie anyway (a handler that wrote the session) goes back to `no-store`. `Vary: X-Inertia` stays. Put the language in the URL (@dokebi/i18n `http.pathLocale`): a page whose language comes from Accept-Language or a cookie says `Vary: Accept-Language, Cookie`. The i18n prop of a public first HTML ignores the bundle cookie (every bundle is sent), so it does not vary by browser.
203
+ - **`head`** (any page, sealed or not, server rendered or not; the first HTML only — visits keep Vue's `<Head>`): `title`, `description`, `canonical`, `robots`, `alternates` (`{ hreflang, href }`, a language tag or `x-default`), `meta` (`{ name, content }` or `{ property, content }`); URLs are http(s) or local absolute paths; at most 64 alternates and 64 meta; everything is escaped. The tags carry no `data-inertia`, so Inertia's head manager leaves them. `INVALID_RENDER` for anything else. Types: `InertiaHead`, `InertiaHeadAlternate`, `InertiaHeadMeta`.
204
+ - Debug log `Inertia public page` (`component`, `sealedHost`, `serverRendered`, `visitor` keys, `cacheControl`); `Inertia public page sets a cookie: not cacheable`.
205
+ - Example: `examples/80-sealed-public-ssr` (real Chromium).
206
+
179
207
  ## 4. Serving build files
180
208
 
181
209
  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.
@@ -188,7 +216,7 @@ The seal client (`@dokebi/seal/http`) keeps its seal (the bridge, the clock offs
188
216
 
189
217
  - `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
218
  - 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).
219
+ - server rendering (SSR builds, development SSR): untouched; Node resolves the package as usual (only public pages of a seal host are server rendered, and they are plain, §3.5).
192
220
  - 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
221
 
194
222
  So a Vue page writes a plain static import, and the dynamic import of the generated bootstrap uses the same name:
@@ -247,7 +275,7 @@ const answer = await sealedFetch('/posts/preview', { method: 'POST', body: { tit
247
275
 
248
276
  ## 6. Scopes
249
277
 
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.
278
+ `await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared?, publicShared?, sealed?, app?, encryptHistory?, errorPage? })` → a renderer with those settings (`InertiaScopeOptions`). Unset values come from the plugin options; `publicShared` and `shared` are merged after the plugin's shared props (`publicShared` also into public first pages, §3.5). The engine contributed to web says `publicPages: true` (D-0195), which @dokebi/app checks before it passes `publicShared`. @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.
251
279
 
252
280
  ## 6a. View engine contribution and errors (D-0063)
253
281
 
@@ -269,12 +297,11 @@ const answer = await sealedFetch('/posts/preview', { method: 'POST', body: { tit
269
297
  | `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 |
270
298
  | `VITE_DEV_MISSING` | `InertiaError` | `DOKEBI_VITE_DEV=1` but the project has no vite |
271
299
  | `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 |
272
- | `SEAL_SSR_CONFLICT` | `InertiaError` | SSR in a seal-host scope or for a sealed request (D-0062) |
273
300
  | `METHOD_NOT_ALLOWED` | `InertiaError` | `render` for a request that is not GET or HEAD |
274
301
  | `CLOSED` | `InertiaError` | `render` after the app stopped |
275
- | `INVALID_ENTRY` | `InertiaUsageError` | the entry exports something other than the `shared` function |
276
- | `INVALID_SCOPE` | `InertiaUsageError` | bad `scope()` settings (`app`, `sealed`, `shared`, `vite`, `ssr`, asset URLs) |
277
- | `INVALID_RENDER` | `InertiaUsageError` | bad component name or render options |
302
+ | `INVALID_ENTRY` | `InertiaUsageError` | the entry exports something other than the `shared` and `publicShared` functions |
303
+ | `INVALID_SCOPE` | `InertiaUsageError` | bad `scope()` settings (`app`, `sealed`, `shared`, `publicShared`, `vite`, `ssr`, asset URLs) |
304
+ | `INVALID_RENDER` | `InertiaUsageError` | bad component name or render options (`public`, `head`, unknown keys, §3.5) |
278
305
  | `INVALID_PROPS` | `InertiaUsageError` | `Page props must be JSON serializable` |
279
306
  | `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 |
280
307
  | `INVALID_PROP_HELPER` | `InertiaUsageError` | bad arguments to `defer`, `merge` or `once` |
@@ -294,7 +321,7 @@ const answer = await sealedFetch('/posts/preview', { method: 'POST', body: { tit
294
321
  - 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`.
295
322
  - **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
323
  - 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).
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`.
324
+ - With SSR (0.4.6, D-0195): a sealed request is server rendered only for the first HTML of a **public page** (§3.5), which is plain and holds no visitor's value. Every other page of a seal host keeps the sealed first HTML and is never server rendered; `scope({ sealed: true, ssr })` starts (debug `Inertia scope created` says `ssrOnSealedHosts: 'public pages'`). Before 0.4.6 that combination failed with `SEAL_SSR_CONFLICT`, which no longer exists.
298
325
  - 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.
299
326
 
300
327
  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.
package/README.md CHANGED
@@ -92,9 +92,13 @@ With `DOKEBI_VITE_DEV=1` (set by `dokebi dev`) the project's Vite starts in midd
92
92
 
93
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
94
 
95
+ ## Public pages (0.4.6, D-0195)
96
+
97
+ `render(request, reply, component, props, { public: true, head })` marks a page whose first HTML is the same for every visitor: it carries the render's props and the public shared props (view data a plugin calls public, such as @dokebi/i18n's, and `publicShared` of the entry and the scope), while the visitor's own (`auth`, `csrf`, the entry's `shared`) are named in the deferred group `dokebi-visitor` and fetched by the client right after it starts. On a seal host that HTML is plain and server rendered (with `<meta name="dokebi-seal" content="host">`, which tells `sealInertia` to seal everything after it), so crawlers read the page. `public: { cacheControl }` lets a shared cache keep it (an answer that sets a cookie stays `no-store`). `head` writes escaped title, description, canonical, robots, `hreflang` alternates and meta tags into the first HTML of any page, sealed ones included. Details: AI.md §3.5; example `examples/80-sealed-public-ssr`.
98
+
95
99
  ## Seal hosts and history (D-0062, 0.2.0)
96
100
 
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`.
101
+ [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`. With SSR, only a public page's first HTML is server rendered there (0.4.6, below); the others stay sealed.
98
102
 
99
103
  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
104
 
@@ -80,11 +80,14 @@ const escapeHtml = (value) => value
80
80
  function textsFor(settings, language) {
81
81
  const tag = (language ?? '').toLowerCase();
82
82
  const primary = tag.split('-')[0] ?? '';
83
- const builtIn = BUILT_IN[primary] ?? BUILT_IN.en;
83
+ // Own keys only: Content-Language is any text the app set (`constructor`, `__proto__` named
84
+ // Object.prototype members and left the texts undefined: the page answered 500).
85
+ const own = (table, key) => Object.hasOwn(table, key) ? table[key] : undefined;
86
+ const builtIn = own(BUILT_IN, primary) ?? BUILT_IN.en;
84
87
  return {
85
88
  ...builtIn,
86
- ...settings.messages[primary],
87
- ...settings.messages[tag],
89
+ ...own(settings.messages, primary),
90
+ ...own(settings.messages, tag),
88
91
  };
89
92
  }
90
93
  /**
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DokebiError, type DokebiErrorOptions } from '@dokebi/core';
2
2
  /** Codes of `InertiaError` (failures of setup, files and the page flow). */
3
- export type InertiaErrorCode = 'SSR_BUNDLE_MISSING' | 'SSR_BUNDLE_INVALID' | 'SSR_ENTRY_MISSING' | 'SSR_TIMEOUT' | 'VITE_MANIFEST_MISSING' | 'VITE_MANIFEST_INVALID' | 'VITE_DEV_MISSING' | 'VITE_DEV_UNAVAILABLE' | 'SEAL_SSR_CONFLICT' | 'METHOD_NOT_ALLOWED' | 'CLOSED';
3
+ export type InertiaErrorCode = 'SSR_BUNDLE_MISSING' | 'SSR_BUNDLE_INVALID' | 'SSR_ENTRY_MISSING' | 'SSR_TIMEOUT' | 'VITE_MANIFEST_MISSING' | 'VITE_MANIFEST_INVALID' | 'VITE_DEV_MISSING' | 'VITE_DEV_UNAVAILABLE' | 'METHOD_NOT_ALLOWED' | 'CLOSED';
4
4
  /** Codes of `InertiaUsageError` (a wrong argument: a TypeError, as before 0.4.0). */
5
5
  export type InertiaUsageErrorCode = 'INVALID_ENTRY' | 'INVALID_SCOPE' | 'INVALID_RENDER' | 'INVALID_PROPS' | 'INVALID_URL' | 'INVALID_PROP_HELPER' | 'SSR_INVALID_RESULT' | 'INVALID_PRUNE';
6
6
  /** Failures of @dokebi/template-inertia; compare `code`, not the class. */
package/dist/errors.js CHANGED
@@ -9,12 +9,11 @@ const FIX = {
9
9
  VITE_MANIFEST_INVALID: 'Point vite.manifest at .vite/manifest.json of a Vite build (at most viteManifestMaxBytes, 4 MiB by default) and vite.entry at one of its entries.',
10
10
  VITE_DEV_MISSING: 'npm install --save-dev vite (DOKEBI_VITE_DEV=1 serves the views through it).',
11
11
  VITE_DEV_UNAVAILABLE: 'Run the app with dokebi dev, which starts one Vite dev host and sets DOKEBI_VITE_DEV_URL and DOKEBI_VITE_DEV_KEY; or unset both to start Vite in this process.',
12
- SEAL_SSR_CONFLICT: 'Set inertia.ssr: false for the app of a seal host, or remove seal: true from its domains (D-0062).',
13
12
  METHOD_NOT_ALLOWED: 'Render pages for GET/HEAD; redirect after POST/PUT/PATCH/DELETE (303).',
14
13
  CLOSED: 'Render only while the application runs.',
15
- INVALID_ENTRY: 'The Inertia entry (src/infra/inertia/shared.ts) exports only shared: (request) => props.',
16
- INVALID_SCOPE: 'Give scope() document settings (title, rootId, version, scripts, styles or vite, ssr, encryptHistory), shared as a function and sealed as a boolean; vite replaces version, scripts and styles.',
17
- INVALID_RENDER: 'render(request, reply, component, props?, { encryptHistory?, clearHistory? }): component is names of letters, digits, _ or - joined by /.',
14
+ INVALID_ENTRY: 'The Inertia entry (src/infra/inertia/shared.ts) exports only shared: (request) => props and publicShared: (request) => props.',
15
+ INVALID_SCOPE: 'Give scope() document settings (title, rootId, version, scripts, styles or vite, ssr, encryptHistory), shared and publicShared as functions and sealed as a boolean; vite replaces version, scripts and styles.',
16
+ INVALID_RENDER: 'render(request, reply, component, props?, { encryptHistory?, clearHistory?, public?, head? }): component is names of letters, digits, _ or - joined by /; public is true or { cacheControl }; head holds title, description, canonical, robots, alternates and meta as documented (AI.md §3.5).',
18
17
  INVALID_PROPS: 'Page props must be JSON values: no undefined, functions, symbols, NaN or Infinity (use null, or leave the prop out).',
19
18
  INVALID_URL: 'Use a local absolute URL such as /posts/1 (not //host, no backslash or spaces).',
20
19
  INVALID_PROP_HELPER: 'defer(fn, { group? }), merge(value, { deep?, matchOn? }), once(value, { key? }): a JSON value or a function, with the documented options.',
package/dist/head.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /** A `<link rel="alternate" hreflang>` of the page (the same page in another language). */
2
+ export interface InertiaHeadAlternate {
3
+ /** A language tag (`ko`, `en-US`) or `x-default`. */
4
+ readonly hreflang: string;
5
+ /** An absolute http(s) URL, or a local absolute path. */
6
+ readonly href: string;
7
+ }
8
+ /** A `<meta>` by `name` (`twitter:card`) or by `property` (Open Graph `og:title`). */
9
+ export type InertiaHeadMeta = {
10
+ readonly name: string;
11
+ readonly content: string;
12
+ } | {
13
+ readonly property: string;
14
+ readonly content: string;
15
+ };
16
+ /** The head tags of the first HTML (`render(…, { head })`). */
17
+ export interface InertiaHead {
18
+ /** The document title; replaces the configured title and a `<title>` of the SSR bundle. */
19
+ readonly title?: string;
20
+ /** `<meta name="description">`. */
21
+ readonly description?: string;
22
+ /** `<link rel="canonical">`: an absolute http(s) URL or a local absolute path. */
23
+ readonly canonical?: string;
24
+ /** `<meta name="robots">` (`noindex, nofollow`). */
25
+ readonly robots?: string;
26
+ /** `<link rel="alternate" hreflang>`, at most 64. */
27
+ readonly alternates?: readonly InertiaHeadAlternate[];
28
+ /** More `<meta>` tags (Open Graph, Twitter cards), at most 64. */
29
+ readonly meta?: readonly InertiaHeadMeta[];
30
+ }
31
+ /** The checked head: its title (when given) and the other tags, escaped. */
32
+ export interface HeadMarkup {
33
+ readonly title?: string;
34
+ readonly tags: string;
35
+ }
36
+ /** Checks `head` and writes its tags; throws INVALID_RENDER for anything else. */
37
+ export declare function headMarkup(input: unknown): HeadMarkup;
package/dist/head.js ADDED
@@ -0,0 +1,80 @@
1
+ // D-0195: the head of a page's first HTML, given by the route (`render(…, { head })`): what a
2
+ // crawler or a link preview reads before any script runs — on a sealed page too, whose props
3
+ // leave sealed. Structured on purpose: every value is text or a URL, escaped here, so a title
4
+ // from the database never becomes markup.
5
+ import { InertiaUsageError } from './errors.js';
6
+ const TEXT_MAX = 4096;
7
+ const TITLE_MAX = 1024;
8
+ const URL_MAX = 2048;
9
+ const LIST_MAX = 64;
10
+ const LANGUAGE = /^(?:x-default|[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)$/;
11
+ const META_NAME = /^[A-Za-z][A-Za-z0-9:._-]{0,127}$/;
12
+ const KEYS = ['title', 'description', 'canonical', 'robots', 'alternates', 'meta'];
13
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
14
+ const refuse = (message) => {
15
+ throw new InertiaUsageError('INVALID_RENDER', message);
16
+ };
17
+ function text(value, field, max) {
18
+ if (typeof value !== 'string' || value.length > max)
19
+ refuse(`head.${field} must be a string of at most ${max} characters`);
20
+ return value;
21
+ }
22
+ /** An absolute http(s) URL or a local absolute path, without spaces or control characters. */
23
+ function url(value, field) {
24
+ const given = text(value, field, URL_MAX);
25
+ const local = given.startsWith('/') && !given.startsWith('//') && !given.includes('\\');
26
+ const absolute = !local && URL.canParse(given) && /^https?:$/.test(new URL(given).protocol);
27
+ if ((!local && !absolute) || [...given].some((char) => char.charCodeAt(0) <= 32))
28
+ refuse(`head.${field} must be an http(s) URL or a local absolute path`);
29
+ return given;
30
+ }
31
+ function list(value, field) {
32
+ if (!Array.isArray(value) || value.length > LIST_MAX)
33
+ refuse(`head.${field} must be an array of at most ${LIST_MAX} entries`);
34
+ return value;
35
+ }
36
+ function record(value, field) {
37
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
38
+ refuse(`head.${field} must be an object`);
39
+ return value;
40
+ }
41
+ /** Checks `head` and writes its tags; throws INVALID_RENDER for anything else. */
42
+ export function headMarkup(input) {
43
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
44
+ refuse('head must be an object');
45
+ const head = input;
46
+ for (const key of Object.keys(head))
47
+ if (!KEYS.includes(key))
48
+ refuse(`head.${key} is not a head field (${KEYS.join(', ')})`);
49
+ const tags = [];
50
+ const title = head.title === undefined ? undefined : text(head.title, 'title', TITLE_MAX);
51
+ if (head.description !== undefined)
52
+ tags.push(`<meta name="description" content="${escapeHtml(text(head.description, 'description', TEXT_MAX))}">`);
53
+ if (head.robots !== undefined)
54
+ tags.push(`<meta name="robots" content="${escapeHtml(text(head.robots, 'robots', 256))}">`);
55
+ if (head.canonical !== undefined)
56
+ tags.push(`<link rel="canonical" href="${escapeHtml(url(head.canonical, 'canonical'))}">`);
57
+ if (head.alternates !== undefined)
58
+ list(head.alternates, 'alternates').forEach((entry, index) => {
59
+ const alternate = record(entry, `alternates[${index}]`);
60
+ if (Object.keys(alternate).some((key) => key !== 'hreflang' && key !== 'href'))
61
+ refuse(`head.alternates[${index}] has only hreflang and href`);
62
+ const language = text(alternate.hreflang, `alternates[${index}].hreflang`, 64);
63
+ if (!LANGUAGE.test(language))
64
+ refuse(`head.alternates[${index}].hreflang must be a language tag or x-default`);
65
+ tags.push(`<link rel="alternate" hreflang="${escapeHtml(language)}" href="${escapeHtml(url(alternate.href, `alternates[${index}].href`))}">`);
66
+ });
67
+ if (head.meta !== undefined)
68
+ list(head.meta, 'meta').forEach((entry, index) => {
69
+ const meta = record(entry, `meta[${index}]`);
70
+ const keys = Object.keys(meta).sort().join(',');
71
+ if (keys !== 'content,name' && keys !== 'content,property')
72
+ refuse(`head.meta[${index}] is { name, content } or { property, content }`);
73
+ const kind = 'name' in meta ? 'name' : 'property';
74
+ const name = text(meta[kind], `meta[${index}].${kind}`, 128);
75
+ if (!META_NAME.test(name))
76
+ refuse(`head.meta[${index}].${kind} must be letters, digits, :, ., _ or -`);
77
+ tags.push(`<meta ${kind}="${escapeHtml(name)}" content="${escapeHtml(text(meta.content, `meta[${index}].content`, TEXT_MAX))}">`);
78
+ });
79
+ return Object.freeze({ ...(title === undefined ? {} : { title }), tags: tags.join('') });
80
+ }
package/dist/index.d.ts CHANGED
@@ -3,12 +3,19 @@ import type { FastifyRequest } from 'fastify';
3
3
  import { type InertiaApi, type Props } from './responder.js';
4
4
  export type { InertiaBootFallback, InertiaBootMessages } from './boot-fallback.js';
5
5
  export { InertiaError, type InertiaErrorCode, InertiaUsageError, type InertiaUsageErrorCode, } from './errors.js';
6
+ export type { InertiaHead, InertiaHeadAlternate, InertiaHeadMeta, } from './head.js';
6
7
  export { type InertiaPluginOptions, options } from './options.js';
7
8
  export { type DeferOptions, deepMerge, defer, InertiaProp, isInertiaProp, type MergeOptions, merge, type OnceOptions, once, type PropValue, } from './props.js';
8
- export type { InertiaApi, InertiaErrorResponse, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
9
+ export { type InertiaApi, type InertiaErrorResponse, type InertiaFlash, type InertiaOptions, type InertiaPage, type InertiaRenderOptions, type InertiaSsrResult, type Json, type Props, SEAL_HOST_META, VISITOR_GROUP, } from './responder.js';
9
10
  export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
10
11
  export interface InertiaEntry {
12
+ /** The visitor's own props of every page (the user, preferences): never in a public page's first HTML. */
11
13
  readonly shared?: InertiaShared;
14
+ /**
15
+ * D-0195: props of every page that are the same for every visitor of a URL (the site's menu,
16
+ * its name): they go into the first HTML of public pages too.
17
+ */
18
+ readonly publicShared?: InertiaShared;
12
19
  }
13
20
  /**
14
21
  * Server rendering of the first HTML of a page (D-0039), in this process: the SSR build output is
@@ -52,8 +59,13 @@ export interface InertiaScopeOptions {
52
59
  };
53
60
  /** Server rendering for this scope; unset uses the plugin's `ssr`, `false` turns it off. */
54
61
  readonly ssr?: InertiaSsrOptions | false;
55
- /** Props merged after the plugin's shared props for every page of this scope. */
62
+ /** Props merged after the plugin's shared props for every page of this scope (the visitor's own). */
56
63
  readonly shared?: InertiaShared;
64
+ /**
65
+ * D-0195: props of every page of this scope that are the same for every visitor (dokebi-app
66
+ * gives `app` and `sealed`): they also go into the first HTML of public pages.
67
+ */
68
+ readonly publicShared?: InertiaShared;
57
69
  /** History encryption of this scope's pages (default `'sealed'`: on for sealed requests). */
58
70
  readonly encryptHistory?: boolean | 'sealed';
59
71
  /**
@@ -62,8 +74,8 @@ export interface InertiaScopeOptions {
62
74
  */
63
75
  readonly errorPage?: string | false;
64
76
  /**
65
- * This scope answers seal hosts (dokebi-app sets it, D-0062). Server rendering would write the
66
- * props as plain HTML, so `sealed` with SSR stops the start (`SEAL_SSR_CONFLICT`).
77
+ * This scope answers seal hosts (dokebi-app sets it, D-0062). With `ssr`, a sealed request is
78
+ * server rendered only for a public page's first HTML (D-0195); other pages stay sealed.
67
79
  */
68
80
  readonly sealed?: boolean;
69
81
  /**
@@ -75,6 +87,8 @@ export interface InertiaScopeOptions {
75
87
  /** The `inertia` engine contributed to `dokebi-web:view-engine` (web `WebViewEngine` shape). */
76
88
  export interface InertiaViewEngine {
77
89
  readonly name: 'inertia';
90
+ /** D-0195: `scope` takes `publicShared` and `render` takes `public` (dokebi-app checks it). */
91
+ readonly publicPages: true;
78
92
  scope(input: InertiaScopeOptions): Promise<InertiaApi>;
79
93
  }
80
94
  export interface InertiaPluginApi extends InertiaApi {
package/dist/index.js CHANGED
@@ -13,12 +13,13 @@ import { bootHashes } from './boot-fallback.js';
13
13
  import { builtFolderFile, HASHED } from './built-files.js';
14
14
  import { InertiaError, InertiaUsageError } from './errors.js';
15
15
  import { options } from './options.js';
16
- import { InertiaResponder, normalizeInertiaOptions, vary, } from './responder.js';
16
+ import { cacheableReplies, InertiaResponder, normalizeInertiaOptions, vary, } from './responder.js';
17
17
  import { isViteUpgrade, openViteDev, viteDevRequested } from './vite-dev.js';
18
18
  import { DEV_CLIENT_HEADER, isLoopbackHost, isRemoteRequest } from './vite-files.js';
19
19
  export { InertiaError, InertiaUsageError, } from './errors.js';
20
20
  export { options } from './options.js';
21
21
  export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
22
+ export { SEAL_HOST_META, VISITOR_GROUP, } from './responder.js';
22
23
  /** The SSR render deadline when `ssr.timeoutMs` is not set (D-0107 §4); the schema's default. */
23
24
  const SSR_TIMEOUT_MS = 5000;
24
25
  const SSR_TIMEOUT_MAX_MS = 600_000;
@@ -299,10 +300,6 @@ const session = optionalDependency({
299
300
  name: 'dokebi-session',
300
301
  version: '^0.4.0',
301
302
  });
302
- /** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
303
- function sealSsrConflict() {
304
- return new InertiaError('SEAL_SSR_CONFLICT', 'An Inertia scope of seal hosts cannot use SSR: server rendering writes the props as plain HTML. Set ssr: false for this app or drop seal from its hosts.');
305
- }
306
303
  /** The boot text of a scope for its debug record: the CSP hashes of its inline style and script. */
307
304
  function bootLog(options) {
308
305
  const boot = options.bootFallback;
@@ -345,26 +342,27 @@ function parseEntry(input) {
345
342
  types.isProxy(input) ||
346
343
  ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
347
344
  throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
348
- let shared;
345
+ const found = {};
349
346
  for (const key of Reflect.ownKeys(input)) {
350
347
  // Module namespaces carry Symbol.toStringTag; only string exports are entry fields.
351
348
  if (typeof key !== 'string')
352
349
  continue;
353
350
  const descriptor = Object.getOwnPropertyDescriptor(input, key);
354
- if (key !== 'shared' || !descriptor || !('value' in descriptor))
351
+ if ((key !== 'shared' && key !== 'publicShared') || !descriptor || !('value' in descriptor))
355
352
  throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
356
- shared = descriptor.value;
353
+ if (descriptor.value !== undefined && typeof descriptor.value !== 'function')
354
+ throw new InertiaUsageError('INVALID_ENTRY', `Inertia ${key} must be a function`);
355
+ if (descriptor.value !== undefined)
356
+ found[key] = descriptor.value;
357
357
  }
358
- if (shared !== undefined && typeof shared !== 'function')
359
- throw new InertiaUsageError('INVALID_ENTRY', 'Inertia shared must be a function');
360
- return shared === undefined ? {} : { shared: shared };
358
+ return found;
361
359
  }
362
360
  class InertiaRuntime {
363
361
  settings;
364
- shared;
365
- constructor(settings, shared) {
362
+ entry;
363
+ constructor(settings, entry) {
366
364
  this.settings = settings;
367
- this.shared = shared;
365
+ this.entry = entry;
368
366
  }
369
367
  async setup(context) {
370
368
  const server = context.getDependency(web);
@@ -374,15 +372,59 @@ class InertiaRuntime {
374
372
  plugin: 'dokebi-template-inertia',
375
373
  reserved: ['errors', 'flash'],
376
374
  });
377
- const entryShared = this.shared;
378
- const shared = !pluginData.keys.length
379
- ? entryShared
380
- : entryShared
381
- ? async (request) => ({
382
- ...(await pluginData.values(request)),
383
- ...(await entryShared(request)),
384
- })
385
- : async (request) => (await pluginData.values(request));
375
+ const entryShared = this.entry.shared;
376
+ const entryPublic = this.entry.publicShared;
377
+ // D-0195: view data its contributor calls public (@dokebi/i18n) goes into a public page's
378
+ // first HTML; a dokebi-web before 0.4.6 names none, so all of it waits for the visit.
379
+ const publicView = new Set(pluginData.publicKeys ?? []);
380
+ const visitorView = pluginData.keys.filter((key) => !publicView.has(key));
381
+ const PUBLIC_VIEW = Object.freeze({ public: true });
382
+ /**
383
+ * The shared props of a scope (D-0195), in this order, later winning: plugin view data, the
384
+ * entry's `publicShared`, its `shared`, the scope's `publicShared`, its `shared`. For a
385
+ * public first HTML only the public ones are computed; the keys of the others are named.
386
+ */
387
+ const sharedSource = (scopePublic, scopeShared) => {
388
+ if (!pluginData.keys.length && !entryShared && !entryPublic && !scopePublic && !scopeShared)
389
+ return undefined;
390
+ return {
391
+ resolve: async (request, view) => {
392
+ if (view.public) {
393
+ const data = publicView.size
394
+ ? await pluginData.values(request, PUBLIC_VIEW)
395
+ : undefined;
396
+ const props = {
397
+ ...data,
398
+ ...(await entryPublic?.(request)),
399
+ ...(await scopePublic?.(request)),
400
+ };
401
+ // A dokebi-web before 0.4.6 gives every value: only the public keys are kept.
402
+ if (data)
403
+ for (const key of Object.keys(data))
404
+ if (!publicView.has(key))
405
+ delete props[key];
406
+ const visitor = [
407
+ ...visitorView,
408
+ ...Object.keys((await entryShared?.(request)) ?? {}),
409
+ ...Object.keys((await scopeShared?.(request)) ?? {}),
410
+ ];
411
+ return { props, visitor };
412
+ }
413
+ const data = pluginData.keys.length
414
+ ? (await pluginData.values(request))
415
+ : undefined;
416
+ const publicOwn = await entryPublic?.(request);
417
+ const own = await entryShared?.(request);
418
+ const scopeOwnPublic = await scopePublic?.(request);
419
+ const scopeOwn = await scopeShared?.(request);
420
+ return {
421
+ props: { ...data, ...publicOwn, ...own, ...scopeOwnPublic, ...scopeOwn },
422
+ visitor: [...visitorView, ...Object.keys(own ?? {}), ...Object.keys(scopeOwn ?? {})],
423
+ };
424
+ },
425
+ };
426
+ };
427
+ const shared = sharedSource(undefined, undefined);
386
428
  const sessions = context.getOptionalDependency(session);
387
429
  const sessionOf = (request) =>
388
430
  // Static paths excluded by the session plugin have none (`request.session` is null).
@@ -438,6 +480,22 @@ class InertiaRuntime {
438
480
  }
439
481
  : {}),
440
482
  });
483
+ // D-0195: a public first page with the app's own Cache-Control must never be kept by a
484
+ // shared cache when its answer sets a cookie (a session, a token): back to no-store.
485
+ const cacheGuard = async (scope) => {
486
+ scope.addHook('onSend', async (request, reply, payload) => {
487
+ if (!cacheableReplies.has(reply) || !reply.hasHeader('set-cookie'))
488
+ return payload;
489
+ reply.header('Cache-Control', 'no-store');
490
+ if (logger.isEnabled('debug'))
491
+ logger.log('debug', 'Inertia public page sets a cookie: not cacheable', {
492
+ route: request.routeOptions.url ?? 'unmatched',
493
+ });
494
+ return payload;
495
+ });
496
+ };
497
+ cacheGuard[Symbol.for('skip-override')] = true;
498
+ server.register(cacheGuard, {});
441
499
  if (sessions) {
442
500
  const carry = async (scope) => {
443
501
  scope.addHook('onSend', async (request, reply, payload) => {
@@ -480,7 +538,9 @@ class InertiaRuntime {
480
538
  devHosts: this.settings.devHosts.length,
481
539
  defaultVersion: this.settings.defaultVersion,
482
540
  shared: entryShared !== undefined,
541
+ publicShared: entryPublic !== undefined,
483
542
  viewData: pluginData.keys,
543
+ publicViewData: [...publicView],
484
544
  flash: flash !== undefined,
485
545
  historyReset: hooks.historyReset !== undefined,
486
546
  encryptHistory: this.settings.document.encryptHistory,
@@ -917,7 +977,7 @@ class InertiaRuntime {
917
977
  render: rootRender,
918
978
  ssrStats: () => Object.freeze({ ...stats }),
919
979
  scope: async (options) => {
920
- const { shared: extra, vite, ssr: ssrInput, sealed = false, app: appName, ...fields } = (options ?? {});
980
+ const { shared: extra, publicShared: extraPublic, vite, ssr: ssrInput, sealed = false, app: appName, ...fields } = (options ?? {});
921
981
  // Audit r6: `app` goes into log records as it is, so it is checked like the other fields
922
982
  // (it used to be refused as an unknown key; anything was accepted once it became one).
923
983
  if (appName !== undefined && (typeof appName !== 'string' || !SCOPE_APP.test(appName)))
@@ -925,16 +985,10 @@ class InertiaRuntime {
925
985
  if (typeof sealed !== 'boolean')
926
986
  throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
927
987
  const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
928
- if (sealed && ssr) {
929
- logger.log('error', 'Inertia scope of seal hosts with SSR', {
930
- code: 'SEAL_SSR_CONFLICT',
931
- title: typeof fields.title === 'string' ? fields.title.slice(0, 100) : null,
932
- fix: 'Set inertia.ssr: false for this app, or remove seal: true from its domains',
933
- });
934
- throw sealSsrConflict();
935
- }
936
988
  if (extra !== undefined && typeof extra !== 'function')
937
989
  throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope shared must be a function');
990
+ if (extraPublic !== undefined && typeof extraPublic !== 'function')
991
+ throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope publicShared must be a function');
938
992
  if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
939
993
  throw new InertiaUsageError('INVALID_SCOPE', 'Inertia vite options replace version, scripts and styles');
940
994
  const scoped = normalizeInertiaOptions({
@@ -942,9 +996,7 @@ class InertiaRuntime {
942
996
  ...fields,
943
997
  ...(vite === undefined ? {} : await built(parseVite(vite))),
944
998
  });
945
- const merged = shared && extra
946
- ? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
947
- : (extra ?? shared);
999
+ const merged = sharedSource(extraPublic, extra);
948
1000
  const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
949
1001
  const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash, hooks);
950
1002
  // Once the scope is valid: a scope refused above serves no page with the default version.
@@ -961,8 +1013,11 @@ class InertiaRuntime {
961
1013
  rootId: scoped.rootId,
962
1014
  vite: scopedVite ? scopedVite.entry : false,
963
1015
  ssr: ssr ? ssr.bundle : false,
964
- shared: merged !== undefined,
1016
+ shared: extra !== undefined,
1017
+ publicShared: extraPublic !== undefined,
965
1018
  sealed,
1019
+ // D-0195: a seal host's requests are server rendered only for public first pages.
1020
+ ...(sealed && ssr ? { ssrOnSealedHosts: 'public pages' } : {}),
966
1021
  encryptHistory: scoped.encryptHistory,
967
1022
  errorPage: scoped.errorPage,
968
1023
  bootFallback: bootLog(scoped),
@@ -973,6 +1028,7 @@ class InertiaRuntime {
973
1028
  // D-0063 §5: the view engine `inertia` for dokebi-app (inert until a plugin collects it).
974
1029
  context.contribute(viewEngines, Object.freeze({
975
1030
  name: 'inertia',
1031
+ publicPages: true,
976
1032
  scope: (input) => plugin.scope(input),
977
1033
  }));
978
1034
  return plugin;
@@ -980,12 +1036,12 @@ class InertiaRuntime {
980
1036
  }
981
1037
  export const createPlugin = definePlugin({
982
1038
  name: 'dokebi-template-inertia',
983
- version: '0.4.4',
1039
+ version: '0.4.6',
984
1040
  core: '^0.4.2',
985
1041
  options,
986
1042
  entry: parseEntry,
987
1043
  contributes: [viewEngines],
988
1044
  collects: [viewData],
989
1045
  dependencies: () => [web, session],
990
- instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
1046
+ instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry ?? {}),
991
1047
  });
@@ -3,6 +3,7 @@ import { diagnostics } from '@dokebi/core/libraries';
3
3
  import { type WebErrorResponse } from '@dokebi/web';
4
4
  import type { FastifyReply, FastifyRequest } from 'fastify';
5
5
  import { type InertiaBootFallback, type NormalizedBootFallback } from './boot-fallback.js';
6
+ import { type InertiaHead } from './head.js';
6
7
  import { type InertiaProp } from './props.js';
7
8
  export type Json = null | boolean | number | string | readonly Json[] | {
8
9
  readonly [key: string]: Json;
@@ -35,7 +36,7 @@ export interface InertiaOptions {
35
36
  }
36
37
  /** What `answerError` reads of a dokebi-web error answer (`WebErrorResponse`). */
37
38
  export type InertiaErrorResponse = Pick<WebErrorResponse, 'request' | 'reply' | 'status' | 'body' | 'format' | 'sealed'>;
38
- /** Per response history settings; unset values come from the scope (`encryptHistory`) and session. */
39
+ /** Per response settings; unset history values come from the scope (`encryptHistory`) and session. */
39
40
  export interface InertiaRenderOptions {
40
41
  /** Encrypt this page in the browser history (Inertia `encryptHistory`). */
41
42
  readonly encryptHistory?: boolean;
@@ -44,7 +45,44 @@ export interface InertiaRenderOptions {
44
45
  * the session was destroyed or regenerated (@dokebi/session 0.2 `historyReset`, logout, login).
45
46
  */
46
47
  readonly clearHistory?: boolean;
48
+ /**
49
+ * D-0195: a public page, whose first HTML is the same for every visitor (a crawler, a guest, a
50
+ * signed-in member). That HTML carries only the props of `render` and the public shared props
51
+ * (view data such as @dokebi/i18n's, the entry's and the scope's `publicShared`); the visitor's
52
+ * own (auth, csrf, the entry's `shared`) are announced as the deferred group `dokebi-visitor`
53
+ * and fetched by the client right after it starts, through a visit (sealed on a seal host). The
54
+ * flash and the history reset are left for the next page. On a seal host the first HTML is
55
+ * plain and server rendered when the scope has `ssr`. `cacheControl` replaces `no-store` for
56
+ * that HTML (never when the answer sets a cookie). Inertia visits are unchanged.
57
+ */
58
+ readonly public?: boolean | {
59
+ readonly cacheControl?: string;
60
+ };
61
+ /** D-0195: head tags of the first HTML (title, description, canonical, alternates, meta). */
62
+ readonly head?: InertiaHead;
47
63
  }
64
+ /**
65
+ * Where a responder gets the shared props of a page (D-0195). `public` asks only for what is the
66
+ * same for every visitor (the first HTML of a public page); `visitor` names the keys of the rest,
67
+ * which that HTML leaves out.
68
+ */
69
+ export interface InertiaSharedSource {
70
+ resolve(request: FastifyRequest, view: {
71
+ readonly public: boolean;
72
+ }): Promise<{
73
+ readonly props: Props;
74
+ readonly visitor: readonly string[];
75
+ }>;
76
+ }
77
+ /** The deferred group of a public page's visitor props (D-0195). */
78
+ export declare const VISITOR_GROUP = "dokebi-visitor";
79
+ /**
80
+ * Replies of public first pages with the app's own Cache-Control: an answer among them that
81
+ * sets a cookie goes back to `no-store` on its way out (the plugin's onSend hook).
82
+ */
83
+ export declare const cacheableReplies: WeakSet<FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>>;
84
+ /** The `<meta>` that tells `sealInertia` of @dokebi/seal/http that a plain first page is on a seal host. */
85
+ export declare const SEAL_HOST_META = "<meta name=\"dokebi-seal\" content=\"host\">";
48
86
  /**
49
87
  * What the plugin gives a responder besides its options: the sealing of a request (dokebi-web
50
88
  * `sealOf`) and the session's history reset signal.
@@ -130,13 +168,14 @@ export type NormalizedInertiaOptions = Readonly<Required<Omit<InertiaOptions, 'b
130
168
  export declare function normalizeInertiaOptions(options?: InertiaOptions): NormalizedInertiaOptions;
131
169
  export declare class InertiaResponder implements InertiaApi {
132
170
  #private;
133
- readonly shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined;
134
171
  readonly ssr?: InertiaSsr | undefined;
135
172
  readonly diagnostics?: InertiaDiagnostics | undefined;
136
173
  readonly flash?: InertiaFlash | undefined;
137
174
  readonly hooks: InertiaHooks;
138
175
  readonly options: NormalizedInertiaOptions;
139
- constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
176
+ constructor(options?: InertiaOptions,
177
+ /** A function gives the visitor's props (none of them public); a source can give both. */
178
+ shared?: ((request: FastifyRequest) => Awaitable<Props>) | InertiaSharedSource, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
140
179
  close(): void;
141
180
  render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props, history?: InertiaRenderOptions): Promise<never>;
142
181
  /**
package/dist/responder.js CHANGED
@@ -2,8 +2,19 @@ import { diagnostics } from '@dokebi/core/libraries';
2
2
  import { formFieldErrors } from '@dokebi/web';
3
3
  import { bootMarkup, normalizeBootFallback, } from './boot-fallback.js';
4
4
  import { InertiaError, InertiaUsageError } from './errors.js';
5
+ import { headMarkup } from './head.js';
5
6
  import { VERSION } from './options.js';
6
- import { isInertiaProp } from './props.js';
7
+ import { defer, isInertiaProp } from './props.js';
8
+ /** The deferred group of a public page's visitor props (D-0195). */
9
+ export const VISITOR_GROUP = 'dokebi-visitor';
10
+ /**
11
+ * Replies of public first pages with the app's own Cache-Control: an answer among them that
12
+ * sets a cookie goes back to `no-store` on its way out (the plugin's onSend hook).
13
+ */
14
+ export const cacheableReplies = new WeakSet();
15
+ /** The `<meta>` that tells `sealInertia` of @dokebi/seal/http that a plain first page is on a seal host. */
16
+ export const SEAL_HOST_META = '<meta name="dokebi-seal" content="host">';
17
+ const CACHE_CONTROL = /^[\x21-\x7e](?:[\x20-\x7e]{0,254}[\x21-\x7e])?$/;
7
18
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
8
19
  /**
9
20
  * A `<title>` head tag of the SSR bundle with its text made safe. @inertiajs/vue3 escapes the
@@ -159,25 +170,36 @@ function checkScalar(value) {
159
170
  throw new InertiaUsageError('INVALID_PROPS', UNSUPPORTED);
160
171
  }
161
172
  export class InertiaResponder {
162
- shared;
163
173
  ssr;
164
174
  diagnostics;
165
175
  flash;
166
176
  hooks;
167
177
  options;
178
+ /** The shared props of every page (D-0195: public and visitor ones). */
179
+ #shared;
168
180
  /**
169
181
  * Every value of a page is checked outside production (where the mistake is made and seen);
170
182
  * in production only the top-level props are, and the page goes to JSON.stringify as it is.
171
183
  */
172
184
  #deepCheck = process.env.NODE_ENV !== 'production';
173
185
  #closed = false;
174
- constructor(options = {}, shared, ssr, diagnostics, flash, hooks = {}) {
175
- this.shared = shared;
186
+ constructor(options = {},
187
+ /** A function gives the visitor's props (none of them public); a source can give both. */
188
+ shared, ssr, diagnostics, flash, hooks = {}) {
176
189
  this.ssr = ssr;
177
190
  this.diagnostics = diagnostics;
178
191
  this.flash = flash;
179
192
  this.hooks = hooks;
180
193
  this.options = normalizeInertiaOptions(options);
194
+ this.#shared =
195
+ typeof shared === 'function'
196
+ ? {
197
+ resolve: async (request, view) => {
198
+ const props = (await shared(request)) ?? {};
199
+ return { props: view.public ? {} : props, visitor: Object.keys(props) };
200
+ },
201
+ }
202
+ : shared;
181
203
  }
182
204
  close() {
183
205
  this.#closed = true;
@@ -190,9 +212,13 @@ export class InertiaResponder {
190
212
  this.#check();
191
213
  if (history === null ||
192
214
  typeof history !== 'object' ||
215
+ Object.keys(history).some((key) => !['encryptHistory', 'clearHistory', 'public', 'head'].includes(key)) ||
193
216
  (history.encryptHistory !== undefined && typeof history.encryptHistory !== 'boolean') ||
194
217
  (history.clearHistory !== undefined && typeof history.clearHistory !== 'boolean'))
195
- throw new InertiaUsageError('INVALID_RENDER', 'Inertia render options are { encryptHistory?, clearHistory? } booleans');
218
+ throw new InertiaUsageError('INVALID_RENDER', 'Inertia render options are { encryptHistory?, clearHistory?, public?, head? }');
219
+ publicSettings(history.public);
220
+ if (history.head !== undefined)
221
+ headMarkup(history.head);
196
222
  if (!['GET', 'HEAD'].includes(request.method))
197
223
  throw new InertiaError('METHOD_NOT_ALLOWED', 'Render pages using GET/HEAD; redirect mutations or use REST');
198
224
  if (!validComponent(component))
@@ -279,11 +305,30 @@ export class InertiaResponder {
279
305
  .header('X-Inertia-Version', this.options.version)
280
306
  .send();
281
307
  }
282
- // A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML.
308
+ // A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML,
309
+ // except the first HTML of a public page (D-0195), which holds no visitor's value.
283
310
  const sealer = this.hooks.sealOf?.(request);
284
- if (sealer && this.ssr && !inertia)
285
- throw new InertiaError('SEAL_SSR_CONFLICT', 'A sealed page cannot be server rendered: SSR writes the props as plain HTML. Turn off ssr for this app or seal for its hosts.');
286
- const props = { ...(withShared ? await this.shared?.(request) : undefined), ...input };
311
+ const publicPage = publicSettings(history.public);
312
+ const publicHtml = publicPage !== undefined && !inertia;
313
+ // D-0195: the shared props. The first HTML of a public page gets only the public ones; the
314
+ // visitor's own are announced as a deferred group, fetched by a visit after the page starts.
315
+ let props = { ...input };
316
+ let visitor;
317
+ if (withShared && this.#shared) {
318
+ const shared = await this.#shared.resolve(request, { public: publicHtml });
319
+ props = { ...shared.props, ...input };
320
+ if (publicPage !== undefined)
321
+ visitor = new Set(shared.visitor);
322
+ if (publicHtml)
323
+ for (const key of shared.visitor)
324
+ if (key !== 'errors' && !Object.hasOwn(input, key))
325
+ Object.defineProperty(props, key, {
326
+ value: defer(() => null, VISITOR_GROUP),
327
+ enumerable: true,
328
+ writable: true,
329
+ configurable: true,
330
+ });
331
+ }
287
332
  const partial = inertia && request.headers['x-inertia-partial-component'] === component;
288
333
  const list = (key) => typeof request.headers[key] === 'string'
289
334
  ? new Set(request.headers[key]
@@ -297,21 +342,23 @@ export class InertiaResponder {
297
342
  const meta = buildMetadata(props, partial, only, except, reset, held);
298
343
  // The client ignores the flash of a deferred-props fetch (it keeps the page's), so such a
299
344
  // request leaves the session's flash for the next page instead of using it up.
345
+ // The fetch of a public page's visitor props (D-0195) is such a fetch too.
300
346
  const deferredFetch = partial &&
301
347
  only.size > 0 &&
302
348
  [...only].every((key) => {
303
349
  const value = Object.hasOwn(props, key) ? props[key] : undefined;
304
- return isInertiaProp(value) && value.group !== undefined;
350
+ return (isInertiaProp(value) && value.group !== undefined) || visitor?.has(key) === true;
305
351
  });
306
352
  const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
307
353
  // Read after the props: a failed render leaves the flash for the next page. An error page
308
354
  // leaves it (and the history reset) for the page the user goes to next.
309
- const flash = this.flash && session && !deferredFetch ? await this.flash(request) : undefined;
355
+ // A public page's first HTML is the same for every visitor: the flash and the history reset
356
+ // stay for the next page (D-0195).
357
+ const visitorState = session && !deferredFetch && !publicHtml;
358
+ const flash = this.flash && visitorState ? await this.flash(request) : undefined;
310
359
  // Read like the flash: once, by the page that shows it (logout or login happened before).
311
360
  const clearHistory = history.clearHistory ??
312
- (this.hooks.historyReset && session && !deferredFetch
313
- ? await this.hooks.historyReset(request)
314
- : false);
361
+ (this.hooks.historyReset && visitorState ? await this.hooks.historyReset(request) : false);
315
362
  const configured = this.options.encryptHistory;
316
363
  const encryptHistory = history.encryptHistory ?? (configured === 'sealed' ? sealer !== undefined : configured);
317
364
  // An ordinary object: JSON.stringify of a null-prototype object leaves V8's fast path (2×
@@ -395,7 +442,9 @@ export class InertiaResponder {
395
442
  // The XHR answer is JSON: dokebi-web seals it on the way out for a sealed request.
396
443
  if (inertia)
397
444
  return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
398
- const { rootId, title, scripts, styles } = this.options;
445
+ const head = history.head === undefined ? undefined : headMarkup(history.head);
446
+ const { rootId, scripts, styles } = this.options;
447
+ const title = head?.title ?? this.options.title;
399
448
  const language = reply.getHeader('content-language');
400
449
  const htmlLanguage = typeof language === 'string' && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)
401
450
  ? ` lang="${escapeHtml(language)}"`
@@ -404,27 +453,47 @@ export class InertiaResponder {
404
453
  const boot = this.options.bootFallback.enabled
405
454
  ? bootMarkup(this.options.bootFallback, rootId, typeof language === 'string' ? language : undefined)
406
455
  : undefined;
407
- if (sealer) {
456
+ if (sealer && !publicHtml) {
408
457
  // `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
409
- // (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
458
+ // (`sealInertia` of @dokebi/seal/http). Never server rendered: SSR writes the props as
459
+ // plain HTML (only a public page's are, below).
410
460
  const frame = sealer.encode(json);
411
461
  return reply
412
462
  .type('text/html; charset=utf-8')
413
- .send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${boot?.head ?? ''}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}">${boot?.root ?? ''}</div></body></html>`);
463
+ .send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${head?.tags ?? ''}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${boot?.head ?? ''}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}">${boot?.root ?? ''}</div></body></html>`);
414
464
  }
415
465
  // Only the first HTML is server rendered; Inertia visits above stay JSON.
416
466
  const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
467
+ if (publicPage !== undefined) {
468
+ if (publicPage.cacheControl !== undefined) {
469
+ reply.header('Cache-Control', publicPage.cacheControl);
470
+ cacheableReplies.add(reply);
471
+ }
472
+ if (logger?.isEnabled('debug'))
473
+ logger.log('debug', 'Inertia public page', {
474
+ component,
475
+ sealedHost: sealer !== undefined,
476
+ serverRendered: rendered !== undefined,
477
+ visitor: [...(visitor ?? [])].slice(0, 64),
478
+ cacheControl: publicPage.cacheControl ?? 'no-store',
479
+ });
480
+ }
417
481
  const safeJson = json
418
482
  .replace(/</g, '\\u003c')
419
483
  .replace(/\//g, '\\/')
420
484
  .replace(/\u2028/g, '\\u2028')
421
485
  .replace(/\u2029/g, '\\u2029');
422
- // A <title> from the page's <Head> replaces the configured one.
423
- const head = (rendered?.head ?? []).map(safeTitleTag);
424
- const titled = head.some((tag) => /^<title[\s>]/i.test(tag));
486
+ // A <title> from the page's <Head> replaces the configured one; the route's head title
487
+ // (D-0195) replaces both.
488
+ const ssrHead = (rendered?.head ?? [])
489
+ .map(safeTitleTag)
490
+ .filter((tag) => head?.title === undefined || !/^<title[\s>]/i.test(tag));
491
+ const titled = head?.title === undefined && ssrHead.some((tag) => /^<title[\s>]/i.test(tag));
492
+ // D-0195: a plain first page of a seal host tells sealInertia to seal what follows.
493
+ const sealHost = sealer ? SEAL_HOST_META : '';
425
494
  return reply
426
495
  .type('text/html; charset=utf-8')
427
- .send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titled ? '' : `<title>${escapeHtml(title)}</title>`}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${rendered ? '' : (boot?.head ?? '')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}${head.join('')}</head><body>${rendered?.body ?? `<script data-page="${rootId}" type="application/json">${safeJson}</script><div id="${rootId}">${boot?.root ?? ''}</div>`}</body></html>`);
496
+ .send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${sealHost}${titled ? '' : `<title>${escapeHtml(title)}</title>`}${head?.tags ?? ''}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${rendered ? '' : (boot?.head ?? '')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}${ssrHead.join('')}</head><body>${rendered?.body ?? `<script data-page="${rootId}" type="application/json">${safeJson}</script><div id="${rootId}">${boot?.root ?? ''}</div>`}</body></html>`);
428
497
  }
429
498
  /**
430
499
  * The value of one selected prop. A function runs here; a rescued deferred prop that throws is
@@ -506,6 +575,24 @@ export class InertiaResponder {
506
575
  }
507
576
  }
508
577
  const RESCUED = Symbol('rescued');
578
+ /**
579
+ * The checked `public` render option (D-0195): undefined for an ordinary page, the settings of a
580
+ * public one. Throws INVALID_RENDER for anything else.
581
+ */
582
+ function publicSettings(value) {
583
+ if (value === undefined || value === false)
584
+ return undefined;
585
+ if (value === true)
586
+ return {};
587
+ if (value !== null &&
588
+ typeof value === 'object' &&
589
+ !Array.isArray(value) &&
590
+ Object.keys(value).every((key) => key === 'cacheControl') &&
591
+ (value.cacheControl === undefined ||
592
+ (typeof value.cacheControl === 'string' && CACHE_CONTROL.test(value.cacheControl))))
593
+ return value;
594
+ throw new InertiaUsageError('INVALID_RENDER', 'Inertia render option public is true or { cacheControl } (1-256 printable ASCII characters)');
595
+ }
509
596
  /**
510
597
  * An ordinary object with the Map's entries as own keys (JSON.stringify keeps its fast path, see
511
598
  * the page props); `__proto__` is defined as an own key, never assigned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/template-inertia",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Inertia v3 server adapter for Dokebi: page JSON, Vite manifest assets and optional server-side rendering.",
5
5
  "keywords": [
6
6
  "dokebi",
@@ -135,9 +135,9 @@
135
135
  ]
136
136
  },
137
137
  "devDependencies": {
138
- "@dokebi/i18n": "0.2.0",
139
- "@dokebi/session": "0.4.4",
140
- "@dokebi/web": "0.4.3",
138
+ "@dokebi/i18n": "0.2.2",
139
+ "@dokebi/session": "0.4.6",
140
+ "@dokebi/web": "0.4.6",
141
141
  "@dokebi/websocket": "0.4.2",
142
142
  "@dokebi/websocket-hub": "0.4.2",
143
143
  "@inertiajs/vue3": "3.9.1",