@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 CHANGED
@@ -1,133 +1,189 @@
1
- # AI 작업 안내 — @dokebi/template-inertia 0.2.0
1
+ # AI guide — @dokebi/template-inertia 0.4.1
2
2
 
3
- Inertia(v3) 서버 어댑터다. 첫 요청은 HTML(+ `script[data-page]` JSON), 이후 `X-Inertia` 요청은 page JSON으로 답한다. Vite manifest를 읽어 스크립트·스타일·버전을 채우고 빌드 파일을 직접 내보낸다. 선택적으로 첫 HTML을 같은 프로세스에서 서버 렌더링(SSR, D-0039)한다. Vue·Vite는 서버 의존성이 아니다(앱의 개발 의존성). Node >=24.0.0, core ^0.2.0, @dokebi/web(peer). 세부는 [README](README.md)다.
3
+ An Inertia (v3) server adapter. The first request is answered with HTML (plus `script[data-page]` JSON), later `X-Inertia` requests with page JSON. It reads the Vite manifest to fill in scripts, styles and the version, and serves the build files itself. Optionally it server-renders the first HTML in the same process (SSR, D-0039). Vue and Vite are not server dependencies (they are the app's dev dependencies). Node >=24.0.0, core ^0.4.0, @dokebi/web ^0.4.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(공통 props)
9
- npx dokebi app add admin --inertia [--ssr] --domain admin.a.com # @dokebi/app 앱 + Vite 빌드 준비
10
- npm run build # dist/(브라우저) + dist/ssr/(서버, --ssr)
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
- - `src/infra/inertia/shared.ts`의 `export async function shared(request)`가 모든 페이지 props에 합쳐진다(호출 props가 우선).
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
- ## 2. 설정 `options.dokebi-template-inertia`(앱마다는 `scope`로 덮어씀)
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
- ## 3. 페이지 렌더
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
- ```ts
30
- // @dokebi/app 앱 라우트: page.inertia가 앱 설정을 쓰는 렌더러
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'); // mutation은 303
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에서만. 다섯째 인수는 이 응답의 history 설정(없으면 범위 설정과 세션 신호). 핸들러에서 반환·await. component는 `/`로 구분된 `[A-Za-z0-9][A-Za-z0-9_-]*` 조각.
44
- - props: JSON 값, 비동기 lazy 함수, 또는 §3.1 도우미. partial reload(`only`·`except`)는 선택된 것만 평가하고 `errors`는 항상 포함. 비JSON 값·순환은 실패.
45
- - `redirect(request, reply, localUrl)`: GET/HEAD 302, mutation 303, Inertia의 `#` 이동은 409 + `X-Inertia-Redirect`.
46
- - `location(request, reply, url)`: 로컬 또는 http(s) 외부 이동. Inertia 요청은 409 + `X-Inertia-Location`.
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·merge·once props(D-0052)
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' }), // partial reload 때 배열 뒤에 붙임(같은 id는 교체)
54
- stats: defer(() => computeStats()), // 첫 페이지에서 빼고 화면 뒤 group 'default'로 가져옴
55
- chart: defer(() => chart(), 'charts'), // group마다 요청 하나
56
- feed: defer(() => feed(), { merge: true, rescue: true }), // 던지면 빼고 rescuedProps + error 로그
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? })`: 첫 로드(HTML·Inertia 방문)에서 계산하지 않고 page `deferredProps: { group: [이름] }`. 클라이언트가 `only`로 다시 요청할 때만 `fn`이 돈다. SSR 첫 HTML에도 값이 없다(Vue `<Deferred data="stats">`의 `#fallback`).
63
- - `merge(value | fn, { mode?: 'append' | 'prepend' | 'deep', matchOn? })`, `deepMerge(value | fn, { matchOn? })`: page `mergeProps`·`prependProps`·`deepMergeProps`·`matchPropsOn`(`"<prop>.<필드>"`). 클라이언트는 **partial reload에서만** 합친다(`router.reload({ only: ['orders'], data: { page: 2 } })`). `X-Inertia-Reset`(`reset: ['orders']`)이면 합치지 않고 바꾼다.
64
- - `once(value | fn, { key?, ttlMs? })`: page `onceProps: { key: { prop, expiresAt } }`. 클라이언트가 `X-Inertia-Except-Once-Props`로 가진 key를 알리면 첫 로드에서 계산하지 않는다. partial reload가 그 prop을 `only`로 요청하면 다시 계산한다.
65
- - 도우미는 `render` props·`shared` props 어디든 쓴다. 서로 감싸지 않는다(조합은 `defer` 옵션). group·key·matchOn에 쉼표·공백 금지.
66
- - partial reload는 지금처럼 `only`·`except`로 고르고, 고른 deferred·once도 계산한다(`except`만 주면 deferred도 포함). 알림 필드도 고른 props만.
67
- - rescue가 아닌 prop 함수의 실패는 지금처럼 요청 실패(500, web이 전체 오류 로그). rescue는 `INERTIA_PROP_RESCUED`(error, 10초에 한 번·`repeated`). debug: `Inertia props resolved`(evaluated·deferred·heldOnce·rescued), `Inertia prop failed`.
68
- - 오래된 version의 Inertia 요청은 props 평가 없이 409 + `X-Inertia-Location`(새로고침 유도).
69
- - 응답에 `Vary: X-Inertia`, `Cache-Control: no-store`가 붙고 HTML에 넣는 JSON·제목·URL은 escape된다.
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 session flash(D-0055)
76
+ ### 3.2 Session flash (D-0055)
72
77
 
