@dokebi/template-inertia 0.2.0 → 0.4.1
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 +147 -91
- package/README.md +52 -42
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +43 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +123 -67
- package/dist/options.d.ts +22 -0
- package/dist/options.js +84 -0
- package/dist/props.js +6 -5
- package/dist/responder.d.ts +30 -2
- package/dist/responder.js +174 -37
- package/dist/vite-dev.d.ts +50 -12
- package/dist/vite-dev.js +460 -27
- package/dist/vite-host.d.ts +1 -0
- package/dist/vite-host.js +62 -0
- package/options.schema.json +44 -17
- package/package.json +66 -11
- package/dist/repeats.d.ts +0 -18
- package/dist/repeats.js +0 -52
package/AI.md
CHANGED
|
@@ -1,133 +1,189 @@
|
|
|
1
|
-
# AI
|
|
1
|
+
# AI guide — @dokebi/template-inertia 0.4.1
|
|
2
2
|
|
|
3
|
-
Inertia(v3)
|
|
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.1 (peer). Details are in the [README](README.md).
|
|
4
4
|
|
|
5
|
-
## 1.
|
|
5
|
+
## 1. Install and project layout
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npx dokebi add @dokebi/template-inertia # src/infra/inertia/shared.ts(
|
|
9
|
-
npx dokebi app add admin --inertia [--ssr] --domain admin.a.com # @dokebi/app
|
|
10
|
-
npm run build # dist/(
|
|
8
|
+
npx dokebi add @dokebi/template-inertia # src/infra/inertia/shared.ts (shared props)
|
|
9
|
+
npx dokebi app add admin --inertia [--ssr] --domain admin.a.com # a @dokebi/app app + Vite build setup
|
|
10
|
+
npm run build # dist/ (browser) + dist/ssr/ (server, --ssr)
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
- Inertia 앱의 app.ts: `inertia: { title, vite: { manifest: 'dist/.vite/manifest.json', entry: 'src/apps/<앱>/views/app.js', base: '/build/' }, ssr?: { bundle: 'dist/ssr/<앱>/ssr.js' } }`.
|
|
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 …`.
|
|
15
14
|
|
|
16
|
-
|
|
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
|
+
- 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' } }`.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|---|---|
|
|
20
|
-
| `title` | 문서 제목(기본 빈 문자열) |
|
|
21
|
-
| `rootId` | 루트 요소 id(기본 `app`, `^[A-Za-z][A-Za-z0-9_-]*$`) |
|
|
22
|
-
| `version` · `scripts` · `styles` | 자산 버전과 URL(`/`로 시작하는 로컬 URL). 없으면 `INERTIA_VERSION_DEFAULT` 경고 |
|
|
23
|
-
| `vite: { manifest, entry, base? }` | manifest에서 scripts·styles·version 계산. 위 셋과 함께 쓰면 거부 |
|
|
24
|
-
| `ssr: { bundle, failure?, entry? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error`. `entry`는 개발 모드에서 쓰는 번들의 원본 모듈(§5.1) |
|
|
25
|
-
| `encryptHistory` | Inertia history 암호화: `true`·`false`·`'sealed'`(기본: 봉인 요청이면 켬, D-0062) |
|
|
18
|
+
## 2. Options `options.dokebi-template-inertia` (per app, `scope` overrides them)
|
|
26
19
|
|
|
27
|
-
|
|
20
|
+
| Key | Type | Default | Description |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `title` | string | `''` | document title of the first HTML |
|
|
23
|
+
| `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 |
|
|
25
|
+
| `scripts` · `styles` | string[] | none | module scripts and stylesheets of the first HTML (local URLs starting with `/`) |
|
|
26
|
+
| `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) |
|
|
28
|
+
| `encryptHistory` | `true` · `false` · `'sealed'` | `'sealed'` | Inertia history encryption; `'sealed'` turns it on for sealed requests (D-0062) |
|
|
29
|
+
| `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 |
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
## 3. Rendering pages
|
|
32
|
+
|
|
33
|
+
```ts snippet
|
|
34
|
+
// @dokebi/app app route: page.inertia is a renderer with the app's settings
|
|
31
35
|
route.get('/orders', async (request, reply, page) =>
|
|
32
36
|
page.inertia!.render(request, reply, 'Orders/Index', {
|
|
33
|
-
orders: await listOrders(), // JSON
|
|
34
|
-
stats: () => computeStats(), // lazy:
|
|
37
|
+
orders: await listOrders(), // a JSON value
|
|
38
|
+
stats: () => computeStats(), // lazy: evaluated only when requested (concurrently)
|
|
35
39
|
}),
|
|
36
40
|
);
|
|
37
41
|
route.post('/orders', async (request, reply, page) => {
|
|
38
42
|
await createOrder(request.body);
|
|
39
|
-
return page.inertia!.redirect(request, reply, '/orders'); //
|
|
43
|
+
return page.inertia!.redirect(request, reply, '/orders'); // 303 for mutations
|
|
40
44
|
});
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
- `render(request, reply, component, props?, { encryptHistory?, clearHistory? }?)`: GET/HEAD
|
|
44
|
-
- props: JSON
|
|
45
|
-
- `
|
|
46
|
-
- `
|
|
47
|
+
- `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
|
+
- 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 and cycles with `JSON.stringify`'s `TypeError`. 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
|
+
- 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).
|
|
50
|
+
- `redirect(request, reply, localUrl)`: 302 for GET/HEAD, 303 for mutations; an Inertia `#` navigation gets 409 + `X-Inertia-Redirect`.
|
|
51
|
+
- `location(request, reply, url)`: a local or external http(s) navigation. Inertia requests get 409 + `X-Inertia-Location`.
|
|
47
52
|
|
|
48
|
-
### 3.1 deferred
|
|
53
|
+
### 3.1 deferred, merge and once props (D-0052)
|
|
49
54
|
|
|
50
|
-
```ts
|
|
55
|
+
```ts snippet
|
|
51
56
|
import { defer, merge, deepMerge, once } from '@dokebi/template-inertia';
|
|
52
57
|
page.inertia!.render(request, reply, 'Orders/Index', {
|
|
53
|
-
orders: merge(() => listOrders(page), { matchOn: 'id' }), //
|
|
54
|
-
stats: defer(() => computeStats()), //
|
|
55
|
-
chart: defer(() => chart(), 'charts'), //
|
|
56
|
-
feed: defer(() => feed(), { merge: true, rescue: true }), //
|
|
57
|
-
plans: once(() => listPlans(), { ttlMs: 3_600_000 }), //
|
|
58
|
+
orders: merge(() => listOrders(page), { matchOn: 'id' }), // appended to the array on partial reloads (same id replaced)
|
|
59
|
+
stats: defer(() => computeStats()), // left out of the first page, fetched after render in group 'default'
|
|
60
|
+
chart: defer(() => chart(), 'charts'), // one request per group
|
|
61
|
+
feed: defer(() => feed(), { merge: true, rescue: true }), // a throw drops it into rescuedProps + error log
|
|
62
|
+
plans: once(() => listPlans(), { ttlMs: 3_600_000 }), // not recomputed while the client has it
|
|
58
63
|
settings: deepMerge({ theme: { dark: true } }),
|
|
59
64
|
});
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
- `defer(fn, group? | { group?, merge?, once?, rescue? })`:
|
|
63
|
-
- `merge(value | fn, { mode?: 'append' | 'prepend' | 'deep', matchOn? })`, `deepMerge(value | fn, { matchOn? })`: page `mergeProps
|
|
64
|
-
- `once(value | fn, { key?, ttlMs? })`: page `onceProps: { key: { prop, expiresAt } }`.
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
67
|
+
- `defer(fn, group? | { group?, merge?, once?, rescue? })`: not computed on the first load (HTML or Inertia visit); the page gets `deferredProps: { group: [name] }`. `fn` runs only when the client asks again with `only`. The SSR first HTML has no value either (the `#fallback` of Vue `<Deferred data="stats">`).
|
|
68
|
+
- `merge(value | fn, { mode?: 'append' | 'prepend' | 'deep', matchOn? })`, `deepMerge(value | fn, { matchOn? })`: page `mergeProps`, `prependProps`, `deepMergeProps`, `matchPropsOn` (`"<prop>.<field>"`). The client merges **only on partial reloads** (`router.reload({ only: ['orders'], data: { page: 2 } })`). With `X-Inertia-Reset` (`reset: ['orders']`) it replaces instead of merging.
|
|
69
|
+
- `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
|
+
- 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
|
+
- 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.
|
|
70
75
|
|
|
71
|
-
### 3.2
|
|
76
|
+
### 3.2 Session flash (D-0055)
|
|
72
77
|
|
|
73
|
-
@dokebi/session
|
|
78
|
+
When @dokebi/session is registered in the app (an optional dependency, never created automatically), the session flash goes into the page's **`flash` field**. It works like `takeFlash()`: shown once, then gone. When empty, the field is absent. It is not a prop (not `props.flash`).
|
|
74
79
|
|
|
75
|
-
```ts
|
|
80
|
+
```ts snippet
|
|
76
81
|
route.post('/orders', async (request, reply, page) => {
|
|
77
82
|
await createOrder(request.body);
|
|
78
83
|
await request.session.flash('success', 'Order saved'); // @dokebi/session
|
|
79
84
|
return page.inertia!.redirect(request, reply, '/orders'); // 303
|
|
80
85
|
});
|
|
81
|
-
// Vue: usePage().flash.success,
|
|
86
|
+
// Vue: usePage().flash.success, or router.on('flash', (event) => toast(event.detail.flash))
|
|
82
87
|
```
|
|
83
88
|
|
|
84
|
-
-
|
|
85
|
-
- deferred props
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
## 4. 빌드 파일 서빙
|
|
89
|
-
|
|
90
|
-
`vite`를 쓰면 manifest(최대 4MiB)를 setup에서 한 번 읽어 entry에서 닿는 파일(정적·동적 청크, CSS, assets)만 `<base><파일>` 정확한 경로로 내보낸다(`cache-control: public, max-age=31536000, immutable`). manifest 자체와 목록 밖 파일, `dist/ssr/`는 내보내지 않는다. manifest가 없으면 시작 실패(`Vite manifest not found: … Build the views first (npm run build).`), entry가 없으면 시작 실패.
|
|
91
|
-
|
|
92
|
-
## 5. SSR(D-0039)
|
|
93
|
-
|
|
94
|
-
- `ssr.bundle`은 `render(page) → { head: string[]; body: string }`을 export하는 빌드 파일이다. Inertia v3에서는 `createInertiaApp({ resolve, layout })`을 서버에서 page 없이 부르면 `(page, renderToString)` 함수를 준다(`--ssr`이 만드는 `views/ssr.js` 참고).
|
|
95
|
-
- 번들은 시작 때 한 번 import한다. **없거나 `render`가 없으면 시작 실패.**
|
|
96
|
-
- 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500). 로그의 `error`는 이름·전체 메시지·스택·`Caused by:` 원인, URL 인증 정보만 `//***@`로 지우고 16KB 넘으면 잘라 표시이고, 같은 번들·컴포넌트는 10초에 한 번만 남기며 사이 횟수는 다음 로그의 `repeated`(D-0047). `ssrStats().failures`는 모두 센다.
|
|
97
|
-
- head에 `<title>`이 있으면 설정 title은 빠진다. Inertia 이동(JSON)은 SSR을 거치지 않는다.
|
|
98
|
-
- 브라우저 진입은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다. 서버·브라우저가 같은 레이아웃을 고르도록 페이지 모듈을 바꾸지 말고 `layout` 옵션을 쓴다(동시에 여러 템플릿을 그리므로).
|
|
99
|
-
- `ssrStats()` → `{ renders, failures }`(health·대시보드용). `scope({ ssr: false })`로 앱별로 끌 수 있다.
|
|
89
|
+
- The client (3.9.1) does not store flash in history, so it does not reappear on back navigation.
|
|
90
|
+
- A partial reload that fetches only deferred props does not read the flash (the client would drop it). The first HTML, visits and other partial reloads read it. A render whose props failed leaves the flash in place.
|
|
91
|
+
- Without the session plugin the page is unchanged.
|
|
100
92
|
|
|
101
|
-
|
|
93
|
+
### 3.3 Forms and validation (D-0074)
|
|
102
94
|
|
|
103
|
-
|
|
104
|
-
- HMR 소켓은 프로세스마다 빈 포트 하나를 따로 쓴다(앱 포트는 @dokebi/websocket이 upgrade를 모두 받으므로). 브라우저는 페이지와 같은 호스트로 붙는다.
|
|
105
|
-
- SSR은 요청마다 `ssrLoadModule(<원본 진입>)`. 원본 진입은 `ssr.entry`(예: `src/apps/admin/views/ssr.js`), 없으면 `vite.entry`와 같은 폴더의 `ssr.js`. 둘 다 없으면 시작 실패.
|
|
106
|
-
- Vite가 프로젝트에 없으면 시작 실패(`npm install --save-dev vite`).
|
|
107
|
-
- 호스트: Vite 자체 검사(`allowedHosts`)는 끄고, `<base>*`는 루프백 이름(`localhost`·`*.localhost`·127.x·::1), @dokebi/app의 `domains`, 이 프로세스가 Inertia 페이지로 답한 호스트에만 답한다(나머지 403). 그래서 hosts 파일·터널의 앱 도메인(예: `a.com`)으로도 개발할 수 있고 DNS rebinding은 막힌다.
|
|
108
|
-
- cluster 앱은 `dokebi dev`에서 워커 1개로 돈다(@dokebi/cluster 0.1.2).
|
|
109
|
-
- 절대 경로 import를 빌드에서 `external`로 둔 경우, 개발 서버는 그 경로 앞에 `<base>`를 붙인다. 개발 서버에서는 실제 파일로 풀어 주는 Vite 플러그인을 쓴다(플레이그라운드 `vite.config.ts` 참고).
|
|
110
|
-
- 운영(`DOKEBI_VITE_DEV` 없음)에는 영향이 없다. 운영 서버에 이 변수를 넣지 않는다.
|
|
111
|
-
- Vite 미들웨어가 오류를 넘기면 500으로 답하고 `INERTIA_VITE_DEV_FAILED`(error, 10초에 한 번·`repeated`) 로그를 남긴다. 처리할 것이 없을 때만 404.
|
|
112
|
-
- debug 로그(D-0047): `Inertia settings resolved`·`Vite dev server started`(ms)·`Vite dev route registered`·`Vite manifest read`·`Inertia SSR bundle loaded`·`Inertia SSR renders the source through Vite`(쓰인 원본)·`Inertia scope created`, 요청마다는 개발 경로가 거절한 호스트 `Vite dev route refused a host`(debug가 켜졌을 때만).
|
|
95
|
+
**Refuse input with a validation error, never with your own 422.** A refused route schema (`schema.body`, `schema.querystring`) and `throw validationError({ name: 'Already taken' })` (from `@dokebi/web`) in the handler are the same 422 `VALIDATION_FAILED` with `fields` and `errors`. For an Inertia request (`X-Inertia`) this plugin answers it like Laravel does:
|
|
113
96
|
|
|
114
|
-
|
|
97
|
+
- **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), 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
|
+
- **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 } })`.
|
|
99
|
+
- **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:
|
|
115
100
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
- 데이터 조회·수정 API는 Inertia 응답이 아니라 일반 JSON 라우트(REST)로 만든다.
|
|
122
|
-
- 배포에는 빌드별 version(또는 `vite`)을 꼭 쓴다. 기본 version이면 자산이 바뀌어도 클라이언트가 모른다.
|
|
123
|
-
- Precognition, `scrollProps`(무한 스크롤), `sharedProps`, 점 경로 partial reload(`only: ['user.name']`)는 없다. 있다고 가정하지 않는다. session flash는 @dokebi/session이 있을 때만(§3.2).
|
|
124
|
-
- 앱 인스턴스마다 새 플러그인. 전체 drain 뒤에 responder가 닫힌다.
|
|
125
|
-
|
|
126
|
-
근거: `examples/24-template-inertia`(실제 Chrome: CSR·SSR·cluster, 측정), `examples/26-scaffold`(CLI가 만든 SSR 앱의 빌드·서버 렌더), `examples/25-playground`, `tests/flash.test.ts`(세션 flash), D-0027·D-0031·D-0039·D-0045·D-0052·D-0055.
|
|
127
|
-
|
|
128
|
-
## 봉인 호스트와 브라우저 기록(D-0062, 0.2.0)
|
|
101
|
+
```js snippet
|
|
102
|
+
const form = useForm({ name: '', text: '' });
|
|
103
|
+
form.post('/guestbook', { only: ['entries'], onSuccess: () => form.reset('text') });
|
|
104
|
+
// refused: 303 → GET with only entries and errors; form.errors.text holds the message
|
|
105
|
+
```
|
|
129
106
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
107
|
+
`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
|
+
|
|
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.
|
|
110
|
+
|
|
111
|
+
## 4. Serving build files
|
|
112
|
+
|
|
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`). 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.
|
|
114
|
+
|
|
115
|
+
## 5. SSR (D-0039)
|
|
116
|
+
|
|
117
|
+
- `ssr.bundle` is a build file that exports `render(page) → { head: string[]; body: string }`. In Inertia v3, calling `createInertiaApp({ resolve, layout })` on the server without a page returns a `(page, renderToString)` function (see the `views/ssr.js` that `--ssr` creates).
|
|
118
|
+
- The bundle is imported once at start. **A missing bundle or one without `render` fails the start.**
|
|
119
|
+
- When render throws during a request, returns the wrong shape, or the body has no `id="<rootId>"`: `fallback` answers that request with the client-rendered page and logs `INERTIA_SSR_FAILED` (error); `error` fails the request (500). The log's `error` is the name, full message, stack and `Caused by:` causes; only URL credentials are removed as `//***@`, and it is cut above 16 KB. The same bundle and component is logged once per 10 seconds and the count in between is `repeated` in the next record (D-0047). `ssrStats().failures` counts all of them.
|
|
120
|
+
- When the head has a `<title>`, the configured title is left out. Inertia navigation (JSON) never goes through SSR.
|
|
121
|
+
- The browser entry hydrates with `createSSRApp` when `el.hasAttribute('data-server-rendered')`. So that server and browser pick the same layout, use the `layout` option instead of changing the page module (several templates render at the same time).
|
|
122
|
+
- `ssrStats()` → `{ renders, failures }` (`InertiaSsrStats`, for health checks and dashboards). Turn SSR off per app with `scope({ ssr: false })`.
|
|
123
|
+
|
|
124
|
+
## 5.1 Development mode (HMR, D-0045)
|
|
125
|
+
|
|
126
|
+
- 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.
|
|
130
|
+
- 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`).
|
|
132
|
+
- 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.
|
|
134
|
+
- Cluster apps run one worker under `dokebi dev` (@dokebi/cluster); `DOKEBI_DEV_WORKERS` asks for more, which also works on a cold cache.
|
|
135
|
+
- 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
|
+
- Production (no `DOKEBI_VITE_DEV`) is unaffected. Never set this variable on a production server.
|
|
137
|
+
- 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`.
|
|
139
|
+
|
|
140
|
+
## 6. Scopes
|
|
141
|
+
|
|
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.
|
|
143
|
+
|
|
144
|
+
## 6a. View engine contribution and errors (D-0063)
|
|
145
|
+
|
|
146
|
+
- This plugin contributes `{ name: 'inertia', scope(options) }` to @dokebi/web's point `dokebi-web:view-engine`. @dokebi/app finds it by name (`defineApp({ inertia })`). `scope` is the same as the plugin API's `scope`.
|
|
147
|
+
- The options are a `defineOptions` declaration in `src/options.ts`; the build writes `options.schema.json`. Invalid options are core `INVALID_PLUGIN_OPTIONS` (cause `INVALID_OPTIONS` with paths such as `vite.entry` or `scripts[0]` in `details.issues`). `vite` together with `version`, `scripts` or `styles` is refused.
|
|
148
|
+
- Every error carries `plugin: 'dokebi-template-inertia'` and a `fix`. Bad arguments are still a `TypeError` with the Dokebi error shape (`InertiaUsageError`). web answers them according to D-0063 §1 (with `fix` in development).
|
|
149
|
+
|
|
150
|
+
## 6b. Errors
|
|
151
|
+
|
|
152
|
+
<!-- docs-check: complete-error-codes InertiaErrorCode -->
|
|
153
|
+
<!-- docs-check: complete-error-codes InertiaUsageErrorCode -->
|
|
154
|
+
| Code | Class | When |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `SSR_BUNDLE_MISSING` | `InertiaError` | the `ssr.bundle` file does not exist (start failure) |
|
|
157
|
+
| `SSR_BUNDLE_INVALID` | `InertiaError` | the bundle is not a file or does not export `render(page)` |
|
|
158
|
+
| `SSR_ENTRY_MISSING` | `InertiaError` | development-mode SSR cannot find the source module (`ssr.entry`) |
|
|
159
|
+
| `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 |
|
|
161
|
+
| `VITE_DEV_MISSING` | `InertiaError` | `DOKEBI_VITE_DEV=1` but the project has no vite |
|
|
162
|
+
| `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
|
+
| `SEAL_SSR_CONFLICT` | `InertiaError` | SSR in a seal-host scope or for a sealed request (D-0062) |
|
|
164
|
+
| `METHOD_NOT_ALLOWED` | `InertiaError` | `render` for a request that is not GET or HEAD |
|
|
165
|
+
| `CLOSED` | `InertiaError` | `render` after the app stopped |
|
|
166
|
+
| `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) |
|
|
168
|
+
| `INVALID_RENDER` | `InertiaUsageError` | bad component name or render options |
|
|
169
|
+
| `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 |
|
|
171
|
+
| `INVALID_PROP_HELPER` | `InertiaUsageError` | bad arguments to `defer`, `merge` or `once` |
|
|
172
|
+
| `SSR_INVALID_RESULT` | `InertiaUsageError` | the SSR `render` did not return `{ head, body }` or the body has no root element |
|
|
173
|
+
|
|
174
|
+
## 7. Cautions
|
|
175
|
+
|
|
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.
|
|
178
|
+
- Always use a per-build version (or `vite`) in deployments. With the default version, clients do not notice changed assets.
|
|
179
|
+
- 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
|
+
- A new plugin per app instance. The responder closes after the whole drain.
|
|
181
|
+
|
|
182
|
+
## 8. Seal hosts and browser history (D-0062, 0.2.0)
|
|
183
|
+
|
|
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`.
|
|
185
|
+
- **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.
|
|
186
|
+
- 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
|
+
- 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
|
+
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
# @dokebi/template-inertia 0.
|
|
1
|
+
# @dokebi/template-inertia 0.4.1
|
|
2
2
|
|
|
3
|
-
Node >=24.0.0
|
|
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
|
|
|
5
|
-
|
|
5
|
+
It follows plugin contract v2 ([D-0028](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0028-plugin-contract-v2.md)): `createPlugin({ app: app.view, options?, entry? })`, where `app` is required. The old `web`, `dependencies` and `shared` inputs are gone. web is connected through a `providerDependency`: when `dokebi-web` is registered in the same app that instance is used, otherwise it is created from `@dokebi/web`. Per-request shared props go through `entry.shared`.
|
|
6
6
|
|
|
7
|
-
```ts
|
|
7
|
+
```ts snippet
|
|
8
8
|
import { createPlugin as createInertia } from '@dokebi/template-inertia';
|
|
9
9
|
import { createPlugin as createWeb } from '@dokebi/web';
|
|
10
|
-
const web = createWeb({ app: app.view }); //
|
|
10
|
+
const web = createWeb({ app: app.view }); // register first when you read the address or add routes directly
|
|
11
11
|
app.register(web);
|
|
12
12
|
const pages = createInertia({
|
|
13
13
|
app: app.view,
|
|
14
14
|
options: { version: 'build-hash', scripts: ['/assets/app.js'], styles: ['/assets/app.css'] },
|
|
15
15
|
entry: { shared: (request) => ({ user: String(request.headers['x-user'] ?? 'guest') }) },
|
|
16
16
|
});
|
|
17
|
-
//
|
|
17
|
+
// Inside the setup of a business plugin that declares web and pages as dependencies:
|
|
18
18
|
// context.get(web).route({ method: 'GET', url: '/', handler: (req, reply) =>
|
|
19
19
|
// context.get(pages).render(req, reply, 'Home', { counter: { value: 0 } }) });
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
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).`.
|
|
23
23
|
|
|
24
|
-
|
|
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. 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:
|
|
@@ -29,59 +29,69 @@ dokebi-template-inertia:
|
|
|
29
29
|
vite: { manifest: dist/.vite/manifest.json, entry: src/apps/shop/views/app.js, base: /build/ }
|
|
30
30
|
```
|
|
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
|
|
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.
|
|
35
35
|
|
|
36
|
-
`redirect(request, reply, localUrl)
|
|
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
|
-
Vue
|
|
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.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
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
41
|
|
|
42
|
-
##
|
|
42
|
+
## Install
|
|
43
43
|
|
|
44
|
-
`dokebi add
|
|
44
|
+
`dokebi add` uses a static install declaration to create the entry file `src/infra/inertia/shared.ts` (`export async function shared(_request) { return {}; }`), and the generated code passes it as `entry`. There is no install module (installModule). web is installed alongside as a manifest dependency and checked for order.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Supported: the initial HTML, page navigation, shared/lazy/partial props, deferred/merge/once props (see below), redirects, version negotiation and server rendering (see SSR below). Precognition, `scrollProps` and `sharedProps` are not provided. Session flash is delivered in the page's `flash` field when [@dokebi/session](https://git.nyx-zone.com/dokebi/framework/-/blob/main/plugins/dokebi-session/README.md) is registered ([D-0055](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0055-session-store.md), read once). Vue persistent layouts are the Vue client's job.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
## Forms and validation
|
|
49
49
|
|
|
50
|
-
|
|
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 (0.4.1, shared with @dokebi/app's HTML forms), so this version needs @dokebi/web ^0.4.1.
|
|
51
51
|
|
|
52
|
-
`
|
|
52
|
+
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
53
|
|
|
54
|
-
|
|
54
|
+
The option `errorPage` (for example `Error`, per app in `defineApp({ inertia: { errorPage } })`) renders that page component with `{ status, error, message, hint }` for the other errors of Inertia visits and page loads (403, 404, 500 …). Without it a visit gets the JSON error and a page load @dokebi/web's small HTML page. Details: [AI.md](AI.md) §3.3.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
[Example 24](https://git.nyx-zone.com/dokebi/framework/-/blob/main/examples/24-template-inertia/README.md) has a real Vue 3 + Inertia + REST app, authentication and CSRF fixtures and automatic Chrome checks. `npm test -w @dokebi/template-inertia` is the HTTP protocol regression suite.
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
- `merge(value | fn, { mode?, matchOn? })`·`deepMerge(value | fn, { matchOn? })`: `mergeProps`(append)·`prependProps`·`deepMergeProps`, `matchOn`은 `matchPropsOn: ["<prop>.<필드>"]`. 클라이언트는 같은 컴포넌트의 partial reload에서만 합친다. `X-Inertia-Reset`에 든 prop은 알리지 않는다.
|
|
60
|
-
- `once(value | fn, { key?, ttlMs? })`: `onceProps: { [key]: { prop, expiresAt } }`(`expiresAt`은 ms, 없으면 null). Inertia 방문의 `X-Inertia-Except-Once-Props`에 key가 있으면 계산하지 않는다(알림은 남겨 클라이언트가 가진 값을 옮긴다). partial reload가 요청하면 다시 계산한다.
|
|
61
|
-
- partial reload는 `only`·`except`로 고른 것만 계산하며 deferred·once도 같다. 알림 필드도 고른 props만 적는다.
|
|
62
|
-
- 오류: rescue가 아닌 함수의 실패는 예전처럼 render 실패(web이 500·전체 오류 로그). debug 로그 `Inertia props resolved`에 계산한 함수 prop·뺀 group·건너뛴 once·rescue가 나온다.
|
|
58
|
+
## Document settings per scope (D-0030)
|
|
63
59
|
|
|
64
|
-
|
|
60
|
+
`scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared? })` returns a renderer with its own document settings and extra shared props (async; with `vite` it reads the manifest). Unset values follow the plugin options. `shared` is merged after the plugin's shared props. @dokebi/app uses it for each app's own Vite entry.
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
## deferred, merge and once props ([D-0052](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0052-inertia-deferred-merge-once.md))
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
- 번들이 없거나 `render`가 없으면 **시작이 실패한다.** SSR을 켰는데 조용히 CSR이 되는 일을 막는다.
|
|
70
|
-
- 요청 중 `render`가 던지거나, 모양이 틀리거나(`head` 문자열 배열·`body` 문자열), `body`에 `id="<rootId>"`가 없으면 `failure`를 따른다. `fallback`(기본)은 그 요청을 CSR 페이지로 답하고 `INERTIA_SSR_FAILED` 오류 로그를 남긴다. `error`는 요청을 실패시킨다(개발 중 바로 보기).
|
|
71
|
-
- `head`는 `</head>` 앞에 넣는다. 그 안에 `<title>`이 있으면(페이지의 `<Head>`) 설정한 `title`은 빠진다. `body`는 기본 `<script data-page>`·빈 루트 대신 들어간다.
|
|
72
|
-
- 브라우저 진입점은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다.
|
|
73
|
-
- `ssrStats()`는 시작 뒤 렌더 수와 실패 수다(health·대시보드용).
|
|
74
|
-
- `scope({ ssr })`: 앱마다 번들을 정한다. 생략하면 플러그인 옵션을 따르고 `false`면 끈다. 같은 파일은 한 번만 import한다.
|
|
64
|
+
Import `defer`, `merge`, `deepMerge` and `once` and use them as prop values (render props and shared props). With only plain values and lazy functions the page object is the same as before.
|
|
75
65
|
|
|
76
|
-
|
|
66
|
+
- `defer(fn, group?)` or `defer(fn, { group?, merge?, once?, rescue? })`: not computed on the first load (HTML or Inertia visit, SSR included) and announced as `deferredProps: { [group]: [prop] }` (default group `default`). After showing the page, the client fetches each group with one partial reload. With `rescue: true`, when the function throws only that prop is left out and listed in `rescuedProps`, with an `INERTIA_PROP_RESCUED` error log.
|
|
67
|
+
- `merge(value | fn, { mode?, matchOn? })` and `deepMerge(value | fn, { matchOn? })`: `mergeProps` (append), `prependProps`, `deepMergeProps`; `matchOn` becomes `matchPropsOn: ["<prop>.<field>"]`. The client merges only on partial reloads of the same component. Props listed in `X-Inertia-Reset` are not announced.
|
|
68
|
+
- `once(value | fn, { key?, ttlMs? })`: `onceProps: { [key]: { prop, expiresAt } }` (`expiresAt` in ms, or null). When an Inertia visit's `X-Inertia-Except-Once-Props` lists the key, the value is not computed (the announcement stays so the client carries its value over). A partial reload that asks for it recomputes it.
|
|
69
|
+
- Partial reloads compute only what `only` and `except` select, deferred and once props included. The announcement fields list only the selected props.
|
|
70
|
+
- Errors: a failing function that is not rescue fails the render as before (web answers 500 and logs the full error). The debug log `Inertia props resolved` lists the computed function props, the left-out groups, the skipped once props and the rescues.
|
|
77
71
|
|
|
78
|
-
##
|
|
72
|
+
## Server rendering (SSR, D-0039)
|
|
79
73
|
|
|
80
|
-
`
|
|
74
|
+
With `ssr: { bundle, failure? }` the first HTML of a page is rendered on the server. There is no separate SSR process: the SSR build output is imported once at start in the same process, and its `render(page)` is called for every full-page request. Inertia navigation (`X-Inertia`) stays JSON and never goes through SSR.
|
|
81
75
|
|
|
82
|
-
|
|
76
|
+
- `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
|
+
- 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).
|
|
79
|
+
- `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
|
+
- The browser entry hydrates with `createSSRApp` when `el.hasAttribute('data-server-rendered')`.
|
|
81
|
+
- `ssrStats()` is the number of renders and failures since start (for health checks and dashboards).
|
|
82
|
+
- `scope({ ssr })`: a bundle per app. Without it the plugin option applies; `false` turns it off. The same file is imported once.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
Measured (example 24, same process, 400 sequential requests): CSR 0.20–0.24 ms, SSR 0.24–0.28 ms (a small page), heap +1.1 MB after 450 SSR pages.
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
## Development mode (HMR, [D-0045](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0045-inertia-vite-dev.md))
|
|
87
|
+
|
|
88
|
+
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
|
+
|
|
90
|
+
## Seal hosts and history (D-0062, 0.2.0)
|
|
91
|
+
|
|
92
|
+
[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
|
+
|
|
94
|
+
- `encryptHistory: true | false | 'sealed'` (option and `scope`, default `'sealed'`) and `render(..., props, { encryptHistory, clearHistory })`.
|
|
95
|
+
- 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
|
+
|
|
97
|
+
See the [AI guide](AI.md) for error codes and development-mode details.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DokebiError, type DokebiErrorOptions } from '@dokebi/core';
|
|
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' | 'VITE_MANIFEST_MISSING' | 'VITE_MANIFEST_INVALID' | 'VITE_DEV_MISSING' | 'VITE_DEV_UNAVAILABLE' | 'SEAL_SSR_CONFLICT' | 'METHOD_NOT_ALLOWED' | 'CLOSED';
|
|
4
|
+
/** Codes of `InertiaUsageError` (a wrong argument: a TypeError, as before 0.4.0). */
|
|
5
|
+
export type InertiaUsageErrorCode = 'INVALID_ENTRY' | 'INVALID_SCOPE' | 'INVALID_RENDER' | 'INVALID_PROPS' | 'INVALID_URL' | 'INVALID_PROP_HELPER' | 'SSR_INVALID_RESULT';
|
|
6
|
+
/** Failures of @dokebi/template-inertia; compare `code`, not the class. */
|
|
7
|
+
export declare class InertiaError extends DokebiError<InertiaErrorCode> {
|
|
8
|
+
constructor(code: InertiaErrorCode, message: string, options?: DokebiErrorOptions);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A wrong argument (TypeError, as these were before 0.4.0) with the Dokebi error fields
|
|
12
|
+
* (`code`, `plugin`, `fix`): `isDokebiError` accepts it without inheritance (D-0063 §1).
|
|
13
|
+
*/
|
|
14
|
+
export declare class InertiaUsageError extends TypeError {
|
|
15
|
+
readonly code: InertiaUsageErrorCode;
|
|
16
|
+
readonly plugin = "dokebi-template-inertia";
|
|
17
|
+
readonly fix: string;
|
|
18
|
+
constructor(code: InertiaUsageErrorCode, message: string);
|
|
19
|
+
}
|