@dokebi/template-inertia 0.1.2 → 0.2.0

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,4 +1,4 @@
1
- # AI 작업 안내 — @dokebi/template-inertia 0.1.2
1
+ # AI 작업 안내 — @dokebi/template-inertia 0.2.0
2
2
 
3
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)다.
4
4
 
@@ -22,6 +22,7 @@ npm run build # dist/(브라우저) + dist
22
22
  | `version` · `scripts` · `styles` | 자산 버전과 URL(`/`로 시작하는 로컬 URL). 없으면 `INERTIA_VERSION_DEFAULT` 경고 |
23
23
  | `vite: { manifest, entry, base? }` | manifest에서 scripts·styles·version 계산. 위 셋과 함께 쓰면 거부 |
24
24
  | `ssr: { bundle, failure?, entry? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error`. `entry`는 개발 모드에서 쓰는 번들의 원본 모듈(§5.1) |
25
+ | `encryptHistory` | Inertia history 암호화: `true`·`false`·`'sealed'`(기본: 봉인 요청이면 켬, D-0062) |
25
26
 
26
27
  ## 3. 페이지 렌더
27
28
 
@@ -39,13 +40,51 @@ route.post('/orders', async (request, reply, page) => {
39
40
  });
40
41
  ```
41
42
 
42
- - `render(request, reply, component, props?)`: GET/HEAD에서만. 핸들러에서 반환·await. component는 `/`로 구분된 `[A-Za-z0-9][A-Za-z0-9_-]*` 조각.
43
- - props: JSON 값 또는 비동기 lazy 함수. partial reload(`only`·`except`)는 선택된 것만 평가하고 `errors`는 항상 포함. 비JSON 값·순환은 실패.
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 값·순환은 실패.
44
45
  - `redirect(request, reply, localUrl)`: GET/HEAD 302, mutation 303, Inertia의 `#` 이동은 409 + `X-Inertia-Redirect`.
45
46
  - `location(request, reply, url)`: 로컬 또는 http(s) 외부 이동. Inertia 요청은 409 + `X-Inertia-Location`.
47
+
48
+ ### 3.1 deferred·merge·once props(D-0052)
49
+
50
+ ```ts
51
+ import { defer, merge, deepMerge, once } from '@dokebi/template-inertia';
52
+ 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
+ settings: deepMerge({ theme: { dark: true } }),
59
+ });
60
+ ```
61
+
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`.
46
68
  - 오래된 version의 Inertia 요청은 props 평가 없이 409 + `X-Inertia-Location`(새로고침 유도).
47
69
  - 응답에 `Vary: X-Inertia`, `Cache-Control: no-store`가 붙고 HTML에 넣는 JSON·제목·URL은 escape된다.
48
70
 
71
+ ### 3.2 session flash(D-0055)
72
+
73
+ @dokebi/session이 앱에 등록돼 있으면(선택 의존성, 자동으로 만들지 않음) 페이지 응답의 page **`flash` 필드**에 세션 flash가 들어간다. `takeFlash()`와 같아서 한 번 보이면 사라진다. 비어 있으면 필드가 없다. prop이 아니다(`props.flash` 아님).
74
+
75
+ ```ts
76
+ route.post('/orders', async (request, reply, page) => {
77
+ await createOrder(request.body);
78
+ await request.session.flash('success', 'Order saved'); // @dokebi/session
79
+ return page.inertia!.redirect(request, reply, '/orders'); // 303
80
+ });
81
+ // Vue: usePage().flash.success, 또는 router.on('flash', (event) => toast(event.detail.flash))
82
+ ```
83
+
84
+ - 클라이언트(3.9.1)는 flash를 history에 넣지 않아 뒤로 가기에 다시 보이지 않는다.
85
+ - deferred props만 가져오는 partial reload는 flash를 읽지 않는다(클라이언트가 버리므로). 첫 HTML·방문·그 밖의 partial reload는 읽는다. props 평가가 실패한 렌더는 flash를 남긴다.
86
+ - 세션 플러그인이 없으면 page는 전과 같다.
87
+
49
88
  ## 4. 빌드 파일 서빙
50
89
 
51
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가 없으면 시작 실패.
@@ -54,7 +93,7 @@ route.post('/orders', async (request, reply, page) => {
54
93
 
55
94
  - `ssr.bundle`은 `render(page) → { head: string[]; body: string }`을 export하는 빌드 파일이다. Inertia v3에서는 `createInertiaApp({ resolve, layout })`을 서버에서 page 없이 부르면 `(page, renderToString)` 함수를 준다(`--ssr`이 만드는 `views/ssr.js` 참고).
56
95
  - 번들은 시작 때 한 번 import한다. **없거나 `render`가 없으면 시작 실패.**
57
- - 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500).
96
+ - 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500). 로그의 `error`는 이름·전체 메시지·스택·`Caused by:` 원인, URL 인증 정보만 `//***@`로 지우고 16KB 넘으면 잘라 표시이고, 같은 번들·컴포넌트는 10초에 한 번만 남기며 사이 횟수는 다음 로그의 `repeated`(D-0047). `ssrStats().failures`는 모두 센다.
58
97
  - head에 `<title>`이 있으면 설정 title은 빠진다. Inertia 이동(JSON)은 SSR을 거치지 않는다.
59
98
  - 브라우저 진입은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다. 서버·브라우저가 같은 레이아웃을 고르도록 페이지 모듈을 바꾸지 말고 `layout` 옵션을 쓴다(동시에 여러 템플릿을 그리므로).
60
99
  - `ssrStats()` → `{ renders, failures }`(health·대시보드용). `scope({ ssr: false })`로 앱별로 끌 수 있다.
@@ -64,8 +103,13 @@ route.post('/orders', async (request, reply, page) => {
64
103
  - 환경 변수 `DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 manifest·빌드 없이 **프로젝트의 Vite를 미들웨어 모드로** 한 프로세스에 하나 띄운다. 페이지 스크립트는 `<base>@vite/client`와 `<base><vite.entry>`, 스타일 링크 없음, version `dev`. `<base>*` 요청은 Vite가 답한다.
65
104
  - HMR 소켓은 프로세스마다 빈 포트 하나를 따로 쓴다(앱 포트는 @dokebi/websocket이 upgrade를 모두 받으므로). 브라우저는 페이지와 같은 호스트로 붙는다.
66
105
  - SSR은 요청마다 `ssrLoadModule(<원본 진입>)`. 원본 진입은 `ssr.entry`(예: `src/apps/admin/views/ssr.js`), 없으면 `vite.entry`와 같은 폴더의 `ssr.js`. 둘 다 없으면 시작 실패.
67
- - Vite가 프로젝트에 없으면 시작 실패(`npm install --save-dev vite`). Vite는 `localhost`·`*.localhost`·IP 말고 다른 호스트(예: hosts 파일의 `admin.a.com`)를 막으므로, 그런 도메인으로 개발하면 vite.config에 `server.allowedHosts`를 넣는다.
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` 참고).
68
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가 켜졌을 때만).
69
113
 
70
114
  ## 6. 범위(scope)
71
115
 