73
- @dokebi/session이 앱에 등록돼 있으면(선택 의존성, 자동으로 만들지 않음) 페이지 응답의 page **`flash` 필드**에 세션 flash가 들어간다. `takeFlash()`와 같아서 한 번 보이면 사라진다. 비어 있으면 필드가 없다. prop이 아니다(`props.flash` 아님).
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, 또는 router.on('flash', (event) => toast(event.detail.flash))
86
+ // Vue: usePage().flash.success, or router.on('flash', (event) => toast(event.detail.flash))
82
87
  ```
83
88
 
84
- - 클라이언트(3.9.1)는 flash를 history에 넣지 않아 뒤로 가기에 다시 보이지 않는다.
85
- - deferred props만 가져오는 partial reload는 flash를 읽지 않는다(클라이언트가 버리므로). 첫 HTML·방문·그 밖의 partial reload는 읽는다. props 평가가 실패한 렌더는 flash를 남긴다.
86
- - 세션 플러그인이 없으면 page는 전과 같다.
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
- ## 5.1 개발 모드(HMR, D-0045)
93
+ ### 3.3 Forms and validation (D-0074)
102
94
 
103
- - 환경 변수 `DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 manifest·빌드 없이 **프로젝트의 Vite를 미들웨어 모드로** 한 프로세스에 하나 띄운다. 페이지 스크립트는 `<base>@vite/client`와 `<base><vite.entry>`, 스타일 링크 없음, version `dev`. `<base>*` 요청은 Vite가 답한다.
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
- ## 6. 범위(scope)
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
- `await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared? })` → 그 설정의 렌더러. 정하지 않은 값은 플러그인 옵션, `shared`는 플러그인 shared 뒤에 합쳐진다. @dokebi/app이 앱마다 부른다.
117
-
118
- ## 7. 주의 사항
119
-
120
- - props에 비밀·개인정보를 넣지 않는다(HTML에 그대로 실린다). 인증·인가·CSRF는 앱이 구현한다.
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
- - seal 호스트(@dokebi/app `domains.<host>.seal: true`)의 요청이면 첫 HTML의 `data-page`가 봉인 프레임 하나(`data-sealed="true"`, `E:<ts>:<b64>`)이고, 방문(JSON) 응답은 dokebi-web이 봉인한다. 판단은 dokebi-web `sealOf(request)`. 브라우저는 `views/app.js`에서 `createInertiaApp` 전에 `await sealInertia(http)`(`@dokebi/seal/http`; CLI 템플릿은 `data-sealed`일 때만 `/dokebi/seal/http.mjs`를 동적 import)로 연다.
131
- - **페이지 모듈 맨 위에서 `data-page`를 읽지 않는다**: `import.meta.glob(..., { eager: true })`로 들어온 페이지·모듈은 `sealInertia`보다 먼저 실행되므로 아직 봉인된 글을 JSON으로 읽다 앱이 멈춘다. `usePage()`나 연결할 때 읽는다.
132
- - SSR과 함께 쓸 수 없다: `scope({ sealed: true, ssr })`는 시작 실패 `SEAL_SSR_CONFLICT`(dokebi-app이 seal 도메인 앱에 `sealed`를 넘긴다), SSR 범위에 봉인 요청이 오면 그 요청이 `SEAL_SSR_CONFLICT`.
133
- - `encryptHistory`(기본 `'sealed'`)가 켜진 페이지는 브라우저 기록에 암호화돼 저장된다(보안 문맥에서만: HTTPS·`*.localhost`). `clearHistory`는 @dokebi/session 0.2의 `historyReset()`(이 요청에서 `destroy`·`regenerate`)이면 그 페이지에, 페이지를 그리지 않는 요청(로그인 POST → 303)이면 쿠키 `dokebi.history-reset`(10분)으로 **다음 페이지**에 실린다. 로그아웃 뒤 「뒤로」는 예전 props를 보이지 못하고 서버에서 다시 받는다.
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.2.0
1
+ # @dokebi/template-inertia 0.4.1
2
2
 
