@dokebi/template-inertia 0.1.1 → 0.1.3
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 +57 -6
- package/README.md +17 -3
- package/dist/index.d.ts +7 -1
- package/dist/index.js +190 -22
- package/dist/props.d.ts +77 -0
- package/dist/props.js +84 -0
- package/dist/repeats.d.ts +18 -0
- package/dist/repeats.js +52 -0
- package/dist/responder.d.ts +35 -3
- package/dist/responder.js +140 -7
- package/dist/vite-dev.d.ts +24 -0
- package/dist/vite-dev.js +57 -0
- package/package.json +8 -2
package/AI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# AI 작업 안내 — @dokebi/template-inertia 0.1.
|
|
1
|
+
# AI 작업 안내 — @dokebi/template-inertia 0.1.3
|
|
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
|
|
|
@@ -21,7 +21,7 @@ npm run build # dist/(브라우저) + dist
|
|
|
21
21
|
| `rootId` | 루트 요소 id(기본 `app`, `^[A-Za-z][A-Za-z0-9_-]*$`) |
|
|
22
22
|
| `version` · `scripts` · `styles` | 자산 버전과 URL(`/`로 시작하는 로컬 URL). 없으면 `INERTIA_VERSION_DEFAULT` 경고 |
|
|
23
23
|
| `vite: { manifest, entry, base? }` | manifest에서 scripts·styles·version 계산. 위 셋과 함께 쓰면 거부 |
|
|
24
|
-
| `ssr: { bundle, failure? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error` |
|
|
24
|
+
| `ssr: { bundle, failure?, entry? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error`. `entry`는 개발 모드에서 쓰는 번들의 원본 모듈(§5.1) |
|
|
25
25
|
|
|
26
26
|
## 3. 페이지 렌더
|
|
27
27
|
|
|
@@ -40,12 +40,50 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
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
|
|
43
|
+
- props: JSON 값, 비동기 lazy 함수, 또는 §3.1 도우미. partial reload(`only`·`except`)는 선택된 것만 평가하고 `errors`는 항상 포함. 비JSON 값·순환은 실패.
|
|
44
44
|
- `redirect(request, reply, localUrl)`: GET/HEAD 302, mutation 303, Inertia의 `#` 이동은 409 + `X-Inertia-Redirect`.
|
|
45
45
|
- `location(request, reply, url)`: 로컬 또는 http(s) 외부 이동. Inertia 요청은 409 + `X-Inertia-Location`.
|
|
46
|
+
|
|
47
|
+
### 3.1 deferred·merge·once props(D-0052)
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defer, merge, deepMerge, once } from '@dokebi/template-inertia';
|
|
51
|
+
page.inertia!.render(request, reply, 'Orders/Index', {
|
|
52
|
+
orders: merge(() => listOrders(page), { matchOn: 'id' }), // partial reload 때 배열 뒤에 붙임(같은 id는 교체)
|
|
53
|
+
stats: defer(() => computeStats()), // 첫 페이지에서 빼고 화면 뒤 group 'default'로 가져옴
|
|
54
|
+
chart: defer(() => chart(), 'charts'), // group마다 요청 하나
|
|
55
|
+
feed: defer(() => feed(), { merge: true, rescue: true }), // 던지면 빼고 rescuedProps + error 로그
|
|
56
|
+
plans: once(() => listPlans(), { ttlMs: 3_600_000 }), // 클라이언트가 가지면 다시 계산 안 함
|
|
57
|
+
settings: deepMerge({ theme: { dark: true } }),
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `defer(fn, group? | { group?, merge?, once?, rescue? })`: 첫 로드(HTML·Inertia 방문)에서 계산하지 않고 page `deferredProps: { group: [이름] }`. 클라이언트가 `only`로 다시 요청할 때만 `fn`이 돈다. SSR 첫 HTML에도 값이 없다(Vue `<Deferred data="stats">`의 `#fallback`).
|
|
62
|
+
- `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']`)이면 합치지 않고 바꾼다.
|
|
63
|
+
- `once(value | fn, { key?, ttlMs? })`: page `onceProps: { key: { prop, expiresAt } }`. 클라이언트가 `X-Inertia-Except-Once-Props`로 가진 key를 알리면 첫 로드에서 계산하지 않는다. partial reload가 그 prop을 `only`로 요청하면 다시 계산한다.
|
|
64
|
+
- 도우미는 `render` props·`shared` props 어디든 쓴다. 서로 감싸지 않는다(조합은 `defer` 옵션). group·key·matchOn에 쉼표·공백 금지.
|
|
65
|
+
- partial reload는 지금처럼 `only`·`except`로 고르고, 고른 deferred·once도 계산한다(`except`만 주면 deferred도 포함). 알림 필드도 고른 props만.
|
|
66
|
+
- rescue가 아닌 prop 함수의 실패는 지금처럼 요청 실패(500, web이 전체 오류 로그). rescue는 `INERTIA_PROP_RESCUED`(error, 10초에 한 번·`repeated`). debug: `Inertia props resolved`(evaluated·deferred·heldOnce·rescued), `Inertia prop failed`.
|
|
46
67
|
- 오래된 version의 Inertia 요청은 props 평가 없이 409 + `X-Inertia-Location`(새로고침 유도).
|
|
47
68
|
- 응답에 `Vary: X-Inertia`, `Cache-Control: no-store`가 붙고 HTML에 넣는 JSON·제목·URL은 escape된다.
|
|
48
69
|
|
|
70
|
+
### 3.2 session flash(D-0055)
|
|
71
|
+
|
|
72
|
+
@dokebi/session이 앱에 등록돼 있으면(선택 의존성, 자동으로 만들지 않음) 페이지 응답의 page **`flash` 필드**에 세션 flash가 들어간다. `takeFlash()`와 같아서 한 번 보이면 사라진다. 비어 있으면 필드가 없다. prop이 아니다(`props.flash` 아님).
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
route.post('/orders', async (request, reply, page) => {
|
|
76
|
+
await createOrder(request.body);
|
|
77
|
+
await request.session.flash('success', 'Order saved'); // @dokebi/session
|
|
78
|
+
return page.inertia!.redirect(request, reply, '/orders'); // 303
|
|
79
|
+
});
|
|
80
|
+
// Vue: usePage().flash.success, 또는 router.on('flash', (event) => toast(event.detail.flash))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- 클라이언트(3.9.1)는 flash를 history에 넣지 않아 뒤로 가기에 다시 보이지 않는다.
|
|
84
|
+
- deferred props만 가져오는 partial reload는 flash를 읽지 않는다(클라이언트가 버리므로). 첫 HTML·방문·그 밖의 partial reload는 읽는다. props 평가가 실패한 렌더는 flash를 남긴다.
|
|
85
|
+
- 세션 플러그인이 없으면 page는 전과 같다.
|
|
86
|
+
|
|
49
87
|
## 4. 빌드 파일 서빙
|
|
50
88
|
|
|
51
89
|
`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,11 +92,24 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
54
92
|
|
|
55
93
|
- `ssr.bundle`은 `render(page) → { head: string[]; body: string }`을 export하는 빌드 파일이다. Inertia v3에서는 `createInertiaApp({ resolve, layout })`을 서버에서 page 없이 부르면 `(page, renderToString)` 함수를 준다(`--ssr`이 만드는 `views/ssr.js` 참고).
|
|
56
94
|
- 번들은 시작 때 한 번 import한다. **없거나 `render`가 없으면 시작 실패.**
|
|
57
|
-
- 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500).
|
|
95
|
+
- 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500). 로그의 `error`는 이름·전체 메시지·스택·`Caused by:` 원인, URL 인증 정보만 `//***@`로 지우고 16KB 넘으면 잘라 표시이고, 같은 번들·컴포넌트는 10초에 한 번만 남기며 사이 횟수는 다음 로그의 `repeated`(D-0047). `ssrStats().failures`는 모두 센다.
|
|
58
96
|
- head에 `<title>`이 있으면 설정 title은 빠진다. Inertia 이동(JSON)은 SSR을 거치지 않는다.
|
|
59
97
|
- 브라우저 진입은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다. 서버·브라우저가 같은 레이아웃을 고르도록 페이지 모듈을 바꾸지 말고 `layout` 옵션을 쓴다(동시에 여러 템플릿을 그리므로).
|
|
60
98
|
- `ssrStats()` → `{ renders, failures }`(health·대시보드용). `scope({ ssr: false })`로 앱별로 끌 수 있다.
|
|
61
99
|
|
|
100
|
+
## 5.1 개발 모드(HMR, D-0045)
|
|
101
|
+
|
|
102
|
+
- 환경 변수 `DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 manifest·빌드 없이 **프로젝트의 Vite를 미들웨어 모드로** 한 프로세스에 하나 띄운다. 페이지 스크립트는 `<base>@vite/client`와 `<base><vite.entry>`, 스타일 링크 없음, version `dev`. `<base>*` 요청은 Vite가 답한다.
|
|
103
|
+
- HMR 소켓은 프로세스마다 빈 포트 하나를 따로 쓴다(앱 포트는 @dokebi/websocket이 upgrade를 모두 받으므로). 브라우저는 페이지와 같은 호스트로 붙는다.
|
|
104
|
+
- SSR은 요청마다 `ssrLoadModule(<원본 진입>)`. 원본 진입은 `ssr.entry`(예: `src/apps/admin/views/ssr.js`), 없으면 `vite.entry`와 같은 폴더의 `ssr.js`. 둘 다 없으면 시작 실패.
|
|
105
|
+
- Vite가 프로젝트에 없으면 시작 실패(`npm install --save-dev vite`).
|
|
106
|
+
- 호스트: Vite 자체 검사(`allowedHosts`)는 끄고, `<base>*`는 루프백 이름(`localhost`·`*.localhost`·127.x·::1), @dokebi/app의 `domains`, 이 프로세스가 Inertia 페이지로 답한 호스트에만 답한다(나머지 403). 그래서 hosts 파일·터널의 앱 도메인(예: `a.com`)으로도 개발할 수 있고 DNS rebinding은 막힌다.
|
|
107
|
+
- cluster 앱은 `dokebi dev`에서 워커 1개로 돈다(@dokebi/cluster 0.1.2).
|
|
108
|
+
- 절대 경로 import를 빌드에서 `external`로 둔 경우, 개발 서버는 그 경로 앞에 `<base>`를 붙인다. 개발 서버에서는 실제 파일로 풀어 주는 Vite 플러그인을 쓴다(플레이그라운드 `vite.config.ts` 참고).
|
|
109
|
+
- 운영(`DOKEBI_VITE_DEV` 없음)에는 영향이 없다. 운영 서버에 이 변수를 넣지 않는다.
|
|
110
|
+
- Vite 미들웨어가 오류를 넘기면 500으로 답하고 `INERTIA_VITE_DEV_FAILED`(error, 10초에 한 번·`repeated`) 로그를 남긴다. 처리할 것이 없을 때만 404.
|
|
111
|
+
- 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가 켜졌을 때만).
|
|
112
|
+
|
|
62
113
|
## 6. 범위(scope)
|
|
63
114
|
|
|
64
115
|
`await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared? })` → 그 설정의 렌더러. 정하지 않은 값은 플러그인 옵션, `shared`는 플러그인 shared 뒤에 합쳐진다. @dokebi/app이 앱마다 부른다.
|
|
@@ -68,7 +119,7 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
68
119
|
- props에 비밀·개인정보를 넣지 않는다(HTML에 그대로 실린다). 인증·인가·CSRF는 앱이 구현한다.
|
|
69
120
|
- 데이터 조회·수정 API는 Inertia 응답이 아니라 일반 JSON 라우트(REST)로 만든다.
|
|
70
121
|
- 배포에는 빌드별 version(또는 `vite`)을 꼭 쓴다. 기본 version이면 자산이 바뀌어도 클라이언트가 모른다.
|
|
71
|
-
-
|
|
122
|
+
- Precognition, `scrollProps`(무한 스크롤), `sharedProps`, 점 경로 partial reload(`only: ['user.name']`)는 없다. 있다고 가정하지 않는다. session flash는 @dokebi/session이 있을 때만(§3.2).
|
|
72
123
|
- 앱 인스턴스마다 새 플러그인. 전체 drain 뒤에 responder가 닫힌다.
|
|
73
124
|
|
|
74
|
-
근거: `examples/24-template-inertia`(실제 Chrome: CSR·SSR·cluster, 측정), `examples/26-scaffold`(CLI가 만든 SSR 앱의 빌드·서버 렌더), `examples/25-playground`, D-0027·D-0031·D-0039.
|
|
125
|
+
근거: `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.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @dokebi/template-inertia 0.1.
|
|
1
|
+
# @dokebi/template-inertia 0.1.3
|
|
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
|
|
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 절)이다.
|
|
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을 거치지 않는다.
|
|
@@ -64,3 +74,7 @@ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('
|
|
|
64
74
|
- `scope({ ssr })`: 앱마다 번들을 정한다. 생략하면 플러그인 옵션을 따르고 `false`면 끈다. 같은 파일은 한 번만 import한다.
|
|
65
75
|
|
|
66
76
|
측정(예제 24, 같은 프로세스, 순차 400회): CSR 0.20~0.24ms, SSR 0.24~0.28ms(작은 페이지), SSR 450장 뒤 heap +1.1MB.
|
|
77
|
+
|
|
78
|
+
## 개발 모드(HMR, [D-0045](../../docs/decisions/D-0045-inertia-vite-dev.md))
|
|
79
|
+
|
|
80
|
+
`DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 빌드 없이 프로젝트의 Vite를 미들웨어 모드로 띄워 `<base>*`를 Vite가 답하고, 페이지는 `@vite/client`와 원본 진입을 불러온다. `.vue`를 고치면 새로고침 없이 바뀐다(HMR 소켓은 프로세스마다 따로 잡은 빈 포트). SSR은 요청마다 Vite로 원본(`ssr.entry`, 없으면 `vite.entry` 옆 `ssr.js`)을 그린다. 운영에는 영향이 없다.
|
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
|
|
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, 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;
|
|
@@ -18,6 +19,11 @@ export interface InertiaSsrOptions {
|
|
|
18
19
|
* `error` fails the request, which is easier to notice while developing.
|
|
19
20
|
*/
|
|
20
21
|
readonly failure?: 'fallback' | 'error';
|
|
22
|
+
/**
|
|
23
|
+
* Source module of the bundle (for example `src/apps/admin/views/ssr.js`), rendered through
|
|
24
|
+
* Vite in development (D-0045). Default: `ssr.js` beside the `vite.entry` when it exists.
|
|
25
|
+
*/
|
|
26
|
+
readonly entry?: string;
|
|
21
27
|
}
|
|
22
28
|
/** Counts since start, for health checks and dashboards. */
|
|
23
29
|
export interface InertiaSsrStats {
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { readFile, stat } from 'node:fs/promises';
|
|
3
4
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { types } from 'node:util';
|
|
6
|
-
import { definePlugin, providerDependency, } from '@dokebi/core';
|
|
7
|
+
import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
|
|
8
|
+
import { errorText, Repeats } from './repeats.js';
|
|
7
9
|
import { InertiaResponder, normalizeInertiaOptions, } from './responder.js';
|
|
10
|
+
import { startViteDev, viteDevRequested, viteFallthrough } from './vite-dev.js';
|
|
11
|
+
export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
|
|
8
12
|
const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
|
|
9
13
|
function parseVite(input) {
|
|
10
14
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
@@ -25,13 +29,14 @@ function parseSsr(input) {
|
|
|
25
29
|
return undefined;
|
|
26
30
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
27
31
|
throw new TypeError('Invalid Inertia ssr options');
|
|
28
|
-
const { bundle, failure = 'fallback', ...rest } = input;
|
|
32
|
+
const { bundle, failure = 'fallback', entry, ...rest } = input;
|
|
29
33
|
if (Object.keys(rest).length ||
|
|
30
34
|
typeof bundle !== 'string' ||
|
|
31
35
|
!bundle ||
|
|
32
|
-
(failure !== 'fallback' && failure !== 'error')
|
|
36
|
+
(failure !== 'fallback' && failure !== 'error') ||
|
|
37
|
+
(entry !== undefined && (typeof entry !== 'string' || !entry || entry.includes('..'))))
|
|
33
38
|
throw new TypeError('Invalid Inertia ssr options');
|
|
34
|
-
return Object.freeze({ bundle, failure });
|
|
39
|
+
return Object.freeze({ bundle, failure, ...(entry ? { entry: entry } : {}) });
|
|
35
40
|
}
|
|
36
41
|
/**
|
|
37
42
|
* Imports the SSR bundle. A missing file or a bundle without `render` stops the start: SSR that
|
|
@@ -136,6 +141,10 @@ const web = providerDependency({
|
|
|
136
141
|
packageName: '@dokebi/web',
|
|
137
142
|
load: () => import('@dokebi/web'),
|
|
138
143
|
});
|
|
144
|
+
const session = optionalDependency({
|
|
145
|
+
name: 'dokebi-session',
|
|
146
|
+
version: '^0.1.0',
|
|
147
|
+
});
|
|
139
148
|
function parseOptions(input) {
|
|
140
149
|
const { vite, ssr: ssrInput, ...rest } = (input ?? {});
|
|
141
150
|
const ssr = parseSsr(ssrInput);
|
|
@@ -194,10 +203,119 @@ class InertiaRuntime {
|
|
|
194
203
|
code: 'INERTIA_VERSION_DEFAULT',
|
|
195
204
|
});
|
|
196
205
|
const server = context.getDependency(web);
|
|
206
|
+
const logger = context.logger;
|
|
207
|
+
const sessions = context.getOptionalDependency(session);
|
|
208
|
+
const flash = sessions
|
|
209
|
+
? async (request) => (await sessions.of(request).takeFlash())
|
|
210
|
+
: undefined;
|
|
211
|
+
// Development (D-0045): one Vite dev server for every scope, started by the first `vite`.
|
|
212
|
+
const develop = viteDevRequested();
|
|
213
|
+
logger.log('debug', 'Inertia settings resolved', {
|
|
214
|
+
develop,
|
|
215
|
+
vite: this.settings.vite ? { ...this.settings.vite } : false,
|
|
216
|
+
ssr: this.settings.ssr ? { ...this.settings.ssr } : false,
|
|
217
|
+
defaultVersion: this.settings.defaultVersion,
|
|
218
|
+
shared: this.shared !== undefined,
|
|
219
|
+
flash: flash !== undefined,
|
|
220
|
+
});
|
|
221
|
+
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
222
|
+
const repeats = new Repeats();
|
|
223
|
+
let devServer;
|
|
224
|
+
const devBases = new Set();
|
|
225
|
+
// Vite refuses hosts other than localhost (DNS rebinding). Its check is off; instead the dev
|
|
226
|
+
// routes serve loopback names, the domains of @dokebi/app (the same in every cluster worker)
|
|
227
|
+
// and hosts this process answered with an Inertia page. An app domain (a.com through a hosts
|
|
228
|
+
// file or a tunnel) works; an unknown host does not.
|
|
229
|
+
const appDomains = context.app.config['dokebi-app']
|
|
230
|
+
?.domains;
|
|
231
|
+
const pageHosts = new Set(Object.keys(appDomains ?? {}).map((host) => host.toLowerCase().replace(/:\d+$/, '')));
|
|
232
|
+
const hostName = (request) => server
|
|
233
|
+
.requestHost(request)
|
|
234
|
+
.replace(/:\d+$/, '')
|
|
235
|
+
.replace(/^\[(.*)\]$/, '$1');
|
|
236
|
+
const devHostAllowed = (host) => host === 'localhost' ||
|
|
237
|
+
host.endsWith('.localhost') ||
|
|
238
|
+
host === '::1' ||
|
|
239
|
+
/^127\.\d+\.\d+\.\d+$/.test(host) ||
|
|
240
|
+
pageHosts.has(host);
|
|
241
|
+
// Registered now: scopes are created later, from other plugins' setup.
|
|
242
|
+
context.defer(async () => {
|
|
243
|
+
if (devServer)
|
|
244
|
+
await (await devServer).close();
|
|
245
|
+
});
|
|
246
|
+
const viteDev = (base) => {
|
|
247
|
+
if (!devServer) {
|
|
248
|
+
const begin = performance.now();
|
|
249
|
+
// A start failure reaches setup or the request that awaits the server.
|
|
250
|
+
devServer = startViteDev().then((started) => {
|
|
251
|
+
logger.log('debug', 'Vite dev server started', {
|
|
252
|
+
ms: Math.round(performance.now() - begin),
|
|
253
|
+
hosts: [...pageHosts].slice(0, 64),
|
|
254
|
+
});
|
|
255
|
+
return started;
|
|
256
|
+
});
|
|
257
|
+
context.logger.log('info', 'Inertia views are served by the Vite dev server', {
|
|
258
|
+
code: 'INERTIA_VITE_DEV',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const current = devServer;
|
|
262
|
+
if (!devBases.has(base)) {
|
|
263
|
+
devBases.add(base);
|
|
264
|
+
// Modules, pre-bundled dependencies and @vite/client, under the Vite base.
|
|
265
|
+
server.route({
|
|
266
|
+
method: 'GET', // Fastify adds HEAD
|
|
267
|
+
url: `${base}*`,
|
|
268
|
+
handler: async (request, reply) => {
|
|
269
|
+
const host = hostName(request);
|
|
270
|
+
if (!devHostAllowed(host)) {
|
|
271
|
+
if (logger.isEnabled('debug'))
|
|
272
|
+
logger.log('debug', 'Vite dev route refused a host', {
|
|
273
|
+
host: host.slice(0, 253),
|
|
274
|
+
base,
|
|
275
|
+
});
|
|
276
|
+
return reply
|
|
277
|
+
.code(403)
|
|
278
|
+
.type('text/plain; charset=utf-8')
|
|
279
|
+
.send('This host has not been served an Inertia page by this app.');
|
|
280
|
+
}
|
|
281
|
+
const vite = await current;
|
|
282
|
+
reply.hijack();
|
|
283
|
+
vite.middlewares(request.raw, reply.raw, (error) => viteFallthrough(reply.raw, error, (failure) => {
|
|
284
|
+
const repeated = repeats.take(`vite ${failure instanceof Error ? failure.name : typeof failure}`);
|
|
285
|
+
if (repeated !== undefined)
|
|
286
|
+
logger.log('error', 'Vite dev middleware failed', {
|
|
287
|
+
code: 'INERTIA_VITE_DEV_FAILED',
|
|
288
|
+
base,
|
|
289
|
+
error: errorText(failure),
|
|
290
|
+
...(repeated ? { repeated } : {}),
|
|
291
|
+
});
|
|
292
|
+
}));
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
logger.log('debug', 'Vite dev route registered', { url: `${base}*` });
|
|
296
|
+
}
|
|
297
|
+
return current;
|
|
298
|
+
};
|
|
197
299
|
// Built files are served at their exact URL (no wildcard), once even if apps share chunks.
|
|
198
300
|
const served = new Set();
|
|
199
301
|
const built = async (options) => {
|
|
302
|
+
if (develop) {
|
|
303
|
+
await viteDev(options.base);
|
|
304
|
+
return {
|
|
305
|
+
scripts: [`${options.base}@vite/client`, `${options.base}${options.entry}`],
|
|
306
|
+
styles: [],
|
|
307
|
+
version: 'dev',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
200
310
|
const { files, ...assets } = await viteAssets(options);
|
|
311
|
+
logger.log('debug', 'Vite manifest read', {
|
|
312
|
+
manifest: options.manifest,
|
|
313
|
+
entry: options.entry,
|
|
314
|
+
scripts: assets.scripts,
|
|
315
|
+
styles: assets.styles.length,
|
|
316
|
+
files: files.size,
|
|
317
|
+
version: assets.version,
|
|
318
|
+
});
|
|
201
319
|
for (const [url, file] of files) {
|
|
202
320
|
if (served.has(url))
|
|
203
321
|
continue;
|
|
@@ -223,14 +341,43 @@ class InertiaRuntime {
|
|
|
223
341
|
// One import per bundle file, shared by the scopes that name it.
|
|
224
342
|
const bundles = new Map();
|
|
225
343
|
const stats = { renders: 0, failures: 0 };
|
|
226
|
-
const serverRendering = async (ssr) => {
|
|
344
|
+
const serverRendering = async (ssr, vite) => {
|
|
227
345
|
if (!ssr)
|
|
228
346
|
return undefined;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
347
|
+
let render;
|
|
348
|
+
if (develop) {
|
|
349
|
+
// The source module through Vite on every render: an edit shows in the next first HTML.
|
|
350
|
+
const fallback = vite ? `${dirname(vite.entry)}/ssr.js` : undefined;
|
|
351
|
+
const source = ssr.entry ?? (fallback && existsSync(resolve(fallback)) ? fallback : undefined);
|
|
352
|
+
if (!source || !vite)
|
|
353
|
+
throw new Error(`Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
|
|
354
|
+
const dev = viteDev(vite.base);
|
|
355
|
+
logger.log('debug', 'Inertia SSR renders the source through Vite', {
|
|
356
|
+
source,
|
|
357
|
+
bundle: ssr.bundle,
|
|
358
|
+
entrySet: ssr.entry !== undefined,
|
|
359
|
+
});
|
|
360
|
+
render = Promise.resolve(async (page) => {
|
|
361
|
+
const module = await (await dev).ssrLoadModule(`/${source}`);
|
|
362
|
+
if (typeof module.render !== 'function')
|
|
363
|
+
throw new Error(`Inertia SSR entry ${source} does not export render(page)`);
|
|
364
|
+
return module.render(page);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const path = resolve(ssr.bundle);
|
|
369
|
+
render = bundles.get(path);
|
|
370
|
+
if (!render) {
|
|
371
|
+
const begin = performance.now();
|
|
372
|
+
render = loadBundle(ssr.bundle);
|
|
373
|
+
bundles.set(path, render);
|
|
374
|
+
await render;
|
|
375
|
+
logger.log('debug', 'Inertia SSR bundle loaded', {
|
|
376
|
+
bundle: path,
|
|
377
|
+
failure: ssr.failure,
|
|
378
|
+
ms: Math.round(performance.now() - begin),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
234
381
|
}
|
|
235
382
|
return Object.freeze({
|
|
236
383
|
render: await render,
|
|
@@ -240,13 +387,17 @@ class InertiaRuntime {
|
|
|
240
387
|
},
|
|
241
388
|
failed: (error, component) => {
|
|
242
389
|
stats.failures++;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
390
|
+
// Every failure counts in ssrStats; the log repeats once per interval per component.
|
|
391
|
+
const repeated = repeats.take(`ssr ${ssr.bundle} ${component}`);
|
|
392
|
+
if (repeated !== undefined)
|
|
393
|
+
context.logger.log('error', 'Inertia SSR render failed', {
|
|
394
|
+
code: 'INERTIA_SSR_FAILED',
|
|
395
|
+
bundle: ssr.bundle,
|
|
396
|
+
component,
|
|
397
|
+
fallback: ssr.failure === 'fallback',
|
|
398
|
+
error: errorText(error),
|
|
399
|
+
...(repeated ? { repeated } : {}),
|
|
400
|
+
});
|
|
250
401
|
},
|
|
251
402
|
});
|
|
252
403
|
};
|
|
@@ -257,13 +408,21 @@ class InertiaRuntime {
|
|
|
257
408
|
});
|
|
258
409
|
const api = (responder) => {
|
|
259
410
|
responders.push(responder);
|
|
411
|
+
const render = responder.render.bind(responder);
|
|
260
412
|
return Object.freeze({
|
|
261
|
-
render:
|
|
413
|
+
render: develop
|
|
414
|
+
? (request, reply, component, props) => {
|
|
415
|
+
pageHosts.add(hostName(request));
|
|
416
|
+
return render(request, reply, component, props);
|
|
417
|
+
}
|
|
418
|
+
: render,
|
|
262
419
|
redirect: responder.redirect.bind(responder),
|
|
263
420
|
location: responder.location.bind(responder),
|
|
264
421
|
});
|
|
265
422
|
};
|
|
266
|
-
|
|
423
|
+
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
424
|
+
const diagnostics = Object.freeze({ logger, repeats });
|
|
425
|
+
const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash));
|
|
267
426
|
const shared = this.shared;
|
|
268
427
|
return Object.freeze({
|
|
269
428
|
...root,
|
|
@@ -283,17 +442,26 @@ class InertiaRuntime {
|
|
|
283
442
|
const merged = shared && extra
|
|
284
443
|
? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
|
|
285
444
|
: (extra ?? shared);
|
|
286
|
-
|
|
445
|
+
const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
|
|
446
|
+
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash);
|
|
447
|
+
logger.log('debug', 'Inertia scope created', {
|
|
448
|
+
title: scoped.title,
|
|
449
|
+
rootId: scoped.rootId,
|
|
450
|
+
vite: scopedVite ? scopedVite.entry : false,
|
|
451
|
+
ssr: ssr ? ssr.bundle : false,
|
|
452
|
+
shared: merged !== undefined,
|
|
453
|
+
});
|
|
454
|
+
return api(responder);
|
|
287
455
|
},
|
|
288
456
|
});
|
|
289
457
|
}
|
|
290
458
|
}
|
|
291
459
|
export const createPlugin = definePlugin({
|
|
292
460
|
name: 'dokebi-template-inertia',
|
|
293
|
-
version: '0.1.
|
|
461
|
+
version: '0.1.3',
|
|
294
462
|
core: '^0.2.0',
|
|
295
463
|
options: parseOptions,
|
|
296
464
|
entry: parseEntry,
|
|
297
|
-
dependencies: () => [web],
|
|
465
|
+
dependencies: () => [web, session],
|
|
298
466
|
instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
|
|
299
467
|
});
|
package/dist/props.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/repeats.js
ADDED
|
@@ -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
|
+
}
|
package/dist/responder.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
|
-
|
|
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;
|
|
@@ -19,6 +22,33 @@ export interface InertiaPage {
|
|
|
19
22
|
readonly version: string;
|
|
20
23
|
readonly clearHistory: boolean;
|
|
21
24
|
readonly encryptHistory: boolean;
|
|
25
|
+
/** Props left out of this page by group; the client fetches each group after showing it. */
|
|
26
|
+
readonly deferredProps?: Readonly<Record<string, readonly string[]>>;
|
|
27
|
+
/** Props the client appends (arrays) or shallow merges (objects) on partial reloads. */
|
|
28
|
+
readonly mergeProps?: readonly string[];
|
|
29
|
+
readonly prependProps?: readonly string[];
|
|
30
|
+
readonly deepMergeProps?: readonly string[];
|
|
31
|
+
/** `<prop>.<field>` pairs: merged items with the same field value replace each other. */
|
|
32
|
+
readonly matchPropsOn?: readonly string[];
|
|
33
|
+
/** Once props by key: the client keeps them and names held keys in X-Inertia-Except-Once-Props. */
|
|
34
|
+
readonly onceProps?: Readonly<Record<string, {
|
|
35
|
+
readonly prop: string;
|
|
36
|
+
readonly expiresAt: number | null;
|
|
37
|
+
}>>;
|
|
38
|
+
/** Deferred props whose function threw and was rescued (left out of props). */
|
|
39
|
+
readonly rescuedProps?: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* Flash data of the session (D-0055), read once; only when @dokebi/session is registered and
|
|
42
|
+
* has flash. The client fires its `flash` event and does not keep it in history.
|
|
43
|
+
*/
|
|
44
|
+
readonly flash?: Readonly<Record<string, Json>>;
|
|
45
|
+
}
|
|
46
|
+
/** Reads (and removes) the flash of the request's session. */
|
|
47
|
+
export type InertiaFlash = (request: FastifyRequest) => Promise<Record<string, Json>>;
|
|
48
|
+
/** Where the responder reports prop evaluation (debug) and rescued prop failures (error). */
|
|
49
|
+
export interface InertiaDiagnostics {
|
|
50
|
+
readonly logger: Logger;
|
|
51
|
+
readonly repeats: Repeats;
|
|
22
52
|
}
|
|
23
53
|
/** What an SSR bundle's `render(page)` returns: head tags and the root (script + element). */
|
|
24
54
|
export interface InertiaSsrResult {
|
|
@@ -47,8 +77,10 @@ export declare class InertiaResponder implements InertiaApi {
|
|
|
47
77
|
#private;
|
|
48
78
|
readonly shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined;
|
|
49
79
|
readonly ssr?: InertiaSsr | undefined;
|
|
80
|
+
readonly diagnostics?: InertiaDiagnostics | undefined;
|
|
81
|
+
readonly flash?: InertiaFlash | undefined;
|
|
50
82
|
readonly options: NormalizedInertiaOptions;
|
|
51
|
-
constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined);
|
|
83
|
+
constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined);
|
|
52
84
|
close(): void;
|
|
53
85
|
render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props): Promise<never>;
|
|
54
86
|
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>;
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char);
|
|
2
4
|
function localUrl(value) {
|
|
3
5
|
if (typeof value !== 'string' ||
|
|
@@ -49,11 +51,15 @@ export function normalizeInertiaOptions(options = {}) {
|
|
|
49
51
|
export class InertiaResponder {
|
|
50
52
|
shared;
|
|
51
53
|
ssr;
|
|
54
|
+
diagnostics;
|
|
55
|
+
flash;
|
|
52
56
|
options;
|
|
53
57
|
#closed = false;
|
|
54
|
-
constructor(options = {}, shared, ssr) {
|
|
58
|
+
constructor(options = {}, shared, ssr, diagnostics, flash) {
|
|
55
59
|
this.shared = shared;
|
|
56
60
|
this.ssr = ssr;
|
|
61
|
+
this.diagnostics = diagnostics;
|
|
62
|
+
this.flash = flash;
|
|
57
63
|
this.options = normalizeInertiaOptions(options);
|
|
58
64
|
}
|
|
59
65
|
close() {
|
|
@@ -90,15 +96,37 @@ export class InertiaResponder {
|
|
|
90
96
|
.map((x) => x.trim())
|
|
91
97
|
.filter(Boolean))
|
|
92
98
|
: new Set();
|
|
93
|
-
const only = list('x-inertia-partial-data'), except = list('x-inertia-partial-except')
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
99
|
+
const only = list('x-inertia-partial-data'), except = list('x-inertia-partial-except'), reset = list('x-inertia-reset'),
|
|
100
|
+
// Once keys whose value the client holds; a full HTML load holds nothing.
|
|
101
|
+
held = inertia ? list('x-inertia-except-once-props') : new Set();
|
|
102
|
+
const meta = buildMetadata(props, partial, only, except, reset, held);
|
|
103
|
+
// The client ignores the flash of a deferred-props fetch (it keeps the page's), so such a
|
|
104
|
+
// request leaves the session's flash for the next page instead of using it up.
|
|
105
|
+
const deferredFetch = partial &&
|
|
106
|
+
only.size > 0 &&
|
|
107
|
+
[...only].every((key) => {
|
|
108
|
+
const value = Object.hasOwn(props, key) ? props[key] : undefined;
|
|
109
|
+
return isInertiaProp(value) && value.group !== undefined;
|
|
110
|
+
});
|
|
111
|
+
const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
|
|
112
|
+
// Read after the props: a failed render leaves the flash for the next page.
|
|
113
|
+
const flash = this.flash && !deferredFetch ? await this.flash(request) : undefined;
|
|
97
114
|
const resolved = Object.create(null);
|
|
98
115
|
resolved.errors = {};
|
|
99
|
-
selected.forEach(([key], index) => {
|
|
100
|
-
|
|
116
|
+
meta.selected.forEach(([key], index) => {
|
|
117
|
+
if (values[index] !== RESCUED)
|
|
118
|
+
resolved[key] = values[index];
|
|
101
119
|
});
|
|
120
|
+
const logger = this.diagnostics?.logger;
|
|
121
|
+
if (logger?.isEnabled('debug') && (meta.lazy.length || meta.announced))
|
|
122
|
+
logger.log('debug', 'Inertia props resolved', {
|
|
123
|
+
component,
|
|
124
|
+
partial,
|
|
125
|
+
evaluated: meta.lazy.slice(0, 64),
|
|
126
|
+
...(meta.page.deferredProps ? { deferred: meta.page.deferredProps } : {}),
|
|
127
|
+
...(meta.skippedOnce.length ? { heldOnce: meta.skippedOnce.slice(0, 64) } : {}),
|
|
128
|
+
...(meta.rescued.length ? { rescued: meta.rescued } : {}),
|
|
129
|
+
});
|
|
102
130
|
const page = {
|
|
103
131
|
component,
|
|
104
132
|
props: resolved,
|
|
@@ -106,6 +134,9 @@ export class InertiaResponder {
|
|
|
106
134
|
version: this.options.version,
|
|
107
135
|
clearHistory: false,
|
|
108
136
|
encryptHistory: false,
|
|
137
|
+
...meta.page,
|
|
138
|
+
...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
|
|
139
|
+
...(flash && Object.keys(flash).length ? { flash } : {}),
|
|
109
140
|
};
|
|
110
141
|
// Reject unsupported values rather than silently dropping application data.
|
|
111
142
|
const json = JSON.stringify(page, (_key, value) => {
|
|
@@ -134,6 +165,36 @@ export class InertiaResponder {
|
|
|
134
165
|
.type('text/html; charset=utf-8')
|
|
135
166
|
.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
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* The value of one selected prop. A function runs here; a rescued deferred prop that throws is
|
|
170
|
+
* logged and left out, any other failure rejects the render as before (D-0052).
|
|
171
|
+
*/
|
|
172
|
+
async #evaluate(component, key, value, prop, rescued) {
|
|
173
|
+
if (typeof value !== 'function')
|
|
174
|
+
return value;
|
|
175
|
+
try {
|
|
176
|
+
return await value();
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (!prop?.rescue || !this.diagnostics) {
|
|
180
|
+
// Rethrown unchanged: the web error handler answers 500 and logs the full error.
|
|
181
|
+
if (this.diagnostics?.logger.isEnabled('debug'))
|
|
182
|
+
this.diagnostics.logger.log('debug', 'Inertia prop failed', { component, prop: key });
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
rescued.push(key);
|
|
186
|
+
const repeated = this.diagnostics.repeats.take(`prop ${component} ${key}`);
|
|
187
|
+
if (repeated !== undefined)
|
|
188
|
+
this.diagnostics.logger.log('error', 'Inertia deferred prop failed and was rescued', {
|
|
189
|
+
code: 'INERTIA_PROP_RESCUED',
|
|
190
|
+
component,
|
|
191
|
+
prop: key,
|
|
192
|
+
error: errorText(error),
|
|
193
|
+
...(repeated ? { repeated } : {}),
|
|
194
|
+
});
|
|
195
|
+
return RESCUED;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
137
198
|
/**
|
|
138
199
|
* The SSR bundle's head and root for this page, or undefined to answer client rendered
|
|
139
200
|
* (failure 'fallback'). The page object is a fresh copy, so the bundle cannot change the props.
|
|
@@ -180,3 +241,75 @@ export class InertiaResponder {
|
|
|
180
241
|
: reply.redirect(url, 302);
|
|
181
242
|
}
|
|
182
243
|
}
|
|
244
|
+
const RESCUED = Symbol('rescued');
|
|
245
|
+
/**
|
|
246
|
+
* Which props this response computes and the page fields that announce the others, following
|
|
247
|
+
* the official adapters (D-0052): a partial reload selects by only/except and announces only the
|
|
248
|
+
* selected props; a first load leaves out deferred props (listed by group) and once props the
|
|
249
|
+
* client already holds. Reset props are not announced as merge props.
|
|
250
|
+
*/
|
|
251
|
+
function buildMetadata(props, partial, only, except, reset, held) {
|
|
252
|
+
const selected = [];
|
|
253
|
+
const lazy = [];
|
|
254
|
+
const skippedOnce = [];
|
|
255
|
+
const deferredProps = {};
|
|
256
|
+
const lists = { append: [], prepend: [], deep: [] };
|
|
257
|
+
const matchPropsOn = [];
|
|
258
|
+
const onceProps = {};
|
|
259
|
+
let announced = false;
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
for (const [key, input] of Object.entries(props)) {
|
|
262
|
+
const prop = isInertiaProp(input) ? input : undefined;
|
|
263
|
+
const value = prop ? prop.value : input;
|
|
264
|
+
if (key === 'errors') {
|
|
265
|
+
selected.push([key, value, undefined]);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (partial && ((only.size > 0 && !only.has(key)) || except.has(key)))
|
|
269
|
+
continue;
|
|
270
|
+
if (prop) {
|
|
271
|
+
announced = true;
|
|
272
|
+
if (prop.merge && !reset.has(key)) {
|
|
273
|
+
lists[prop.merge.mode].push(key);
|
|
274
|
+
for (const field of prop.merge.matchOn)
|
|
275
|
+
matchPropsOn.push(`${key}.${field}`);
|
|
276
|
+
}
|
|
277
|
+
const onceKey = prop.once ? (prop.once.key ?? key) : undefined;
|
|
278
|
+
if (prop.once && onceKey !== undefined)
|
|
279
|
+
onceProps[onceKey] = {
|
|
280
|
+
prop: key,
|
|
281
|
+
expiresAt: prop.once.ttlMs === undefined ? null : now + prop.once.ttlMs,
|
|
282
|
+
};
|
|
283
|
+
if (!partial) {
|
|
284
|
+
// The client keeps its value (also for a deferred once prop: nothing to fetch).
|
|
285
|
+
if (onceKey !== undefined && held.has(onceKey)) {
|
|
286
|
+
skippedOnce.push(key);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (prop.group !== undefined) {
|
|
290
|
+
const group = deferredProps[prop.group] ?? [];
|
|
291
|
+
group.push(key);
|
|
292
|
+
deferredProps[prop.group] = group;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (typeof value === 'function')
|
|
298
|
+
lazy.push(key);
|
|
299
|
+
selected.push([key, value, prop]);
|
|
300
|
+
}
|
|
301
|
+
const page = {};
|
|
302
|
+
if (Object.keys(deferredProps).length)
|
|
303
|
+
page.deferredProps = deferredProps;
|
|
304
|
+
if (lists.append.length)
|
|
305
|
+
page.mergeProps = lists.append;
|
|
306
|
+
if (lists.prepend.length)
|
|
307
|
+
page.prependProps = lists.prepend;
|
|
308
|
+
if (lists.deep.length)
|
|
309
|
+
page.deepMergeProps = lists.deep;
|
|
310
|
+
if (matchPropsOn.length)
|
|
311
|
+
page.matchPropsOn = matchPropsOn;
|
|
312
|
+
if (Object.keys(onceProps).length)
|
|
313
|
+
page.onceProps = onceProps;
|
|
314
|
+
return { selected, lazy, skippedOnce, page, announced, rescued: [] };
|
|
315
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The part of Vite's dev server used here. */
|
|
2
|
+
export interface ViteDevServer {
|
|
3
|
+
middlewares(request: unknown, response: unknown, next: (error?: unknown) => void): void;
|
|
4
|
+
ssrLoadModule(url: string): Promise<Record<string, unknown>>;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
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;
|
|
19
|
+
/**
|
|
20
|
+
* Starts the project's Vite (its vite.config.*) in middleware mode. The HMR socket gets a free
|
|
21
|
+
* port of its own: @dokebi/websocket answers every upgrade on the app's HTTP server and closes
|
|
22
|
+
* the ones that are not its routes, so HMR cannot share that server.
|
|
23
|
+
*/
|
|
24
|
+
export declare function startViteDev(root?: string): Promise<ViteDevServer>;
|
package/dist/vite-dev.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Development mode (D-0045): with DOKEBI_VITE_DEV=1 (set by `dokebi dev`) pages load their code
|
|
2
|
+
// from the project's Vite in middleware mode instead of a build, so a .vue edit reaches the
|
|
3
|
+
// browser by hot module replacement. Vite is the project's dev dependency, found from the
|
|
4
|
+
// working directory; this package does not depend on it.
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
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
|
+
}
|
|
24
|
+
function freePort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const server = createServer();
|
|
27
|
+
server.once('error', reject);
|
|
28
|
+
server.listen(0, '127.0.0.1', () => {
|
|
29
|
+
const address = server.address();
|
|
30
|
+
server.close(() => typeof address === 'object' && address ? resolve(address.port) : reject(new Error('port')));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Starts the project's Vite (its vite.config.*) in middleware mode. The HMR socket gets a free
|
|
36
|
+
* port of its own: @dokebi/websocket answers every upgrade on the app's HTTP server and closes
|
|
37
|
+
* the ones that are not its routes, so HMR cannot share that server.
|
|
38
|
+
*/
|
|
39
|
+
export async function startViteDev(root = process.cwd()) {
|
|
40
|
+
let entry;
|
|
41
|
+
try {
|
|
42
|
+
const require = createRequire(join(root, 'package.json'));
|
|
43
|
+
entry = join(dirname(require.resolve('vite/package.json')), 'dist/node/index.js');
|
|
44
|
+
}
|
|
45
|
+
catch (cause) {
|
|
46
|
+
throw new Error('DOKEBI_VITE_DEV=1 needs Vite in the project (npm install --save-dev vite)', {
|
|
47
|
+
cause,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const vite = (await import(pathToFileURL(entry).href));
|
|
51
|
+
return vite.createServer({
|
|
52
|
+
root,
|
|
53
|
+
appType: 'custom',
|
|
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() } },
|
|
56
|
+
});
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/template-inertia",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
{
|
|
42
42
|
"name": "dokebi-web",
|
|
43
43
|
"version": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "dokebi-session",
|
|
47
|
+
"version": "^0.1.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/
|
|
61
|
+
"@dokebi/session": "0.1.0",
|
|
62
|
+
"@dokebi/web": "0.1.2"
|
|
57
63
|
},
|
|
58
64
|
"publishConfig": {
|
|
59
65
|
"access": "public",
|