@@ -76,7 +120,14 @@ route.post('/orders', async (request, reply, page) => {
76
120
  - props에 비밀·개인정보를 넣지 않는다(HTML에 그대로 실린다). 인증·인가·CSRF는 앱이 구현한다.
77
121
  - 데이터 조회·수정 API는 Inertia 응답이 아니라 일반 JSON 라우트(REST)로 만든다.
78
122
  - 배포에는 빌드별 version(또는 `vite`)을 꼭 쓴다. 기본 version이면 자산이 바뀌어도 클라이언트가 모른다.
79
- - deferred·merge·once props, Precognition, session flash는 없다. 있다고 가정하지 않는다.
123
+ - Precognition, `scrollProps`(무한 스크롤), `sharedProps`, 점 경로 partial reload(`only: ['user.name']`)는 없다. 있다고 가정하지 않는다. session flash는 @dokebi/session이 있을 때만(§3.2).
80
124
  - 앱 인스턴스마다 새 플러그인. 전체 drain 뒤에 responder가 닫힌다.
81
125
 
82
- 근거: `examples/24-template-inertia`(실제 Chrome: CSR·SSR·cluster, 측정), `examples/26-scaffold`(CLI가 만든 SSR 앱의 빌드·서버 렌더), `examples/25-playground`, D-0027·D-0031·D-0039·D-0045.
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)
129
+
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를 보이지 못하고 서버에서 다시 받는다.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @dokebi/template-inertia 0.1.2
1
+ # @dokebi/template-inertia 0.2.0
2
2
 
3
3
  Node >=24.0.0, core ^0.2.0, 선택 패키지다(npm 공개). ID는 `dokebi-template-inertia`이며 `dokebi-web`에 의존한다. `@dokebi/web`은 peer 의존성이다(예전의 고정 버전 dependency가 아니다). EJS·Vue·Vite를 서버 패키지의 필수 의존성으로 설치하지 않는다.
4
4
 
@@ -31,7 +31,7 @@ dokebi-template-inertia:
31
31
 
32
32
  잘못된 옵션은 팩토리에서 `INVALID_PLUGIN_OPTIONS`다. asset URL은 `/`로 시작하는 로컬 절대 URL만 허용하며 파일 서빙은 앱/정적 서버 책임이다.
33
33
 
34
- props는 JSON 값 또는 비동기 lazy 함수다. 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는 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을 반환한다.
35
35
 
36
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다. 외부 목적지는 신뢰된 앱 코드에서 선택한다.
37
37
 
@@ -43,7 +43,7 @@ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('
43
43
 
44
44
  `dokebi add`는 정적 설치 선언으로 진입 파일 `src/infra/inertia/shared.ts`(`export async function shared(_request) { return {}; }`)를 만들고 생성 코드가 이를 `entry`로 넘긴다. 설치 모듈(installModule)은 없다. web은 manifest 의존성으로 함께 설치·순서 검사된다.
45
45
 
46
- 지원 범위는 초기 HTML·페이지 이동·shared/lazy/partial props·redirect·version 협상·서버 렌더링(아래 SSR 절)이다. deferred/merge/once props, Precognition, 서버 session flash 및 Inertia form 검증 redirect 저장소는 제공하지 않는다. REST422 오류 처리는 예제에 구현되어 있다. Vue persistent layout은 Vue 클라이언트 책임이다.
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 클라이언트 책임이다.
47
47
 
48
48
  [예제24](../../examples/24-template-inertia/README.md)에 실제 Vue3 + Inertia + REST, 인증/CSRF fixture와 Chrome 자동 검증이 있다. `npm test -w @dokebi/template-inertia`는 HTTP 프로토콜 회귀다.
49
49
 
@@ -51,6 +51,16 @@ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('
51
51
 
52
52
  `scope({ title?, rootId?, version?, scripts?, styles?, vite?, shared? })`는 자기 문서 설정과 추가 공통 props를 가진 렌더러를 돌려준다(비동기, `vite`면 manifest를 읽는다). 정하지 않은 값은 플러그인 옵션을 따른다. `shared`는 플러그인 공통 props 뒤에 합쳐진다. @dokebi/app이 앱마다 다른 Vite 진입점에 쓴다.
53
53
 
54
+ ## deferred·merge·once props([D-0052](../../docs/decisions/D-0052-inertia-deferred-merge-once.md))
55
+
56
+ `defer`·`merge`·`deepMerge`·`once`를 import해 prop 값 자리(render props·shared props)에 쓴다. 일반 값·lazy 함수만 쓰면 page 객체는 예전과 같다.
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가 나온다.
63
+
54
64
  ## 서버 렌더링(SSR, D-0039)
55
65
 
56
66
  `ssr: { bundle, failure? }`를 주면 페이지의 첫 HTML을 서버에서 그린다. 별도 SSR 프로세스 없이 같은 프로세스에서 SSR 빌드 산출물을 시작 때 한 번 import하고, 전체 페이지 요청마다 그 `render(page)`를 부른다. Inertia 이동(`X-Inertia`)은 지금처럼 JSON이고 SSR을 거치지 않는다.
@@ -68,3 +78,10 @@ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('
68
78
  ## 개발 모드(HMR, [D-0045](../../docs/decisions/D-0045-inertia-vite-dev.md))
69
79
 
70
80
  `DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 빌드 없이 프로젝트의 Vite를 미들웨어 모드로 띄워 `<base>*`를 Vite가 답하고, 페이지는 `@vite/client`와 원본 진입을 불러온다. `.vue`를 고치면 새로고침 없이 바뀐다(HMR 소켓은 프로세스마다 따로 잡은 빈 포트). SSR은 요청마다 Vite로 원본(`ssr.entry`, 없으면 `vite.entry` 옆 `ssr.js`)을 그린다. 운영에는 영향이 없다.
81
+
82
+ ## 봉인 호스트와 history(D-0062, 0.2.0)
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`.
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)이 다음 페이지까지 들고 가고 그 페이지가 지운다.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Awaitable } from '@dokebi/core';
2
2
  import type { FastifyRequest } from 'fastify';
3
3
  import { type InertiaApi, type Props } from './responder.js';
4
- export type { InertiaApi, InertiaOptions, InertiaPage, InertiaSsrResult, Json, Props, } from './responder.js';
4
+ export { type DeferOptions, deepMerge, defer, InertiaProp, isInertiaProp, type MergeOptions, merge, type OnceOptions, once, type PropValue, } from './props.js';
5
+ export type { InertiaApi, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
5
6
  export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
6
7
  export interface InertiaEntry {
7
8
  readonly shared?: InertiaShared;
@@ -45,6 +46,13 @@ export interface InertiaScopeOptions {
45
46
  readonly ssr?: InertiaSsrOptions | false;
46
47
  /** Props merged after the plugin's shared props for every page of this scope. */
47
48
  readonly shared?: InertiaShared;
49
+ /** History encryption of this scope's pages (default `'sealed'`: on for sealed requests). */
50
+ readonly encryptHistory?: boolean | 'sealed';
51
+ /**
52
+ * This scope answers seal hosts (dokebi-app sets it, D-0062). Server rendering would write the
53
+ * props as plain HTML, so `sealed` with SSR stops the start (`SEAL_SSR_CONFLICT`).
54
+ */
55
+ readonly sealed?: boolean;
48
56
  }
49
57
  export interface InertiaPluginApi extends InertiaApi {
50
58
  /**
package/dist/index.js CHANGED
@@ -4,9 +4,11 @@ import { readFile, stat } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { types } from 'node:util';
7
- import { definePlugin, providerDependency, } from '@dokebi/core';
7
+ import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
8
+ import { errorText, Repeats } from './repeats.js';
8
9
  import { InertiaResponder, normalizeInertiaOptions, } from './responder.js';
9
- import { startViteDev, viteDevRequested } from './vite-dev.js';
10
+ import { startViteDev, viteDevRequested, viteFallthrough } from './vite-dev.js';
11
+ export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
10
12
  const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
11
13
  function parseVite(input) {
12
14
  if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
@@ -135,10 +137,30 @@ async function viteAssets(vite) {
135
137
  // Written here so the provider package resolves from @dokebi/template-inertia, not from core.
136
138
  const web = providerDependency({
137
139
  name: 'dokebi-web',
138
- version: '^0.1.0',
140
+ version: '^0.2.0',
139
141
  packageName: '@dokebi/web',
140
142
  load: () => import('@dokebi/web'),
141
143
  });
144
+ /**
145
+ * Carries a history reset (login, logout) to the next page when the request that did it shows
146
+ * no page itself (a POST that redirects): `clearHistory` then goes out with that next page.
147
+ */
148
+ const HISTORY_RESET_COOKIE = 'dokebi.history-reset';
149
+ const HISTORY_RESET_SECONDS = 600;
150
+ function hasCookie(header, name) {
151
+ const text = Array.isArray(header) ? header.join('; ') : (header ?? '');
152
+ if (text.length > 16_384)
153
+ return false;
154
+ return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
155
+ }
156
+ const session = optionalDependency({
157
+ name: 'dokebi-session',
158
+ version: '^0.1.0 || ^0.2.0',
159
+ });
160
+ /** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
161
+ function sealSsrConflict() {
162
+ return Object.assign(new Error('An Inertia scope of seal hosts cannot use SSR: server rendering writes the props as plain HTML. Set ssr: false for this app or drop seal from its hosts.'), { code: 'SEAL_SSR_CONFLICT' });
163
+ }
142
164
  function parseOptions(input) {
143
165
  const { vite, ssr: ssrInput, ...rest } = (input ?? {});
144
166
  const ssr = parseSsr(ssrInput);
@@ -197,10 +219,84 @@ class InertiaRuntime {
197
219
  code: 'INERTIA_VERSION_DEFAULT',
198
220
  });
199
221
  const server = context.getDependency(web);
222
+ const logger = context.logger;
223
+ const sessions = context.getOptionalDependency(session);
224
+ const flash = sessions
225
+ ? async (request) => (await sessions.of(request).takeFlash())
226
+ : undefined;
227
+ // D-0062: sealed requests (dokebi-web decides) and the session's history reset (logout, login).
228
+ // A page shown in the request that reset uses it at once; otherwise the reset cookie carries
229
+ // it to the next page, which also clears that cookie.
230
+ const resets = new WeakMap();
231
+ const sessionOf = (request) =>
232
+ // Static paths excluded by the session plugin have none (`request.session` is null).
233
+ request.session ? sessions?.of(request) : undefined;
234
+ const hooks = Object.freeze({
235
+ sealOf: (request) => server.sealOf(request),
236
+ ...(sessions
237
+ ? {
238
+ historyReset: async (request) => {
239
+ const now = sessionOf(request)?.historyReset?.() === true;
240
+ const carried = hasCookie(request.headers.cookie, HISTORY_RESET_COOKIE);
241
+ if (now || carried)
242
+ resets.set(request, { served: now, read: carried });
243
+ return now || carried;
244
+ },
245
+ }
246
+ : {}),
247
+ });
248
+ if (sessions) {
249
+ const carry = async (scope) => {
250
+ scope.addHook('onSend', async (request, reply, payload) => {
251
+ const state = resets.get(request);
252
+ const reset = sessionOf(request)?.historyReset?.() === true;
253
+ if (!(reset && !state?.served) && !state?.read)
254
+ return payload;
255
+ const set = reset && !state?.served;
256
+ reply.header('set-cookie', `${HISTORY_RESET_COOKIE}=${set ? '1' : ''}; Path=/; Max-Age=${set ? HISTORY_RESET_SECONDS : 0}; HttpOnly; SameSite=Lax${request.protocol === 'https' ? '; Secure' : ''}`);
257
+ if (logger.isEnabled('debug'))
258
+ logger.log('debug', 'Inertia history reset cookie', {
259
+ operation: set ? 'set' : 'clear',
260
+ });
261
+ return payload;
262
+ });
263
+ };
264
+ // Without encapsulation (skip-override), so the hook sees every route.
265
+ carry[Symbol.for('skip-override')] = true;
266
+ server.register(carry, {});
267
+ }
200
268
  // Development (D-0045): one Vite dev server for every scope, started by the first `vite`.
201
269
  const develop = viteDevRequested();
270
+ logger.log('debug', 'Inertia settings resolved', {
271
+ develop,
272
+ vite: this.settings.vite ? { ...this.settings.vite } : false,
273
+ ssr: this.settings.ssr ? { ...this.settings.ssr } : false,
274
+ defaultVersion: this.settings.defaultVersion,
275
+ shared: this.shared !== undefined,
276
+ flash: flash !== undefined,
277
+ historyReset: hooks.historyReset !== undefined,
278
+ encryptHistory: this.settings.document.encryptHistory,
279
+ });
280
+ // Per-request failures are logged once per interval with the count of repeats (D-0047).
281
+ const repeats = new Repeats();
202
282
  let devServer;
203
283
  const devBases = new Set();
284
+ // Vite refuses hosts other than localhost (DNS rebinding). Its check is off; instead the dev
285
+ // routes serve loopback names, the domains of @dokebi/app (the same in every cluster worker)
286
+ // and hosts this process answered with an Inertia page. An app domain (a.com through a hosts
287
+ // file or a tunnel) works; an unknown host does not.
288
+ const appDomains = context.app.config['dokebi-app']
289
+ ?.domains;
290
+ const pageHosts = new Set(Object.keys(appDomains ?? {}).map((host) => host.toLowerCase().replace(/:\d+$/, '')));
291
+ const hostName = (request) => server
292
+ .requestHost(request)
293
+ .replace(/:\d+$/, '')
294
+ .replace(/^\[(.*)\]$/, '$1');
295
+ const devHostAllowed = (host) => host === 'localhost' ||
296
+ host.endsWith('.localhost') ||
297
+ host === '::1' ||
298
+ /^127\.\d+\.\d+\.\d+$/.test(host) ||
299
+ pageHosts.has(host);
204
300
  // Registered now: scopes are created later, from other plugins' setup.
205
301
  context.defer(async () => {
206
302
  if (devServer)
@@ -208,7 +304,15 @@ class InertiaRuntime {
208
304
  });
209
305
  const viteDev = (base) => {
210
306
  if (!devServer) {
211
- devServer = startViteDev();
307
+ const begin = performance.now();
308
+ // A start failure reaches setup or the request that awaits the server.
309
+ devServer = startViteDev().then((started) => {
310
+ logger.log('debug', 'Vite dev server started', {
311
+ ms: Math.round(performance.now() - begin),
312
+ hosts: [...pageHosts].slice(0, 64),
313
+ });
314
+ return started;
315
+ });
212
316
  context.logger.log('info', 'Inertia views are served by the Vite dev server', {
213
317
  code: 'INERTIA_VITE_DEV',
214
318
  });
@@ -221,14 +325,33 @@ class InertiaRuntime {
221
325
  method: 'GET', // Fastify adds HEAD
222
326
  url: `${base}*`,
223
327
  handler: async (request, reply) => {
328
+ const host = hostName(request);
329
+ if (!devHostAllowed(host)) {
330
+ if (logger.isEnabled('debug'))
331
+ logger.log('debug', 'Vite dev route refused a host', {
332
+ host: host.slice(0, 253),
333
+ base,
334
+ });
335
+ return reply
336
+ .code(403)
337
+ .type('text/plain; charset=utf-8')
338
+ .send('This host has not been served an Inertia page by this app.');
339
+ }
224
340
  const vite = await current;
225
341
  reply.hijack();
226
- vite.middlewares(request.raw, reply.raw, () => {
227
- reply.raw.statusCode = 404;
228
- reply.raw.end();
229
- });
342
+ vite.middlewares(request.raw, reply.raw, (error) => viteFallthrough(reply.raw, error, (failure) => {
343
+ const repeated = repeats.take(`vite ${failure instanceof Error ? failure.name : typeof failure}`);
344
+ if (repeated !== undefined)
345
+ logger.log('error', 'Vite dev middleware failed', {
346
+ code: 'INERTIA_VITE_DEV_FAILED',
347
+ base,
348
+ error: errorText(failure),
349
+ ...(repeated ? { repeated } : {}),
350
+ });
351
+ }));
230
352
  },
231
353
  });
354
+ logger.log('debug', 'Vite dev route registered', { url: `${base}*` });
232
355
  }
233
356
  return current;
234
357
  };
@@ -244,6 +367,14 @@ class InertiaRuntime {
244
367
  };
245
368
  }
246
369
  const { files, ...assets } = await viteAssets(options);
370
+ logger.log('debug', 'Vite manifest read', {
371
+ manifest: options.manifest,
372
+ entry: options.entry,
373
+ scripts: assets.scripts,
374
+ styles: assets.styles.length,
375
+ files: files.size,
376
+ version: assets.version,
377
+ });
247
378
  for (const [url, file] of files) {
248
379
  if (served.has(url))
249
380
  continue;
@@ -280,6 +411,11 @@ class InertiaRuntime {
280
411
  if (!source || !vite)
281
412
  throw new Error(`Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
282
413
  const dev = viteDev(vite.base);
414
+ logger.log('debug', 'Inertia SSR renders the source through Vite', {
415
+ source,
416
+ bundle: ssr.bundle,
417
+ entrySet: ssr.entry !== undefined,
418
+ });
283
419
  render = Promise.resolve(async (page) => {
284
420
  const module = await (await dev).ssrLoadModule(`/${source}`);
285
421
  if (typeof module.render !== 'function')
@@ -291,8 +427,15 @@ class InertiaRuntime {
291
427
  const path = resolve(ssr.bundle);
292
428
  render = bundles.get(path);
293
429
  if (!render) {
430
+ const begin = performance.now();
294
431
  render = loadBundle(ssr.bundle);
295
432
  bundles.set(path, render);
433
+ await render;
434
+ logger.log('debug', 'Inertia SSR bundle loaded', {
435
+ bundle: path,
436
+ failure: ssr.failure,
437
+ ms: Math.round(performance.now() - begin),
438
+ });
296
439
  }
297
440
  }
298
441
  return Object.freeze({
@@ -303,13 +446,17 @@ class InertiaRuntime {
303
446
  },
304
447
  failed: (error, component) => {
305
448
  stats.failures++;
306
- context.logger.log('error', 'Inertia SSR render failed', {
307
- code: 'INERTIA_SSR_FAILED',
308
- bundle: ssr.bundle,
309
- component,
310
- fallback: ssr.failure === 'fallback',
311
- error: error instanceof Error ? error.message : String(error),
312
- });
449
+ // Every failure counts in ssrStats; the log repeats once per interval per component.
450
+ const repeated = repeats.take(`ssr ${ssr.bundle} ${component}`);
451
+ if (repeated !== undefined)
452
+ context.logger.log('error', 'Inertia SSR render failed', {
453
+ code: 'INERTIA_SSR_FAILED',
454
+ bundle: ssr.bundle,
455
+ component,
456
+ fallback: ssr.failure === 'fallback',
457
+ error: errorText(error),
458
+ ...(repeated ? { repeated } : {}),
459
+ });
313
460
  },
314
461
  });
315
462
  };
@@ -320,20 +467,38 @@ class InertiaRuntime {
320
467
  });
321
468
  const api = (responder) => {
322
469
  responders.push(responder);
470
+ const render = responder.render.bind(responder);
323
471
  return Object.freeze({
324
- render: responder.render.bind(responder),
472
+ render: develop
473
+ ? (request, reply, component, props, options) => {
474
+ pageHosts.add(hostName(request));
475
+ return render(request, reply, component, props, options);
476
+ }
477
+ : render,
325
478
  redirect: responder.redirect.bind(responder),
326
479
  location: responder.location.bind(responder),
327
480
  });
328
481
  };
329
- const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite)));
482
+ // Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
483
+ const diagnostics = Object.freeze({ logger, repeats });
484
+ const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
330
485
  const shared = this.shared;
331
486
  return Object.freeze({
332
487
  ...root,
333
488
  ssrStats: () => Object.freeze({ ...stats }),
334
489
  scope: async (options) => {
335
- const { shared: extra, vite, ssr: ssrInput, ...fields } = (options ?? {});
490
+ const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
491
+ if (typeof sealed !== 'boolean')
492
+ throw new TypeError('Inertia scope sealed must be a boolean');
336
493
  const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
494
+ if (sealed && ssr) {
495
+ logger.log('error', 'Inertia scope of seal hosts with SSR', {
496
+ code: 'SEAL_SSR_CONFLICT',
497
+ title: typeof fields.title === 'string' ? fields.title.slice(0, 100) : null,
498
+ fix: 'Set inertia.ssr: false for this app, or remove seal: true from its domains',
499
+ });
500
+ throw sealSsrConflict();
501
+ }
337
502
  if (extra !== undefined && typeof extra !== 'function')
338
503
  throw new TypeError('Inertia scope shared must be a function');
339
504
  if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
@@ -347,17 +512,27 @@ class InertiaRuntime {
347
512
  ? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
348
513
  : (extra ?? shared);
349
514
  const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
350
- return api(new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite)));
515
+ const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash, hooks);
516
+ logger.log('debug', 'Inertia scope created', {
517
+ title: scoped.title,
518
+ rootId: scoped.rootId,
519
+ vite: scopedVite ? scopedVite.entry : false,
520
+ ssr: ssr ? ssr.bundle : false,
521
+ shared: merged !== undefined,
522
+ sealed,
523
+ encryptHistory: scoped.encryptHistory,
524
+ });
525
+ return api(responder);
351
526
  },
352
527
  });
353
528
  }
354
529
  }
355
530
  export const createPlugin = definePlugin({
356
531
  name: 'dokebi-template-inertia',
357
- version: '0.1.2',
532
+ version: '0.2.0',
358
533
  core: '^0.2.0',
359
534
  options: parseOptions,
360
535
  entry: parseEntry,
361
- dependencies: () => [web],
536
+ dependencies: () => [web, session],
362
537
  instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
363
538
  });
@@ -0,0 +1,77 @@
1
+ import type { Awaitable } from '@dokebi/core';
2
+ import type { Json } from './responder.js';
3
+ /** A prop value or a function that computes it when the prop is sent. */
4
+ export type PropValue = Json | (() => Awaitable<Json>);
5
+ /** How the client combines a merge prop with the value it already holds (partial reloads only). */
6
+ export interface MergeOptions {
7
+ /** `append` (default) adds arrays at the end, `prepend` at the start, `deep` merges nested. */
8
+ readonly mode?: 'append' | 'prepend' | 'deep';
9
+ /**
10
+ * Item fields that identify an entry (for example `id` or `data.id` under a deep merge): an
11
+ * incoming item replaces the held one with the same value instead of being added twice.
12
+ */
13
+ readonly matchOn?: string | readonly string[];
14
+ }
15
+ export interface OnceOptions {
16
+ /** Name the client remembers the value under (default: the prop name); pages can share it. */
17
+ readonly key?: string;
18
+ /** After this many milliseconds the client asks for the value again. Default: never. */
19
+ readonly ttlMs?: number;
20
+ }
21
+ export interface DeferOptions {
22
+ /** Deferred props of one group are fetched by one request after the page is shown. */
23
+ readonly group?: string;
24
+ /** The loaded value is merged into the held one (see `merge`). */
25
+ readonly merge?: true | MergeOptions;
26
+ /** Once loaded, the value is kept by the client and not computed again (see `once`). */
27
+ readonly once?: true | OnceOptions;
28
+ /**
29
+ * When the function throws, the prop is left out and named in `rescuedProps` (the client's
30
+ * `<Deferred>` shows its `rescue` slot) and the error is logged, instead of failing the request.
31
+ */
32
+ readonly rescue?: boolean;
33
+ }
34
+ declare const BRAND: unique symbol;
35
+ /**
36
+ * A prop value with Inertia protocol behaviour (D-0052), made by `defer`, `merge`, `deepMerge` or
37
+ * `once`. Usable wherever a prop value is (render props and shared props).
38
+ */
39
+ export declare class InertiaProp {
40
+ readonly value: PropValue;
41
+ readonly group: string | undefined;
42
+ readonly merge: Readonly<{
43
+ mode: 'append' | 'prepend' | 'deep';
44
+ matchOn: readonly string[];
45
+ }> | undefined;
46
+ readonly once: Readonly<{
47
+ key: string | undefined;
48
+ ttlMs: number | undefined;
49
+ }> | undefined;
50
+ readonly rescue: boolean;
51
+ readonly [BRAND] = true;
52
+ /** @internal */
53
+ constructor(value: PropValue, group: string | undefined, merge: Readonly<{
54
+ mode: 'append' | 'prepend' | 'deep';
55
+ matchOn: readonly string[];
56
+ }> | undefined, once: Readonly<{
57
+ key: string | undefined;
58
+ ttlMs: number | undefined;
59
+ }> | undefined, rescue: boolean);
60
+ }
61
+ /** True for a value made by the helpers, also from another copy of this package. */
62
+ export declare function isInertiaProp(value: unknown): value is InertiaProp;
63
+ /**
64
+ * A prop left out of the first page and fetched by the client right after it is shown, one
65
+ * request per group. `fn` runs only when a partial reload asks for the prop.
66
+ */
67
+ export declare function defer(fn: () => Awaitable<Json>, options?: string | DeferOptions): InertiaProp;
68
+ /** A prop the client merges into the value it holds on partial reloads (pagination, feeds). */
69
+ export declare function merge(value: PropValue, options?: MergeOptions): InertiaProp;
70
+ /** `merge` with nested objects merged key by key (and arrays inside them appended). */
71
+ export declare function deepMerge(value: PropValue, options?: Omit<MergeOptions, 'mode'>): InertiaProp;
72
+ /**
73
+ * A prop the client keeps after receiving it: later visits (to this or another page with the
74
+ * same key) do not compute it again until `ttlMs` passes or a partial reload asks for it.
75
+ */
76
+ export declare function once(value: PropValue, options?: OnceOptions): InertiaProp;
77
+ export {};
package/dist/props.js ADDED
@@ -0,0 +1,84 @@
1
+ const BRAND = Symbol.for('dokebi.template-inertia.prop');
2
+ /**
3
+ * A prop value with Inertia protocol behaviour (D-0052), made by `defer`, `merge`, `deepMerge` or
4
+ * `once`. Usable wherever a prop value is (render props and shared props).
5
+ */
6
+ export class InertiaProp {
7
+ value;
8
+ group;
9
+ merge;
10
+ once;
11
+ rescue;
12
+ [BRAND] = true;
13
+ /** @internal */
14
+ constructor(value, group, merge, once, rescue) {
15
+ this.value = value;
16
+ this.group = group;
17
+ this.merge = merge;
18
+ this.once = once;
19
+ this.rescue = rescue;
20
+ Object.freeze(this);
21
+ }
22
+ }
23
+ /** True for a value made by the helpers, also from another copy of this package. */
24
+ export function isInertiaProp(value) {
25
+ return (typeof value === 'object' &&
26
+ value !== null &&
27
+ value[BRAND] === true);
28
+ }
29
+ // Names travel in comma separated headers and dotted paths; such characters cannot round-trip.
30
+ const NAME = /^[^,\s]{1,128}$/;
31
+ function checkValue(value, helper) {
32
+ if (value === undefined || isInertiaProp(value))
33
+ throw new TypeError(`${helper}() takes a JSON value or a function, not ${String(value)}`);
34
+ return value;
35
+ }
36
+ function mergeSettings(options) {
37
+ const { mode = 'append', matchOn = [], ...rest } = (options === true ? {} : (options ?? {}));
38
+ const fields = typeof matchOn === 'string' ? [matchOn] : matchOn;
39
+ if (Object.keys(rest).length ||
40
+ !['append', 'prepend', 'deep'].includes(mode) ||
41
+ !Array.isArray(fields) ||
42
+ fields.some((field) => typeof field !== 'string' || !NAME.test(field)))
43
+ throw new TypeError('Invalid Inertia merge options');
44
+ return Object.freeze({ mode, matchOn: Object.freeze([...fields]) });
45
+ }
46
+ function onceSettings(options) {
47
+ const { key, ttlMs, ...rest } = (options === true ? {} : (options ?? {}));
48
+ if (Object.keys(rest).length ||
49
+ (key !== undefined && (typeof key !== 'string' || !NAME.test(key))) ||
50
+ (ttlMs !== undefined && !(typeof ttlMs === 'number' && Number.isFinite(ttlMs) && ttlMs > 0)))
51
+ throw new TypeError('Invalid Inertia once options');
52
+ return Object.freeze({ key, ttlMs });
53
+ }
54
+ /**
55
+ * A prop left out of the first page and fetched by the client right after it is shown, one
56
+ * request per group. `fn` runs only when a partial reload asks for the prop.
57
+ */
58
+ export function defer(fn, options) {
59
+ if (typeof fn !== 'function')
60
+ throw new TypeError('defer() takes a function');
61
+ const settings = typeof options === 'string' ? { group: options } : (options ?? {});
62
+ const { group = 'default', merge, once, rescue = false, ...rest } = settings;
63
+ if (Object.keys(rest).length ||
64
+ typeof group !== 'string' ||
65
+ !NAME.test(group) ||
66
+ typeof rescue !== 'boolean')
67
+ throw new TypeError('Invalid Inertia defer options');
68
+ return new InertiaProp(fn, group, merge === undefined ? undefined : mergeSettings(merge), once === undefined ? undefined : onceSettings(once), rescue);
69
+ }
70
+ /** A prop the client merges into the value it holds on partial reloads (pagination, feeds). */
71
+ export function merge(value, options) {
72
+ return new InertiaProp(checkValue(value, 'merge'), undefined, mergeSettings(options), undefined, false);
73
+ }
74
+ /** `merge` with nested objects merged key by key (and arrays inside them appended). */
75
+ export function deepMerge(value, options) {
76
+ return merge(value, { ...options, mode: 'deep' });
77
+ }
78
+ /**
79
+ * A prop the client keeps after receiving it: later visits (to this or another page with the
80
+ * same key) do not compute it again until `ttlMs` passes or a partial reload asks for it.
81
+ */
82
+ export function once(value, options) {
83
+ return new InertiaProp(checkValue(value, 'once'), undefined, undefined, onceSettings(options), false);
84
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The full text of a caught value for the `error` log field (D-0047): name, whole message and
3
+ * stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
4
+ * removed; a text over 16 KB is cut and marked.
5
+ */
6
+ export declare function errorText(error: unknown): string;
7
+ /** How often a repeating error of the same kind is logged; repeats in between are counted. */
8
+ export declare const REPEAT_INTERVAL_MS = 10000;
9
+ /**
10
+ * Counts repeats of the same error key so a per-request path logs it once per interval (D-0047).
11
+ * The first occurrence is logged; later ones inside the interval are only counted and reported
12
+ * with the next logged occurrence of that key.
13
+ */
14
+ export declare class Repeats {
15
+ #private;
16
+ /** The repeats to report with this occurrence, or undefined when it is only counted. */
17
+ take(key: string, now?: number): number | undefined;
18
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The full text of a caught value for the `error` log field (D-0047): name, whole message and
3
+ * stack, then each `cause` the same way. Only credentials in URLs (`//user:password@`) are
4
+ * removed; a text over 16 KB is cut and marked.
5
+ */
6
+ export function errorText(error) {
7
+ const parts = [];
8
+ let current = error;
9
+ for (let depth = 0; current !== undefined && depth < 5; depth++) {
10
+ let text;
11
+ if (current instanceof Error)
12
+ text = current.stack ?? `${current.name}: ${current.message}`;
13
+ else if (typeof current === 'string')
14
+ text = current;
15
+ else {
16
+ try {
17
+ text = `NonError: ${JSON.stringify(current)}`;
18
+ }
19
+ catch {
20
+ text = `NonError: ${String(current)}`; // not serialisable (cycle, BigInt): its string form
21
+ }
22
+ }
23
+ parts.push(depth ? `Caused by: ${text}` : text);
24
+ current = current instanceof Error ? current.cause : undefined;
25
+ }
26
+ const text = parts.join('\n').replace(/\/\/[^\s/@]*@/g, '//***@');
27
+ return text.length > 16_384
28
+ ? `${text.slice(0, 16_384)}… [truncated ${text.length - 16_384} characters]`
29
+ : text;
30
+ }
31
+ /** How often a repeating error of the same kind is logged; repeats in between are counted. */
32
+ export const REPEAT_INTERVAL_MS = 10_000;
33
+ /**
34
+ * Counts repeats of the same error key so a per-request path logs it once per interval (D-0047).
35
+ * The first occurrence is logged; later ones inside the interval are only counted and reported
36
+ * with the next logged occurrence of that key.
37
+ */
38
+ export class Repeats {
39
+ #keys = new Map();
40
+ /** The repeats to report with this occurrence, or undefined when it is only counted. */
41
+ take(key, now = Date.now()) {
42
+ const entry = this.#keys.get(key);
43
+ if (entry && now < entry.until) {
44
+ entry.repeated++;
45
+ return undefined;
46
+ }
47
+ if (!entry && this.#keys.size >= 256)
48
+ this.#keys.clear();
49
+ this.#keys.set(key, { until: now + REPEAT_INTERVAL_MS, repeated: 0 });
50
+ return entry?.repeated ?? 0;
51
+ }
52
+ }
@@ -1,15 +1,43 @@
1
- import type { Awaitable } from '@dokebi/core';
1
+ import type { Awaitable, Logger } from '@dokebi/core';
2
2
  import type { FastifyReply, FastifyRequest } from 'fastify';
3
+ import { type InertiaProp } from './props.js';
4
+ import { type Repeats } from './repeats.js';
3
5
  export type Json = null | boolean | number | string | readonly Json[] | {
4
6
  readonly [key: string]: Json;
5
7
  };
6
- export type Props = Readonly<Record<string, Json | (() => Awaitable<Json>)>>;
8
+ /** Prop values: JSON, a lazy function, or a `defer`·`merge`·`deepMerge`·`once` prop (D-0052). */
9
+ export type Props = Readonly<Record<string, Json | (() => Awaitable<Json>) | InertiaProp>>;
7
10
  export interface InertiaOptions {
8
11
  version?: string;
9
12
  rootId?: string;
10
13
  title?: string;
11
14
  scripts?: readonly string[];
12
15
  styles?: readonly string[];
16
+ /**
17
+ * Inertia history encryption of the pages (D-0062): `true`, `false`, or `'sealed'` (default):
18
+ * on for sealed requests (seal hosts), so props kept in the browser history are encrypted too.
19
+ */
20
+ encryptHistory?: boolean | 'sealed';
21
+ }
22
+ /** Per response history settings; unset values come from the scope (`encryptHistory`) and session. */
23
+ export interface InertiaRenderOptions {
24
+ /** Encrypt this page in the browser history (Inertia `encryptHistory`). */
25
+ readonly encryptHistory?: boolean;
26
+ /**
27
+ * Clear the encrypted history before showing this page. Unset: true for the first page after
28
+ * the session was destroyed or regenerated (@dokebi/session 0.2 `historyReset`, logout, login).
29
+ */
30
+ readonly clearHistory?: boolean;
31
+ }
32
+ /**
33
+ * What the plugin gives a responder besides its options: the sealing of a request (dokebi-web
34
+ * `sealOf`) and the session's history reset signal.
35
+ */
36
+ export interface InertiaHooks {
37
+ readonly sealOf?: (request: FastifyRequest) => {
38
+ encode(text: string): string;
39
+ } | undefined;
40
+ readonly historyReset?: (request: FastifyRequest) => Promise<boolean>;
13
41
  }
14
42
  /** The page object Inertia hands to the client and to the SSR bundle. */
15
43
  export interface InertiaPage {
@@ -19,6 +47,33 @@ export interface InertiaPage {
19
47
  readonly version: string;
20
48
  readonly clearHistory: boolean;
21
49
  readonly encryptHistory: boolean;
50
+ /** Props left out of this page by group; the client fetches each group after showing it. */
51
+ readonly deferredProps?: Readonly<Record<string, readonly string[]>>;
52
+ /** Props the client appends (arrays) or shallow merges (objects) on partial reloads. */
53
+ readonly mergeProps?: readonly string[];
54
+ readonly prependProps?: readonly string[];
55
+ readonly deepMergeProps?: readonly string[];
56
+ /** `<prop>.<field>` pairs: merged items with the same field value replace each other. */
57
+ readonly matchPropsOn?: readonly string[];
58
+ /** Once props by key: the client keeps them and names held keys in X-Inertia-Except-Once-Props. */
59
+ readonly onceProps?: Readonly<Record<string, {
60
+ readonly prop: string;
61
+ readonly expiresAt: number | null;
62
+ }>>;
63
+ /** Deferred props whose function threw and was rescued (left out of props). */
64
+ readonly rescuedProps?: readonly string[];
65
+ /**
66
+ * Flash data of the session (D-0055), read once; only when @dokebi/session is registered and
67
+ * has flash. The client fires its `flash` event and does not keep it in history.
68
+ */
69
+ readonly flash?: Readonly<Record<string, Json>>;
70
+ }
71
+ /** Reads (and removes) the flash of the request's session. */
72
+ export type InertiaFlash = (request: FastifyRequest) => Promise<Record<string, Json>>;
73
+ /** Where the responder reports prop evaluation (debug) and rescued prop failures (error). */
74
+ export interface InertiaDiagnostics {
75
+ readonly logger: Logger;
76
+ readonly repeats: Repeats;
22
77
  }
23
78
  /** What an SSR bundle's `render(page)` returns: head tags and the root (script + element). */
24
79
  export interface InertiaSsrResult {
@@ -36,7 +91,7 @@ export interface InertiaSsr {
36
91
  readonly failed?: (error: unknown, component: string) => void;
37
92
  }
38
93
  export interface InertiaApi {
39
- render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props): Promise<FastifyReply>;
94
+ render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props, options?: InertiaRenderOptions): Promise<FastifyReply>;
40
95
  redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
41
96
  location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
42
97
  }
@@ -47,10 +102,13 @@ export declare class InertiaResponder implements InertiaApi {
47
102
  #private;
48
103
  readonly shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined;
49
104
  readonly ssr?: InertiaSsr | undefined;
105
+ readonly diagnostics?: InertiaDiagnostics | undefined;
106
+ readonly flash?: InertiaFlash | undefined;
107
+ readonly hooks: InertiaHooks;
50
108
  readonly options: NormalizedInertiaOptions;
51
- constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined);
109
+ constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
52
110
  close(): void;
53
- render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props): Promise<never>;
111
+ render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props, history?: InertiaRenderOptions): Promise<never>;
54
112
  redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
55
113
  location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
56
114
  }
package/dist/responder.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { isInertiaProp } from './props.js';
2
+ import { errorText } from './repeats.js';
1
3
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
2
4
  function localUrl(value) {
3
5
  if (typeof value !== 'string' ||
@@ -23,7 +25,7 @@ export function normalizeInertiaOptions(options = {}) {
23
25
  if (!options ||
24
26
  typeof options !== 'object' ||
25
27
  Array.isArray(options) ||
26
- Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles'].includes(key)))
28
+ Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles', 'encryptHistory'].includes(key)))
27
29
  throw new TypeError('Invalid Inertia options');
28
30
  for (const assets of [options.scripts, options.styles]) {
29
31
  if (assets !== undefined &&
@@ -36,11 +38,13 @@ export function normalizeInertiaOptions(options = {}) {
36
38
  title: options.title ?? '',
37
39
  scripts: Object.freeze([...(options.scripts ?? [])]),
38
40
  styles: Object.freeze([...(options.styles ?? [])]),
41
+ encryptHistory: options.encryptHistory ?? 'sealed',
39
42
  };
40
43
  if (typeof normalized.version !== 'string' ||
41
44
  typeof normalized.title !== 'string' ||
42
45
  typeof normalized.rootId !== 'string' ||
43
- !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId))
46
+ !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId) ||
47
+ (typeof normalized.encryptHistory !== 'boolean' && normalized.encryptHistory !== 'sealed'))
44
48
  throw new TypeError('Invalid Inertia document settings');
45
49
  for (const asset of [...normalized.scripts, ...normalized.styles])
46
50
  localUrl(asset);
@@ -49,11 +53,17 @@ export function normalizeInertiaOptions(options = {}) {
49
53
  export class InertiaResponder {
50
54
  shared;
51
55
  ssr;
56
+ diagnostics;
57
+ flash;
58
+ hooks;
52
59
  options;
53
60
  #closed = false;
54
- constructor(options = {}, shared, ssr) {
61
+ constructor(options = {}, shared, ssr, diagnostics, flash, hooks = {}) {
55
62
  this.shared = shared;
56
63
  this.ssr = ssr;
64
+ this.diagnostics = diagnostics;
65
+ this.flash = flash;
66
+ this.hooks = hooks;
57
67
  this.options = normalizeInertiaOptions(options);
58
68
  }
59
69
  close() {
@@ -63,8 +73,13 @@ export class InertiaResponder {
63
73
  if (this.#closed)
64
74
  throw new Error('Inertia responder is closed');
65
75
  }
66
- async render(request, reply, component, input = {}) {
76
+ async render(request, reply, component, input = {}, history = {}) {
67
77
  this.#check();
78
+ if (history === null ||
79
+ typeof history !== 'object' ||
80
+ (history.encryptHistory !== undefined && typeof history.encryptHistory !== 'boolean') ||
81
+ (history.clearHistory !== undefined && typeof history.clearHistory !== 'boolean'))
82
+ throw new TypeError('Inertia render options are { encryptHistory?, clearHistory? } booleans');
68
83
  if (!['GET', 'HEAD'].includes(request.method))
69
84
  throw new Error('Render pages using GET/HEAD; redirect mutations or use REST');
70
85
  if (typeof component !== 'string' ||
@@ -82,6 +97,10 @@ export class InertiaResponder {
82
97
  .header('X-Inertia-Version', this.options.version)
83
98
  .send();
84
99
  }
100
+ // A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML.
101
+ const sealer = this.hooks.sealOf?.(request);
102
+ if (sealer && this.ssr && !inertia)
103
+ throw Object.assign(new Error('A sealed page cannot be server rendered: SSR writes the props as plain HTML. Turn off ssr for this app or seal for its hosts.'), { code: 'SEAL_SSR_CONFLICT' });
85
104
  const props = { ...(await this.shared?.(request)), ...input };
86
105
  const partial = inertia && request.headers['x-inertia-partial-component'] === component;
87
106
  const list = (key) => typeof request.headers[key] === 'string'
@@ -90,22 +109,52 @@ export class InertiaResponder {
90
109
  .map((x) => x.trim())
91
110
  .filter(Boolean))
92
111
  : new Set();
93
- const only = list('x-inertia-partial-data'), except = list('x-inertia-partial-except');
94
- const selected = Object.entries(props).filter(([key]) => key === 'errors' || !partial || !((only.size > 0 && !only.has(key)) || except.has(key)));
95
- // Lazy props run concurrently; every rejection is observed by Promise.all.
96
- const values = await Promise.all(selected.map(async ([, value]) => (typeof value === 'function' ? await value() : value)));
112
+ const only = list('x-inertia-partial-data'), except = list('x-inertia-partial-except'), reset = list('x-inertia-reset'),
113
+ // Once keys whose value the client holds; a full HTML load holds nothing.
114
+ held = inertia ? list('x-inertia-except-once-props') : new Set();
115
+ const meta = buildMetadata(props, partial, only, except, reset, held);
116
+ // The client ignores the flash of a deferred-props fetch (it keeps the page's), so such a
117
+ // request leaves the session's flash for the next page instead of using it up.
118
+ const deferredFetch = partial &&
119
+ only.size > 0 &&
120
+ [...only].every((key) => {
121
+ const value = Object.hasOwn(props, key) ? props[key] : undefined;
122
+ return isInertiaProp(value) && value.group !== undefined;
123
+ });
124
+ const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
125
+ // Read after the props: a failed render leaves the flash for the next page.
126
+ const flash = this.flash && !deferredFetch ? await this.flash(request) : undefined;
127
+ // Read like the flash: once, by the page that shows it (logout or login happened before).
128
+ const clearHistory = history.clearHistory ??
129
+ (this.hooks.historyReset && !deferredFetch ? await this.hooks.historyReset(request) : false);
130
+ const configured = this.options.encryptHistory;
131
+ const encryptHistory = history.encryptHistory ?? (configured === 'sealed' ? sealer !== undefined : configured);
97
132
  const resolved = Object.create(null);
98
133
  resolved.errors = {};
99
- selected.forEach(([key], index) => {
100
- resolved[key] = values[index];
134
+ meta.selected.forEach(([key], index) => {
135
+ if (values[index] !== RESCUED)
136
+ resolved[key] = values[index];
101
137
  });
138
+ const logger = this.diagnostics?.logger;
139
+ if (logger?.isEnabled('debug') && (meta.lazy.length || meta.announced))
140
+ logger.log('debug', 'Inertia props resolved', {
141
+ component,
142
+ partial,
143
+ evaluated: meta.lazy.slice(0, 64),
144
+ ...(meta.page.deferredProps ? { deferred: meta.page.deferredProps } : {}),
145
+ ...(meta.skippedOnce.length ? { heldOnce: meta.skippedOnce.slice(0, 64) } : {}),
146
+ ...(meta.rescued.length ? { rescued: meta.rescued } : {}),
147
+ });
102
148
  const page = {
103
149
  component,
104
150
  props: resolved,
105
151
  url: request.url,
106
152
  version: this.options.version,
107
- clearHistory: false,
108
- encryptHistory: false,
153
+ clearHistory,
154
+ encryptHistory,
155
+ ...meta.page,
156
+ ...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
157
+ ...(flash && Object.keys(flash).length ? { flash } : {}),
109
158
  };
110
159
  // Reject unsupported values rather than silently dropping application data.
111
160
  const json = JSON.stringify(page, (_key, value) => {
@@ -117,9 +166,25 @@ export class InertiaResponder {
117
166
  throw new TypeError('Page props must be JSON serializable');
118
167
  return value;
119
168
  });
169
+ if (logger?.isEnabled('debug') && (sealer || clearHistory))
170
+ logger.log('debug', 'Inertia page history and seal', {
171
+ component,
172
+ sealed: sealer !== undefined,
173
+ encryptHistory,
174
+ clearHistory,
175
+ });
176
+ // The XHR answer is JSON: dokebi-web seals it on the way out for a sealed request.
120
177
  if (inertia)
121
178
  return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
122
179
  const { rootId, title, scripts, styles } = this.options;
180
+ if (sealer) {
181
+ // `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
182
+ // (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
183
+ const frame = sealer.encode(json);
184
+ return reply
185
+ .type('text/html; charset=utf-8')
186
+ .send(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}"></div></body></html>`);
187
+ }
123
188
  // Only the first HTML is server rendered; Inertia visits above stay JSON.
124
189
  const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
125
190
  const safeJson = json
@@ -134,6 +199,36 @@ export class InertiaResponder {
134
199
  .type('text/html; charset=utf-8')
135
200
  .send(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titled ? '' : `<title>${escapeHtml(title)}</title>`}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}${head.join('')}</head><body>${rendered?.body ?? `<script data-page="${rootId}" type="application/json">${safeJson}</script><div id="${rootId}"></div>`}</body></html>`);
136
201
  }
202
+ /**
203
+ * The value of one selected prop. A function runs here; a rescued deferred prop that throws is
204
+ * logged and left out, any other failure rejects the render as before (D-0052).
205
+ */
206
+ async #evaluate(component, key, value, prop, rescued) {
207
+ if (typeof value !== 'function')
208
+ return value;
209
+ try {
210
+ return await value();
211
+ }
212
+ catch (error) {
213
+ if (!prop?.rescue || !this.diagnostics) {
214
+ // Rethrown unchanged: the web error handler answers 500 and logs the full error.
215
+ if (this.diagnostics?.logger.isEnabled('debug'))
216
+ this.diagnostics.logger.log('debug', 'Inertia prop failed', { component, prop: key });
217
+ throw error;
218
+ }
219
+ rescued.push(key);
220
+ const repeated = this.diagnostics.repeats.take(`prop ${component} ${key}`);
221
+ if (repeated !== undefined)
222
+ this.diagnostics.logger.log('error', 'Inertia deferred prop failed and was rescued', {
223
+ code: 'INERTIA_PROP_RESCUED',
224
+ component,
225
+ prop: key,
226
+ error: errorText(error),
227
+ ...(repeated ? { repeated } : {}),
228
+ });
229
+ return RESCUED;
230
+ }
231
+ }
137
232
  /**
138
233
  * The SSR bundle's head and root for this page, or undefined to answer client rendered
139
234
  * (failure 'fallback'). The page object is a fresh copy, so the bundle cannot change the props.
@@ -180,3 +275,75 @@ export class InertiaResponder {
180
275
  : reply.redirect(url, 302);
181
276
  }
182
277
  }
278
+ const RESCUED = Symbol('rescued');
279
+ /**
280
+ * Which props this response computes and the page fields that announce the others, following
281
+ * the official adapters (D-0052): a partial reload selects by only/except and announces only the
282
+ * selected props; a first load leaves out deferred props (listed by group) and once props the
283
+ * client already holds. Reset props are not announced as merge props.
284
+ */
285
+ function buildMetadata(props, partial, only, except, reset, held) {
286
+ const selected = [];
287
+ const lazy = [];
288
+ const skippedOnce = [];
289
+ const deferredProps = {};
290
+ const lists = { append: [], prepend: [], deep: [] };
291
+ const matchPropsOn = [];
292
+ const onceProps = {};
293
+ let announced = false;
294
+ const now = Date.now();
295
+ for (const [key, input] of Object.entries(props)) {
296
+ const prop = isInertiaProp(input) ? input : undefined;
297
+ const value = prop ? prop.value : input;
298
+ if (key === 'errors') {
299
+ selected.push([key, value, undefined]);
300
+ continue;
301
+ }
302
+ if (partial && ((only.size > 0 && !only.has(key)) || except.has(key)))
303
+ continue;
304
+ if (prop) {
305
+ announced = true;
306
+ if (prop.merge && !reset.has(key)) {
307
+ lists[prop.merge.mode].push(key);
308
+ for (const field of prop.merge.matchOn)
309
+ matchPropsOn.push(`${key}.${field}`);
310
+ }
311
+ const onceKey = prop.once ? (prop.once.key ?? key) : undefined;
312
+ if (prop.once && onceKey !== undefined)
313
+ onceProps[onceKey] = {
314
+ prop: key,
315
+ expiresAt: prop.once.ttlMs === undefined ? null : now + prop.once.ttlMs,
316
+ };
317
+ if (!partial) {
318
+ // The client keeps its value (also for a deferred once prop: nothing to fetch).
319
+ if (onceKey !== undefined && held.has(onceKey)) {
320
+ skippedOnce.push(key);
321
+ continue;
322
+ }
323
+ if (prop.group !== undefined) {
324
+ const group = deferredProps[prop.group] ?? [];
325
+ group.push(key);
326
+ deferredProps[prop.group] = group;
327
+ continue;
328
+ }
329
+ }
330
+ }
331
+ if (typeof value === 'function')
332
+ lazy.push(key);
333
+ selected.push([key, value, prop]);
334
+ }
335
+ const page = {};
336
+ if (Object.keys(deferredProps).length)
337
+ page.deferredProps = deferredProps;
338
+ if (lists.append.length)
339
+ page.mergeProps = lists.append;
340
+ if (lists.prepend.length)
341
+ page.prependProps = lists.prepend;
342
+ if (lists.deep.length)
343
+ page.deepMergeProps = lists.deep;
344
+ if (matchPropsOn.length)
345
+ page.matchPropsOn = matchPropsOn;
346
+ if (Object.keys(onceProps).length)
347
+ page.onceProps = onceProps;
348
+ return { selected, lazy, skippedOnce, page, announced, rescued: [] };
349
+ }
@@ -5,6 +5,17 @@ export interface ViteDevServer {
5
5
  close(): Promise<void>;
6
6
  }
7
7
  export declare const viteDevRequested: () => boolean;
8
+ /** The part of a Node response the dev route finishes. */
9
+ export interface DevResponse {
10
+ statusCode: number;
11
+ readonly headersSent: boolean;
12
+ end(): unknown;
13
+ }
14
+ /**
15
+ * The `next` of Vite's middlewares on a dev route: no error means Vite has nothing for the URL
16
+ * (404); an error is reported (logged by the caller) and answered 500, never dropped (D-0047).
17
+ */
18
+ export declare function viteFallthrough(response: DevResponse, error: unknown, report: (error: unknown) => void): void;
8
19
  /**
9
20
  * Starts the project's Vite (its vite.config.*) in middleware mode. The HMR socket gets a free
10
21
  * port of its own: @dokebi/websocket answers every upgrade on the app's HTTP server and closes
package/dist/vite-dev.js CHANGED
@@ -7,6 +7,20 @@ import { createServer } from 'node:net';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  export const viteDevRequested = () => process.env.DOKEBI_VITE_DEV === '1';
10
+ /**
11
+ * The `next` of Vite's middlewares on a dev route: no error means Vite has nothing for the URL
12
+ * (404); an error is reported (logged by the caller) and answered 500, never dropped (D-0047).
13
+ */
14
+ export function viteFallthrough(response, error, report) {
15
+ if (error !== undefined && error !== null) {
16
+ report(error);
17
+ if (!response.headersSent)
18
+ response.statusCode = 500;
19
+ }
20
+ else if (!response.headersSent)
21
+ response.statusCode = 404;
22
+ response.end();
23
+ }
10
24
  function freePort() {
11
25
  return new Promise((resolve, reject) => {
12
26
  const server = createServer();
@@ -37,6 +51,7 @@ export async function startViteDev(root = process.cwd()) {
37
51
  return vite.createServer({
38
52
  root,
39
53
  appType: 'custom',
40
- server: { middlewareMode: true, hmr: { port: await freePort() } },
54
+ // Hosts are checked by the dev routes of the plugin (see index.ts), not by Vite.
55
+ server: { middlewareMode: true, allowedHosts: true, hmr: { port: await freePort() } },
41
56
  });
42
57
  }
@@ -66,6 +66,18 @@
66
66
  "description": "When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)"
67
67
  }
68
68
  }
69
+ },
70
+ "encryptHistory": {
71
+ "default": "sealed",
72
+ "oneOf": [
73
+ {
74
+ "type": "boolean"
75
+ },
76
+ {
77
+ "const": "sealed"
78
+ }
79
+ ],
80
+ "description": "Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts"
69
81
  }
70
82
  }
71
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/template-inertia",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@dokebi/core": "^0.2.0",
33
- "@dokebi/web": "^0.1.0",
33
+ "@dokebi/web": "^0.2.0",
34
34
  "fastify": "^5.12.7"
35
35
  },
36
36
  "dokebi": {
@@ -40,7 +40,12 @@
40
40
  "dependencies": [
41
41
  {
42
42
  "name": "dokebi-web",
43
- "version": "^0.1.0"
43
+ "version": "^0.2.0"
44
+ },
45
+ {
46
+ "name": "dokebi-session",
47
+ "version": "^0.1.0 || ^0.2.0",
48
+ "optional": true
44
49
  }
45
50
  ],
46
51
  "install": {
@@ -53,7 +58,8 @@
53
58
  "documentation": "AI.md"
54
59
  },
55
60
  "devDependencies": {
56
- "@dokebi/web": "0.1.1"
61
+ "@dokebi/session": "0.2.0",
62
+ "@dokebi/web": "0.2.0"
57
63
  },
58
64
  "publishConfig": {
59
65
  "access": "public",