3
- Node >=24.0.0, core ^0.2.0, 선택 패키지다(npm 공개). ID는 `dokebi-template-inertia`이며 `dokebi-web`에 의존한다. `@dokebi/web`은 peer 의존성이다(예전의 고정 버전 dependency가 아니다). EJS·Vue·Vite를 서버 패키지의 필수 의존성으로 설치하지 않는다.
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
- 플러그인 계약 v2([D-0028](../../docs/decisions/D-0028-plugin-contract-v2.md))를 따른다. `createPlugin({ app: app.view, options?, entry? })`이며 `app`은 필수다. 예전 `web`·`dependencies`·`shared` 입력은 없다. web은 `providerDependency`로 연결한다: 같은 앱에 `dokebi-web`이 등록되어 있으면 그것을 쓰고, 없으면 `@dokebi/web`에서 만든다. 요청별 공통 props는 `entry.shared`로 넘긴다.
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
- // 업무 플러그인이 web과 pages를 의존성으로 선언한 setup 안에서:
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
- 위 x-user는 API 예시이며 인증 수단이 아니다. 앱의 인증 결과에서 사용자 props를 만든다. `render(request, reply, component, props?)`는 GET/HEAD에서만 호출하며 handler에서 반드시 반환/await한다. 초기 접근은 HTML과 JSON page script, 이후 X-Inertia 요청은 page JSON으로 응답한다. Inertia v3의 `script[data-page="app"]` 형식이다. `rootId` 기본 app, title 기본 빈 문자열, version 기본 development다. version을 설정하지 않으면 setup에서 한 번 warn 로그(`code: INERTIA_VERSION_DEFAULT`)를 남긴다. 그 상태에서는 자산이 바뀌어도 클라이언트가 감지하지 못하므로 실제 배포에서는 빌드별 version과 해당 manifest의 scripts/styles를 지정한다. Vite 빌드를 쓰면 `vite: { manifest, entry, base? }`로 대신 지정할 수 있다. setup에서 manifest(`.vite/manifest.json`, 작업 폴더 기준 상대 또는 절대 경로, 최대 4MiB)를 한 번 읽어 entry의 스크립트, entry와 import한 청크의 CSS, manifest 내용 해시(SHA-256 앞 32자) version을 채운다. `base`(기본 `/`)는 빌드 파일의 URL 접두사다. `vite`와 `version`·`scripts`·`styles`를 함께 쓰면 거부한다. entry가 manifest에 없으면 시작이 실패하고 오류에 entry 이름이 나온다([ISSUE-0005](../../docs/issues/ISSUE-0005-playground-findings.md) PG-07). manifest 파일이 없으면 `Vite manifest not found: <경로>. Build the views first (npm run build).`로 실패한다.
22
+ The x-user header above only illustrates the API; it is not authentication. Build user props from the app's authentication result. Call `render(request, reply, component, props?)` only for GET/HEAD and always return or await it in the handler. The first visit is answered with HTML and a JSON page script, later X-Inertia requests with page JSON, in Inertia v3's `script[data-page="app"]` format. `rootId` defaults to app, title to an empty string and version to development. Without a configured version a warn log (`code: INERTIA_VERSION_DEFAULT`) is written 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
- 빌드 파일은 이 플러그인이 dokebi-web으로 직접 내보낸다([D-0031](../../docs/decisions/D-0031-project-layout.md) 보완 1). entry에서 닿는 파일만 대상이다. 정적·동적 import 청크, CSS, assets가 여기에 들어간다. 각 파일은 `<base><파일>`의 정확한 경로에 GET 경로로 등록되고, 와일드카드 경로는 쓰지 않는다. 그래서 manifest 자체나 목록에 없는 파일은 내보내지 않는다. 앱 여럿이 같은 청크를 쓰면 한 번만 등록한다. 파일 위치는 manifest가 `<outDir>/.vite/manifest.json`이면 outDir이고, 그렇지 않으면 manifest가 있는 폴더다. 응답에는 확장자별 content-type과 `cache-control: public, max-age=31536000, immutable`이 붙는다. 이름에 해시가 든 빌드 파일을 전제로 하기 때문이다. CDN이나 다른 정적 서버로 낼 때도 같은 경로에 두면 된다.
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
- 잘못된 옵션은 팩토리에서 `INVALID_PLUGIN_OPTIONS`다. asset URL은 `/`로 시작하는 로컬 절대 URL만 허용하며 파일 서빙은 앱/정적 서버 책임이다.
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는 JSON 값, 비동기 lazy 함수 또는 deferred/merge/once 도우미(아래 절)다. shared(request)와 호출 props를 요청마다 합치고 호출 props가 우선한다. 동일 component의 partial-data/partial-except는 선택 props만 평가한다(except 우선). 선택된 lazy 함수는 `Promise.all`로 동시에 평가하고 결과 객체의 키 순서는 원래 순서를 유지한다. errors는 항상 포함한다. 비JSON 값·순환은 성공 응답 대신 실패한다. HTML에 넣는 JSON/제목/asset 속성을 escape하며 Vary: X-Inertia와 Cache-Control: no-store를 설정한다. 오래된 version은 props 평가 없이409 + X-Inertia-Location/Version을 반환한다.
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)`은 GET/HEAD302, mutation303이며 Inertia fragment 이동은409 + X-Inertia-Redirect다. `location(request, reply, url)`은 로컬 또는 HTTP(S) 이동으로 Inertia409 + X-Inertia-Location, 일반 요청302다. 외부 목적지는 신뢰된 앱 코드에서 선택한다.
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 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('/api/...')` REST로 구분한다. API 라우트는 JSON·상태 코드를 직접 반환하며 Inertia 응답으로 포장하지 않는다. 페이지/API의 업무 서비스와 인증·인가를 공유하고 쿠키 인증 mutation의 CSRF를 앱에서 검증한다. 서버별 상태 공유가 필요하면 선택 DB/Redis를 연결한다.
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
- 공통 Template 기반 클래스는 없다. EJS 문자열 엔진과 HTTP 응답 어댑터를 독립 클래스로 조합한다. 전체 요청 drain 뒤 responder를 닫으며 Application마다 새 인스턴스를 만든다. 옵션은 `options`가 없으면 app.config의 dokebi-template-inertia 값을 사용한다.
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`는 정적 설치 선언으로 진입 파일 `src/infra/inertia/shared.ts`(`export async function shared(_request) { return {}; }`)를 만들고 생성 코드가 이를 `entry`로 넘긴다. 설치 모듈(installModule)은 없다. web은 manifest 의존성으로 함께 설치·순서 검사된다.
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
- 지원 범위는 초기 HTML·페이지 이동·shared/lazy/partial props·deferred/merge/once props(아래 절)·redirect·version 협상·서버 렌더링(아래 SSR 절)이다. Precognition, Inertia form 검증 redirect 저장소, `scrollProps`·`sharedProps`는 제공하지 않는다. session flash는 [@dokebi/session](../dokebi-session/README.md)이 등록돼 있을 때 page `flash` 필드로 전달된다([D-0055](../../docs/decisions/D-0055-session-store.md), 한 번 읽기). REST422 오류 처리는 예제에 구현되어 있다. Vue persistent layout은 Vue 클라이언트 책임이다.
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
- [예제24](../../examples/24-template-inertia/README.md)에 실제 Vue3 + Inertia + REST, 인증/CSRF fixture와 Chrome 자동 검증이 있다. `npm test -w @dokebi/template-inertia`는 HTTP 프로토콜 회귀다.
48
+ ## Forms and validation
49
49
 
50
- ## 범위별 문서 설정 (D-0030)
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
- `scope({ title?, rootId?, version?, scripts?, styles?, vite?, shared? })`는 자기 문서 설정과 추가 공통 props를 가진 렌더러를 돌려준다(비동기, `vite`면 manifest를 읽는다). 정하지 않은 값은 플러그인 옵션을 따른다. `shared`는 플러그인 공통 props 뒤에 합쳐진다. @dokebi/app이 앱마다 다른 Vite 진입점에 쓴다.
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
- ## deferred·merge·once props([D-0052](../../docs/decisions/D-0052-inertia-deferred-merge-once.md))
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
- `defer`·`merge`·`deepMerge`·`once`를 import해 prop 값 자리(render props·shared props)에 쓴다. 일반 값·lazy 함수만 쓰면 page 객체는 예전과 같다.
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
- - `defer(fn, group?)` 또는 `defer(fn, { group?, merge?, once?, rescue? })`: 첫 로드(HTML·Inertia 방문, SSR 포함)에서 계산하지 않고 `deferredProps: { [group]: [prop] }`로 알린다(기본 group `default`). 클라이언트는 화면을 보인 뒤 group마다 partial reload 한 번으로 가져온다. `rescue: true`면 함수가 던질 때 그 prop만 빼고 `rescuedProps`에 넣으며 `INERTIA_PROP_RESCUED` 오류 로그를 남긴다.
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
- ## 서버 렌더링(SSR, D-0039)
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
- `ssr: { bundle, failure? }`를 주면 페이지의 첫 HTML을 서버에서 그린다. 별도 SSR 프로세스 없이 같은 프로세스에서 SSR 빌드 산출물을 시작 때 한 번 import하고, 전체 페이지 요청마다 그 `render(page)`를 부른다. Inertia 이동(`X-Inertia`)은 지금처럼 JSON이고 SSR을 거치지 않는다.
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
- - `bundle`: `render(page) → Promise<{ head: string[]; body: string }>`를 export하는 빌드 파일(작업 폴더 기준 또는 절대 경로). Inertia v3의 `createInertiaApp`은 서버에서 `page` 없이 부르면 `(page, renderToString)` 함수를 돌려주므로 `export const render = async (page) => (await app)(page, renderToString)`이면 된다(예제 24의 `client/ssr.js`).
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
- 측정(예제 24, 같은 프로세스, 순차 400회): CSR 0.20~0.24ms, SSR 0.24~0.28ms(작은 페이지), SSR 450장 뒤 heap +1.1MB.
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
- ## 개발 모드(HMR, [D-0045](../../docs/decisions/D-0045-inertia-vite-dev.md))
72
+ ## Server rendering (SSR, D-0039)
79
73
 
80
- `DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 빌드 없이 프로젝트의 Vite를 미들웨어 모드로 띄워 `<base>*`를 Vite가 답하고, 페이지는 `@vite/client`와 원본 진입을 불러온다. `.vue`를 고치면 새로고침 없이 바뀐다(HMR 소켓은 프로세스마다 따로 잡은 빈 포트). SSR은 요청마다 Vite로 원본(`ssr.entry`, 없으면 `vite.entry` 옆 `ssr.js`)을 그린다. 운영에는 영향이 없다.
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
- ## 봉인 호스트와 history(D-0062, 0.2.0)
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
- [D-0062](../../docs/decisions/D-0062-http-seal.md). 봉인 요청(dokebi-web `sealOf`)의 첫 HTML은 `<script data-page="app" type="application/json" data-sealed="true">E:…</script>`만 싣고(제목은 설정값), 방문 JSON은 dokebi-web이 봉인한다. 브라우저는 `@dokebi/seal/http`의 `sealInertia(http)`로 연다. SSR과 함께 쓰면 `SEAL_SSR_CONFLICT`.
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
- - `encryptHistory: true | false | 'sealed'`(옵션·`scope`, 기본 `'sealed'`)와 `render(..., props, { encryptHistory, clearHistory })`.
87
- - 로그인·로그아웃(@dokebi/session 0.2 `historyReset()`) 다음 페이지가 `clearHistory: true`를 싣는다. 페이지를 그리지 않은 요청에서 일어났으면 쿠키 `dokebi.history-reset`(Path=/, 10분, HttpOnly, SameSite=Lax, https면 Secure)이 다음 페이지까지 들고 가고 그 페이지가 지운다.
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.
@@ -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
+ }