@dokebi/template-inertia 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AI.md ADDED
@@ -0,0 +1,74 @@
1
+ # AI 작업 안내 — @dokebi/template-inertia 0.1.1
2
+
3
+ Inertia(v3) 서버 어댑터다. 첫 요청은 HTML(+ `script[data-page]` JSON), 이후 `X-Inertia` 요청은 page JSON으로 답한다. Vite manifest를 읽어 스크립트·스타일·버전을 채우고 빌드 파일을 직접 내보낸다. 선택적으로 첫 HTML을 같은 프로세스에서 서버 렌더링(SSR, D-0039)한다. Vue·Vite는 서버 의존성이 아니다(앱의 개발 의존성). Node >=24.0.0, core ^0.2.0, @dokebi/web(peer). 세부는 [README](README.md)다.
4
+
5
+ ## 1. 설치와 프로젝트 구성
6
+
7
+ ```sh
8
+ npx dokebi add @dokebi/template-inertia # src/infra/inertia/shared.ts(공통 props)
9
+ npx dokebi app add admin --inertia [--ssr] --domain admin.a.com # @dokebi/app 앱 + Vite 빌드 준비
10
+ npm run build # dist/(브라우저) + dist/ssr/(서버, --ssr)
11
+ ```
12
+
13
+ - `src/infra/inertia/shared.ts`의 `export async function shared(request)`가 모든 페이지 props에 합쳐진다(호출 props가 우선).
14
+ - Inertia 앱의 app.ts: `inertia: { title, vite: { manifest: 'dist/.vite/manifest.json', entry: 'src/apps/<앱>/views/app.js', base: '/build/' }, ssr?: { bundle: 'dist/ssr/<앱>/ssr.js' } }`.
15
+
16
+ ## 2. 설정 `options.dokebi-template-inertia`(앱마다는 `scope`로 덮어씀)
17
+
18
+ | 키 | 뜻 |
19
+ |---|---|
20
+ | `title` | 문서 제목(기본 빈 문자열) |
21
+ | `rootId` | 루트 요소 id(기본 `app`, `^[A-Za-z][A-Za-z0-9_-]*$`) |
22
+ | `version` · `scripts` · `styles` | 자산 버전과 URL(`/`로 시작하는 로컬 URL). 없으면 `INERTIA_VERSION_DEFAULT` 경고 |
23
+ | `vite: { manifest, entry, base? }` | manifest에서 scripts·styles·version 계산. 위 셋과 함께 쓰면 거부 |
24
+ | `ssr: { bundle, failure? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error` |
25
+
26
+ ## 3. 페이지 렌더
27
+
28
+ ```ts
29
+ // @dokebi/app 앱 라우트: page.inertia가 앱 설정을 쓰는 렌더러
30
+ route.get('/orders', async (request, reply, page) =>
31
+ page.inertia!.render(request, reply, 'Orders/Index', {
32
+ orders: await listOrders(), // JSON 값
33
+ stats: () => computeStats(), // lazy: 요청된 경우만 평가(동시 평가)
34
+ }),
35
+ );
36
+ route.post('/orders', async (request, reply, page) => {
37
+ await createOrder(request.body);
38
+ return page.inertia!.redirect(request, reply, '/orders'); // mutation은 303
39
+ });
40
+ ```
41
+
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 값·순환은 실패.
44
+ - `redirect(request, reply, localUrl)`: GET/HEAD 302, mutation 303, Inertia의 `#` 이동은 409 + `X-Inertia-Redirect`.
45
+ - `location(request, reply, url)`: 로컬 또는 http(s) 외부 이동. Inertia 요청은 409 + `X-Inertia-Location`.
46
+ - 오래된 version의 Inertia 요청은 props 평가 없이 409 + `X-Inertia-Location`(새로고침 유도).
47
+ - 응답에 `Vary: X-Inertia`, `Cache-Control: no-store`가 붙고 HTML에 넣는 JSON·제목·URL은 escape된다.
48
+
49
+ ## 4. 빌드 파일 서빙
50
+
51
+ `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가 없으면 시작 실패.
52
+
53
+ ## 5. SSR(D-0039)
54
+
55
+ - `ssr.bundle`은 `render(page) → { head: string[]; body: string }`을 export하는 빌드 파일이다. Inertia v3에서는 `createInertiaApp({ resolve, layout })`을 서버에서 page 없이 부르면 `(page, renderToString)` 함수를 준다(`--ssr`이 만드는 `views/ssr.js` 참고).
56
+ - 번들은 시작 때 한 번 import한다. **없거나 `render`가 없으면 시작 실패.**
57
+ - 요청 중 render가 던지거나 모양이 틀리거나 body에 `id="<rootId>"`가 없으면: `fallback`은 그 요청을 CSR로 답하고 `INERTIA_SSR_FAILED`(error) 로그, `error`는 요청 실패(500).
58
+ - head에 `<title>`이 있으면 설정 title은 빠진다. Inertia 이동(JSON)은 SSR을 거치지 않는다.
59
+ - 브라우저 진입은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다. 서버·브라우저가 같은 레이아웃을 고르도록 페이지 모듈을 바꾸지 말고 `layout` 옵션을 쓴다(동시에 여러 템플릿을 그리므로).
60
+ - `ssrStats()` → `{ renders, failures }`(health·대시보드용). `scope({ ssr: false })`로 앱별로 끌 수 있다.
61
+
62
+ ## 6. 범위(scope)
63
+
64
+ `await api.scope({ title?, rootId?, version?, scripts?, styles?, vite?, ssr?, shared? })` → 그 설정의 렌더러. 정하지 않은 값은 플러그인 옵션, `shared`는 플러그인 shared 뒤에 합쳐진다. @dokebi/app이 앱마다 부른다.
65
+
66
+ ## 7. 주의 사항
67
+
68
+ - props에 비밀·개인정보를 넣지 않는다(HTML에 그대로 실린다). 인증·인가·CSRF는 앱이 구현한다.
69
+ - 데이터 조회·수정 API는 Inertia 응답이 아니라 일반 JSON 라우트(REST)로 만든다.
70
+ - 배포에는 빌드별 version(또는 `vite`)을 꼭 쓴다. 기본 version이면 자산이 바뀌어도 클라이언트가 모른다.
71
+ - deferred·merge·once props, Precognition, session flash는 없다. 있다고 가정하지 않는다.
72
+ - 앱 인스턴스마다 새 플러그인. 전체 drain 뒤에 responder가 닫힌다.
73
+
74
+ 근거: `examples/24-template-inertia`(실제 Chrome: CSR·SSR·cluster, 측정), `examples/26-scaffold`(CLI가 만든 SSR 앱의 빌드·서버 렌더), `examples/25-playground`, D-0027·D-0031·D-0039.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dokebi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,66 @@
1
- # Temporary Holding Version
1
+ # @dokebi/template-inertia 0.1.1
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Node >=24.0.0, core ^0.2.0, 선택 패키지다(npm 공개). ID는 `dokebi-template-inertia`이며 `dokebi-web`에 의존한다. `@dokebi/web`은 peer 의존성이다(예전의 고정 버전 dependency가 아니다). EJS·Vue·Vite를 서버 패키지의 필수 의존성으로 설치하지 않는다.
4
+
5
+ 플러그인 계약 v2([D-0028](../../docs/decisions/D-0028-plugin-contract-v2.md))를 따른다. `createPlugin({ app: app.view, options?, entry? })`이며 `app`은 필수다. 예전 `web`·`dependencies`·`shared` 입력은 없다. web은 `providerDependency`로 연결한다: 같은 앱에 `dokebi-web`이 등록되어 있으면 그것을 쓰고, 없으면 `@dokebi/web`에서 만든다. 요청별 공통 props는 `entry.shared`로 넘긴다.
6
+
7
+ ```ts
8
+ import { createPlugin as createInertia } from '@dokebi/template-inertia';
9
+ import { createPlugin as createWeb } from '@dokebi/web';
10
+ const web = createWeb({ app: app.view }); // 주소를 읽거나 라우트를 직접 붙일 때 먼저 등록
11
+ app.register(web);
12
+ const pages = createInertia({
13
+ app: app.view,
14
+ options: { version: 'build-hash', scripts: ['/assets/app.js'], styles: ['/assets/app.css'] },
15
+ entry: { shared: (request) => ({ user: String(request.headers['x-user'] ?? 'guest') }) },
16
+ });
17
+ // 업무 플러그인이 web과 pages를 의존성으로 선언한 setup 안에서:
18
+ // context.get(web).route({ method: 'GET', url: '/', handler: (req, reply) =>
19
+ // context.get(pages).render(req, reply, 'Home', { counter: { value: 0 } }) });
20
+ ```
21
+
22
+ 위 x-user는 API 예시이며 인증 수단이 아니다. 앱의 인증 결과에서 사용자 props를 만든다. `render(request, reply, component, props?)`는 GET/HEAD에서만 호출하며 handler에서 반드시 반환/await한다. 초기 접근은 HTML과 JSON page script, 이후 X-Inertia 요청은 page JSON으로 응답한다. Inertia v3의 `script[data-page="app"]` 형식이다. `rootId` 기본 app, title 기본 빈 문자열, version 기본 development다. version을 설정하지 않으면 setup에서 한 번 warn 로그(`code: INERTIA_VERSION_DEFAULT`)를 남긴다. 그 상태에서는 자산이 바뀌어도 클라이언트가 감지하지 못하므로 실제 배포에서는 빌드별 version과 해당 manifest의 scripts/styles를 지정한다. Vite 빌드를 쓰면 `vite: { manifest, entry, base? }`로 대신 지정할 수 있다. setup에서 manifest(`.vite/manifest.json`, 작업 폴더 기준 상대 또는 절대 경로, 최대 4MiB)를 한 번 읽어 entry의 스크립트, entry와 import한 청크의 CSS, manifest 내용 해시(SHA-256 앞 32자) version을 채운다. `base`(기본 `/`)는 빌드 파일의 URL 접두사다. `vite`와 `version`·`scripts`·`styles`를 함께 쓰면 거부한다. entry가 manifest에 없으면 시작이 실패하고 오류에 entry 이름이 나온다([ISSUE-0005](../../docs/issues/ISSUE-0005-playground-findings.md) PG-07). manifest 파일이 없으면 `Vite manifest not found: <경로>. Build the views first (npm run build).`로 실패한다.
23
+
24
+ 빌드 파일은 이 플러그인이 dokebi-web으로 직접 내보낸다([D-0031](../../docs/decisions/D-0031-project-layout.md) 보완 1). entry에서 닿는 파일만 대상이다. 정적·동적 import 청크, CSS, assets가 여기에 들어간다. 각 파일은 `<base><파일>`의 정확한 경로에 GET 경로로 등록되고, 와일드카드 경로는 쓰지 않는다. 그래서 manifest 자체나 목록에 없는 파일은 내보내지 않는다. 앱 여럿이 같은 청크를 쓰면 한 번만 등록한다. 파일 위치는 manifest가 `<outDir>/.vite/manifest.json`이면 outDir이고, 그렇지 않으면 manifest가 있는 폴더다. 응답에는 확장자별 content-type과 `cache-control: public, max-age=31536000, immutable`이 붙는다. 이름에 해시가 든 빌드 파일을 전제로 하기 때문이다. CDN이나 다른 정적 서버로 낼 때도 같은 경로에 두면 된다.
25
+
26
+ ```yaml
27
+ dokebi-template-inertia:
28
+ title: My app
29
+ vite: { manifest: dist/.vite/manifest.json, entry: src/apps/shop/views/app.js, base: /build/ }
30
+ ```
31
+
32
+ 잘못된 옵션은 팩토리에서 `INVALID_PLUGIN_OPTIONS`다. asset URL은 `/`로 시작하는 로컬 절대 URL만 허용하며 파일 서빙은 앱/정적 서버 책임이다.
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을 반환한다.
35
+
36
+ `redirect(request, reply, localUrl)`은 GET/HEAD302, mutation303이며 Inertia fragment 이동은409 + X-Inertia-Redirect다. `location(request, reply, url)`은 로컬 또는 HTTP(S) 이동으로 Inertia409 + X-Inertia-Location, 일반 요청302다. 외부 목적지는 신뢰된 앱 코드에서 선택한다.
37
+
38
+ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('/api/...')` REST로 구분한다. API 라우트는 JSON·상태 코드를 직접 반환하며 Inertia 응답으로 포장하지 않는다. 페이지/API의 업무 서비스와 인증·인가를 공유하고 쿠키 인증 mutation의 CSRF를 앱에서 검증한다. 서버별 상태 공유가 필요하면 선택 DB/Redis를 연결한다.
39
+
40
+ 공통 Template 기반 클래스는 없다. EJS 문자열 엔진과 HTTP 응답 어댑터를 독립 클래스로 조합한다. 전체 요청 drain 뒤 responder를 닫으며 Application마다 새 인스턴스를 만든다. 옵션은 `options`가 없으면 app.config의 dokebi-template-inertia 값을 사용한다.
41
+
42
+ ## 설치
43
+
44
+ `dokebi add`는 정적 설치 선언으로 진입 파일 `src/infra/inertia/shared.ts`(`export async function shared(_request) { return {}; }`)를 만들고 생성 코드가 이를 `entry`로 넘긴다. 설치 모듈(installModule)은 없다. web은 manifest 의존성으로 함께 설치·순서 검사된다.
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 클라이언트 책임이다.
47
+
48
+ [예제24](../../examples/24-template-inertia/README.md)에 실제 Vue3 + Inertia + REST, 인증/CSRF fixture와 Chrome 자동 검증이 있다. `npm test -w @dokebi/template-inertia`는 HTTP 프로토콜 회귀다.
49
+
50
+ ## 범위별 문서 설정 (D-0030)
51
+
52
+ `scope({ title?, rootId?, version?, scripts?, styles?, vite?, shared? })`는 자기 문서 설정과 추가 공통 props를 가진 렌더러를 돌려준다(비동기, `vite`면 manifest를 읽는다). 정하지 않은 값은 플러그인 옵션을 따른다. `shared`는 플러그인 공통 props 뒤에 합쳐진다. @dokebi/app이 앱마다 다른 Vite 진입점에 쓴다.
53
+
54
+ ## 서버 렌더링(SSR, D-0039)
55
+
56
+ `ssr: { bundle, failure? }`를 주면 페이지의 첫 HTML을 서버에서 그린다. 별도 SSR 프로세스 없이 같은 프로세스에서 SSR 빌드 산출물을 시작 때 한 번 import하고, 전체 페이지 요청마다 그 `render(page)`를 부른다. Inertia 이동(`X-Inertia`)은 지금처럼 JSON이고 SSR을 거치지 않는다.
57
+
58
+ - `bundle`: `render(page) → Promise<{ head: string[]; body: string }>`를 export하는 빌드 파일(작업 폴더 기준 또는 절대 경로). Inertia v3의 `createInertiaApp`은 서버에서 `page` 없이 부르면 `(page, renderToString)` 함수를 돌려주므로 `export const render = async (page) => (await app)(page, renderToString)`이면 된다(예제 24의 `client/ssr.js`).
59
+ - 번들이 없거나 `render`가 없으면 **시작이 실패한다.** SSR을 켰는데 조용히 CSR이 되는 일을 막는다.
60
+ - 요청 중 `render`가 던지거나, 모양이 틀리거나(`head` 문자열 배열·`body` 문자열), `body`에 `id="<rootId>"`가 없으면 `failure`를 따른다. `fallback`(기본)은 그 요청을 CSR 페이지로 답하고 `INERTIA_SSR_FAILED` 오류 로그를 남긴다. `error`는 요청을 실패시킨다(개발 중 바로 보기).
61
+ - `head`는 `</head>` 앞에 넣는다. 그 안에 `<title>`이 있으면(페이지의 `<Head>`) 설정한 `title`은 빠진다. `body`는 기본 `<script data-page>`·빈 루트 대신 들어간다.
62
+ - 브라우저 진입점은 `el.hasAttribute('data-server-rendered')`면 `createSSRApp`으로 hydration한다.
63
+ - `ssrStats()`는 시작 뒤 렌더 수와 실패 수다(health·대시보드용).
64
+ - `scope({ ssr })`: 앱마다 번들을 정한다. 생략하면 플러그인 옵션을 따르고 `false`면 끈다. 같은 파일은 한 번만 import한다.
65
+
66
+ 측정(예제 24, 같은 프로세스, 순차 400회): CSR 0.20~0.24ms, SSR 0.24~0.28ms(작은 페이지), SSR 450장 뒤 heap +1.1MB.
@@ -0,0 +1,65 @@
1
+ import { type Awaitable } from '@dokebi/core';
2
+ import type { FastifyRequest } from 'fastify';
3
+ import { type InertiaApi, type Props } from './responder.js';
4
+ export type { InertiaApi, InertiaOptions, InertiaPage, InertiaSsrResult, Json, Props, } from './responder.js';
5
+ export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
6
+ export interface InertiaEntry {
7
+ readonly shared?: InertiaShared;
8
+ }
9
+ /**
10
+ * Server rendering of the first HTML of a page (D-0039), in this process: the SSR build output is
11
+ * imported once at start and its `render(page)` is called for every full page load.
12
+ */
13
+ export interface InertiaSsrOptions {
14
+ /** SSR build output exporting `render(page)`, relative to the working directory or absolute. */
15
+ readonly bundle: string;
16
+ /**
17
+ * When render throws: `fallback` (default) answers the client-rendered page and logs the error;
18
+ * `error` fails the request, which is easier to notice while developing.
19
+ */
20
+ readonly failure?: 'fallback' | 'error';
21
+ }
22
+ /** Counts since start, for health checks and dashboards. */
23
+ export interface InertiaSsrStats {
24
+ readonly renders: number;
25
+ readonly failures: number;
26
+ }
27
+ /** Document settings for one part of the app (for example one app of @dokebi/app). */
28
+ export interface InertiaScopeOptions {
29
+ readonly title?: string;
30
+ readonly rootId?: string;
31
+ readonly version?: string;
32
+ readonly scripts?: readonly string[];
33
+ readonly styles?: readonly string[];
34
+ readonly vite?: {
35
+ readonly manifest: string;
36
+ readonly entry: string;
37
+ readonly base?: string;
38
+ };
39
+ /** Server rendering for this scope; unset uses the plugin's `ssr`, `false` turns it off. */
40
+ readonly ssr?: InertiaSsrOptions | false;
41
+ /** Props merged after the plugin's shared props for every page of this scope. */
42
+ readonly shared?: InertiaShared;
43
+ }
44
+ export interface InertiaPluginApi extends InertiaApi {
45
+ /**
46
+ * A renderer with its own document settings and extra shared props. Reads the Vite manifest
47
+ * when `vite` is given; unset fields come from the plugin options.
48
+ */
49
+ scope(options: InertiaScopeOptions): Promise<InertiaApi>;
50
+ /** Server renders and render failures of every scope since start (zero without `ssr`). */
51
+ ssrStats(): InertiaSsrStats;
52
+ }
53
+ /**
54
+ * Assets read from a Vite build manifest at setup: the entry script, the CSS of the entry and
55
+ * of the chunks it imports, and a version derived from the manifest content.
56
+ */
57
+ export interface InertiaViteOptions {
58
+ /** Path of .vite/manifest.json, relative to the working directory, or absolute. */
59
+ readonly manifest: string;
60
+ /** Manifest key of the entry, usually its source path such as `client/main.ts`. */
61
+ readonly entry: string;
62
+ /** URL prefix the built files are served under. Default `/`. */
63
+ readonly base: string;
64
+ }
65
+ export declare const createPlugin: import("@dokebi/core").PluginFactory<InertiaPluginApi>;
package/dist/index.js ADDED
@@ -0,0 +1,299 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { basename, dirname, extname, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { types } from 'node:util';
6
+ import { definePlugin, providerDependency, } from '@dokebi/core';
7
+ import { InertiaResponder, normalizeInertiaOptions, } from './responder.js';
8
+ const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
9
+ function parseVite(input) {
10
+ if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
11
+ throw new TypeError('Invalid Inertia vite options');
12
+ const { manifest, entry, base = '/', ...rest } = input;
13
+ if (Object.keys(rest).length ||
14
+ typeof manifest !== 'string' ||
15
+ !manifest ||
16
+ typeof entry !== 'string' ||
17
+ !entry ||
18
+ typeof base !== 'string' ||
19
+ !/^\/(?:[^/\\\s]+\/)*$/.test(base))
20
+ throw new TypeError('Invalid Inertia vite options');
21
+ return Object.freeze({ manifest, entry, base });
22
+ }
23
+ function parseSsr(input) {
24
+ if (input === undefined || input === false)
25
+ return undefined;
26
+ if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
27
+ throw new TypeError('Invalid Inertia ssr options');
28
+ const { bundle, failure = 'fallback', ...rest } = input;
29
+ if (Object.keys(rest).length ||
30
+ typeof bundle !== 'string' ||
31
+ !bundle ||
32
+ (failure !== 'fallback' && failure !== 'error'))
33
+ throw new TypeError('Invalid Inertia ssr options');
34
+ return Object.freeze({ bundle, failure });
35
+ }
36
+ /**
37
+ * Imports the SSR bundle. A missing file or a bundle without `render` stops the start: SSR that
38
+ * was asked for must not quietly become client rendering.
39
+ */
40
+ async function loadBundle(bundle) {
41
+ const path = resolve(bundle);
42
+ const info = await stat(path).catch((cause) => {
43
+ if (cause.code !== 'ENOENT')
44
+ throw cause;
45
+ throw new Error(`Inertia SSR bundle not found: ${bundle}. Build the views first (npm run build).`, { cause });
46
+ });
47
+ if (!info.isFile())
48
+ throw new Error(`Inertia SSR bundle is not a file: ${bundle}`);
49
+ const module = (await import(pathToFileURL(path).href));
50
+ if (typeof module.render !== 'function')
51
+ throw new Error(`Inertia SSR bundle ${bundle} does not export render(page)`);
52
+ return module.render;
53
+ }
54
+ const CONTENT_TYPES = {
55
+ '.js': 'text/javascript; charset=utf-8',
56
+ '.mjs': 'text/javascript; charset=utf-8',
57
+ '.css': 'text/css; charset=utf-8',
58
+ '.map': 'application/json; charset=utf-8',
59
+ '.json': 'application/json; charset=utf-8',
60
+ '.svg': 'image/svg+xml',
61
+ '.png': 'image/png',
62
+ '.jpg': 'image/jpeg',
63
+ '.jpeg': 'image/jpeg',
64
+ '.gif': 'image/gif',
65
+ '.webp': 'image/webp',
66
+ '.avif': 'image/avif',
67
+ '.ico': 'image/x-icon',
68
+ '.woff': 'font/woff',
69
+ '.woff2': 'font/woff2',
70
+ '.ttf': 'font/ttf',
71
+ '.wasm': 'application/wasm',
72
+ };
73
+ /**
74
+ * Reads the manifest once and returns the document asset settings for the entry, plus the built
75
+ * files the entry can load (its chunks, their CSS and assets) so they can be served.
76
+ */
77
+ async function viteAssets(vite) {
78
+ const path = resolve(vite.manifest);
79
+ const info = await stat(path).catch((cause) => {
80
+ if (cause.code !== 'ENOENT')
81
+ throw cause;
82
+ throw new Error(`Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
83
+ });
84
+ if (!info.isFile() || info.size > MANIFEST_MAX_BYTES)
85
+ throw new Error('Invalid Vite manifest file');
86
+ // Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
87
+ const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
88
+ const text = await readFile(path, 'utf8');
89
+ const manifest = JSON.parse(text);
90
+ const entry = Object.hasOwn(manifest, vite.entry) ? manifest[vite.entry] : undefined;
91
+ if (entry?.isEntry !== true || typeof entry.file !== 'string')
92
+ throw new Error('Vite manifest has no entry ' + JSON.stringify(vite.entry));
93
+ const styles = [];
94
+ const files = new Map();
95
+ const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
96
+ const serve = (file) => {
97
+ // Manifest paths are relative to outDir; anything that leaves it is not served.
98
+ if (typeof file !== 'string' || file.split('/').some((part) => part === '..' || !part))
99
+ return;
100
+ files.set(vite.base + file, join(outDir, file));
101
+ };
102
+ const seen = new Set();
103
+ // Static imports decide the document CSS; dynamic imports are only served.
104
+ const collect = (key, linked) => {
105
+ if (seen.has(key))
106
+ return;
107
+ seen.add(key);
108
+ const chunk = Object.hasOwn(manifest, key) ? manifest[key] : undefined;
109
+ if (!chunk)
110
+ return;
111
+ serve(chunk.file);
112
+ for (const asset of strings(chunk.assets))
113
+ serve(asset);
114
+ for (const imported of strings(chunk.imports))
115
+ collect(imported, linked);
116
+ for (const imported of strings(chunk.dynamicImports))
117
+ collect(imported, false);
118
+ for (const css of strings(chunk.css)) {
119
+ serve(css);
120
+ if (linked && !styles.includes(vite.base + css))
121
+ styles.push(vite.base + css);
122
+ }
123
+ };
124
+ collect(vite.entry, true);
125
+ return {
126
+ scripts: [vite.base + entry.file],
127
+ styles,
128
+ version: createHash('sha256').update(text).digest('hex').slice(0, 32),
129
+ files,
130
+ };
131
+ }
132
+ // Written here so the provider package resolves from @dokebi/template-inertia, not from core.
133
+ const web = providerDependency({
134
+ name: 'dokebi-web',
135
+ version: '^0.1.0',
136
+ packageName: '@dokebi/web',
137
+ load: () => import('@dokebi/web'),
138
+ });
139
+ function parseOptions(input) {
140
+ const { vite, ssr: ssrInput, ...rest } = (input ?? {});
141
+ const ssr = parseSsr(ssrInput);
142
+ if (vite === undefined) {
143
+ const document = normalizeInertiaOptions(rest);
144
+ return Object.freeze({
145
+ document,
146
+ defaultVersion: rest.version === undefined,
147
+ ...(ssr ? { ssr } : {}),
148
+ });
149
+ }
150
+ // The manifest decides scripts, styles and version; setting them too would be ambiguous.
151
+ if (['version', 'scripts', 'styles'].some((key) => rest[key] !== undefined))
152
+ throw new TypeError('Inertia vite options replace version, scripts and styles');
153
+ return Object.freeze({
154
+ document: normalizeInertiaOptions(rest),
155
+ defaultVersion: false,
156
+ vite: parseVite(vite),
157
+ ...(ssr ? { ssr } : {}),
158
+ });
159
+ }
160
+ /** Accepts a plain object or the namespace of the user entry module (src/inertia/shared.ts). */
161
+ function parseEntry(input) {
162
+ if (input === undefined)
163
+ return {};
164
+ if (input === null ||
165
+ typeof input !== 'object' ||
166
+ Array.isArray(input) ||
167
+ types.isProxy(input) ||
168
+ ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
169
+ throw new TypeError('Invalid Inertia entry');
170
+ let shared;
171
+ for (const key of Reflect.ownKeys(input)) {
172
+ // Module namespaces carry Symbol.toStringTag; only string exports are entry fields.
173
+ if (typeof key !== 'string')
174
+ continue;
175
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
176
+ if (key !== 'shared' || !descriptor || !('value' in descriptor))
177
+ throw new TypeError('Invalid Inertia entry');
178
+ shared = descriptor.value;
179
+ }
180
+ if (shared !== undefined && typeof shared !== 'function')
181
+ throw new TypeError('Inertia shared must be a function');
182
+ return shared === undefined ? {} : { shared: shared };
183
+ }
184
+ class InertiaRuntime {
185
+ settings;
186
+ shared;
187
+ constructor(settings, shared) {
188
+ this.settings = settings;
189
+ this.shared = shared;
190
+ }
191
+ async setup(context) {
192
+ if (this.settings.defaultVersion)
193
+ context.logger.log('warn', 'Inertia asset version is not configured', {
194
+ code: 'INERTIA_VERSION_DEFAULT',
195
+ });
196
+ const server = context.getDependency(web);
197
+ // Built files are served at their exact URL (no wildcard), once even if apps share chunks.
198
+ const served = new Set();
199
+ const built = async (options) => {
200
+ const { files, ...assets } = await viteAssets(options);
201
+ for (const [url, file] of files) {
202
+ if (served.has(url))
203
+ continue;
204
+ served.add(url);
205
+ const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
206
+ server.route({
207
+ method: 'GET',
208
+ url,
209
+ handler: async (_request, reply) => reply
210
+ .type(type)
211
+ .header('cache-control', 'public, max-age=31536000, immutable')
212
+ .send(await readFile(file)),
213
+ });
214
+ }
215
+ return assets;
216
+ };
217
+ const document = this.settings.vite
218
+ ? normalizeInertiaOptions({
219
+ ...this.settings.document,
220
+ ...(await built(this.settings.vite)),
221
+ })
222
+ : this.settings.document;
223
+ // One import per bundle file, shared by the scopes that name it.
224
+ const bundles = new Map();
225
+ const stats = { renders: 0, failures: 0 };
226
+ const serverRendering = async (ssr) => {
227
+ if (!ssr)
228
+ return undefined;
229
+ const path = resolve(ssr.bundle);
230
+ let render = bundles.get(path);
231
+ if (!render) {
232
+ render = loadBundle(ssr.bundle);
233
+ bundles.set(path, render);
234
+ }
235
+ return Object.freeze({
236
+ render: await render,
237
+ failure: ssr.failure,
238
+ rendered: () => {
239
+ stats.renders++;
240
+ },
241
+ failed: (error, component) => {
242
+ stats.failures++;
243
+ context.logger.log('error', 'Inertia SSR render failed', {
244
+ code: 'INERTIA_SSR_FAILED',
245
+ bundle: ssr.bundle,
246
+ component,
247
+ fallback: ssr.failure === 'fallback',
248
+ error: error instanceof Error ? error.message : String(error),
249
+ });
250
+ },
251
+ });
252
+ };
253
+ const responders = [];
254
+ context.defer(() => {
255
+ for (const responder of responders)
256
+ responder.close();
257
+ });
258
+ const api = (responder) => {
259
+ responders.push(responder);
260
+ return Object.freeze({
261
+ render: responder.render.bind(responder),
262
+ redirect: responder.redirect.bind(responder),
263
+ location: responder.location.bind(responder),
264
+ });
265
+ };
266
+ const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr)));
267
+ const shared = this.shared;
268
+ return Object.freeze({
269
+ ...root,
270
+ ssrStats: () => Object.freeze({ ...stats }),
271
+ scope: async (options) => {
272
+ const { shared: extra, vite, ssr: ssrInput, ...fields } = (options ?? {});
273
+ const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
274
+ if (extra !== undefined && typeof extra !== 'function')
275
+ throw new TypeError('Inertia scope shared must be a function');
276
+ if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
277
+ throw new TypeError('Inertia vite options replace version, scripts and styles');
278
+ const scoped = normalizeInertiaOptions({
279
+ ...document,
280
+ ...fields,
281
+ ...(vite === undefined ? {} : await built(parseVite(vite))),
282
+ });
283
+ const merged = shared && extra
284
+ ? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
285
+ : (extra ?? shared);
286
+ return api(new InertiaResponder(scoped, merged, await serverRendering(ssr)));
287
+ },
288
+ });
289
+ }
290
+ }
291
+ export const createPlugin = definePlugin({
292
+ name: 'dokebi-template-inertia',
293
+ version: '0.1.1',
294
+ core: '^0.2.0',
295
+ options: parseOptions,
296
+ entry: parseEntry,
297
+ dependencies: () => [web],
298
+ instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
299
+ });
@@ -0,0 +1,56 @@
1
+ import type { Awaitable } from '@dokebi/core';
2
+ import type { FastifyReply, FastifyRequest } from 'fastify';
3
+ export type Json = null | boolean | number | string | readonly Json[] | {
4
+ readonly [key: string]: Json;
5
+ };
6
+ export type Props = Readonly<Record<string, Json | (() => Awaitable<Json>)>>;
7
+ export interface InertiaOptions {
8
+ version?: string;
9
+ rootId?: string;
10
+ title?: string;
11
+ scripts?: readonly string[];
12
+ styles?: readonly string[];
13
+ }
14
+ /** The page object Inertia hands to the client and to the SSR bundle. */
15
+ export interface InertiaPage {
16
+ readonly component: string;
17
+ readonly props: Readonly<Record<string, Json>>;
18
+ readonly url: string;
19
+ readonly version: string;
20
+ readonly clearHistory: boolean;
21
+ readonly encryptHistory: boolean;
22
+ }
23
+ /** What an SSR bundle's `render(page)` returns: head tags and the root (script + element). */
24
+ export interface InertiaSsrResult {
25
+ readonly head: readonly string[];
26
+ readonly body: string;
27
+ }
28
+ /**
29
+ * Server rendering of the first HTML (D-0039). `failure: 'fallback'` answers a render error with
30
+ * the client-rendered page; `'error'` lets the request fail. `failed` sees every error either way.
31
+ */
32
+ export interface InertiaSsr {
33
+ readonly render: (page: InertiaPage) => Promise<InertiaSsrResult>;
34
+ readonly failure: 'fallback' | 'error';
35
+ readonly rendered?: () => void;
36
+ readonly failed?: (error: unknown, component: string) => void;
37
+ }
38
+ export interface InertiaApi {
39
+ render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props): Promise<FastifyReply>;
40
+ redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
41
+ location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
42
+ }
43
+ export type NormalizedInertiaOptions = Readonly<Required<InertiaOptions>>;
44
+ /** Validates and fills defaults; the result is accepted again unchanged. */
45
+ export declare function normalizeInertiaOptions(options?: InertiaOptions): NormalizedInertiaOptions;
46
+ export declare class InertiaResponder implements InertiaApi {
47
+ #private;
48
+ readonly shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined;
49
+ readonly ssr?: InertiaSsr | undefined;
50
+ readonly options: NormalizedInertiaOptions;
51
+ constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined);
52
+ close(): void;
53
+ render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props): Promise<never>;
54
+ 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
+ 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
+ }
@@ -0,0 +1,182 @@
1
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
2
+ function localUrl(value) {
3
+ if (typeof value !== 'string' ||
4
+ !value.startsWith('/') ||
5
+ value.startsWith('//') ||
6
+ [...value].some((char) => char === '\\' || char.charCodeAt(0) <= 32))
7
+ throw new TypeError('Expected a local absolute URL');
8
+ return value;
9
+ }
10
+ function vary(reply) {
11
+ const values = String(reply.getHeader('vary') ?? '')
12
+ .split(',')
13
+ .map((value) => value.trim())
14
+ .filter(Boolean);
15
+ if (!values.some((value) => value.toLowerCase() === 'x-inertia') && !values.includes('*'))
16
+ values.push('X-Inertia');
17
+ reply.header('Vary', values.join(', '));
18
+ // Page props may contain user-specific state, including on version mismatch responses.
19
+ reply.header('Cache-Control', 'no-store');
20
+ }
21
+ /** Validates and fills defaults; the result is accepted again unchanged. */
22
+ export function normalizeInertiaOptions(options = {}) {
23
+ if (!options ||
24
+ typeof options !== 'object' ||
25
+ Array.isArray(options) ||
26
+ Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles'].includes(key)))
27
+ throw new TypeError('Invalid Inertia options');
28
+ for (const assets of [options.scripts, options.styles]) {
29
+ if (assets !== undefined &&
30
+ (!Array.isArray(assets) || assets.some((asset) => typeof asset !== 'string')))
31
+ throw new TypeError('Assets must be arrays of URLs');
32
+ }
33
+ const normalized = {
34
+ version: options.version ?? 'development',
35
+ rootId: options.rootId ?? 'app',
36
+ title: options.title ?? '',
37
+ scripts: Object.freeze([...(options.scripts ?? [])]),
38
+ styles: Object.freeze([...(options.styles ?? [])]),
39
+ };
40
+ if (typeof normalized.version !== 'string' ||
41
+ typeof normalized.title !== 'string' ||
42
+ typeof normalized.rootId !== 'string' ||
43
+ !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId))
44
+ throw new TypeError('Invalid Inertia document settings');
45
+ for (const asset of [...normalized.scripts, ...normalized.styles])
46
+ localUrl(asset);
47
+ return Object.freeze(normalized);
48
+ }
49
+ export class InertiaResponder {
50
+ shared;
51
+ ssr;
52
+ options;
53
+ #closed = false;
54
+ constructor(options = {}, shared, ssr) {
55
+ this.shared = shared;
56
+ this.ssr = ssr;
57
+ this.options = normalizeInertiaOptions(options);
58
+ }
59
+ close() {
60
+ this.#closed = true;
61
+ }
62
+ #check() {
63
+ if (this.#closed)
64
+ throw new Error('Inertia responder is closed');
65
+ }
66
+ async render(request, reply, component, input = {}) {
67
+ this.#check();
68
+ if (!['GET', 'HEAD'].includes(request.method))
69
+ throw new Error('Render pages using GET/HEAD; redirect mutations or use REST');
70
+ if (typeof component !== 'string' ||
71
+ component.length > 256 ||
72
+ !component.split('/').every((part) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(part)))
73
+ throw new TypeError('Invalid page component');
74
+ vary(reply);
75
+ const inertia = request.headers['x-inertia'] === 'true';
76
+ if (inertia &&
77
+ request.headers['x-inertia-version'] !== undefined &&
78
+ request.headers['x-inertia-version'] !== this.options.version) {
79
+ return reply
80
+ .code(409)
81
+ .header('X-Inertia-Location', localUrl(request.url))
82
+ .header('X-Inertia-Version', this.options.version)
83
+ .send();
84
+ }
85
+ const props = { ...(await this.shared?.(request)), ...input };
86
+ const partial = inertia && request.headers['x-inertia-partial-component'] === component;
87
+ const list = (key) => typeof request.headers[key] === 'string'
88
+ ? new Set(request.headers[key]
89
+ .split(',')
90
+ .map((x) => x.trim())
91
+ .filter(Boolean))
92
+ : 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)));
97
+ const resolved = Object.create(null);
98
+ resolved.errors = {};
99
+ selected.forEach(([key], index) => {
100
+ resolved[key] = values[index];
101
+ });
102
+ const page = {
103
+ component,
104
+ props: resolved,
105
+ url: request.url,
106
+ version: this.options.version,
107
+ clearHistory: false,
108
+ encryptHistory: false,
109
+ };
110
+ // Reject unsupported values rather than silently dropping application data.
111
+ const json = JSON.stringify(page, (_key, value) => {
112
+ if (value === undefined ||
113
+ typeof value === 'function' ||
114
+ typeof value === 'symbol' ||
115
+ typeof value === 'bigint' ||
116
+ (typeof value === 'number' && !Number.isFinite(value)))
117
+ throw new TypeError('Page props must be JSON serializable');
118
+ return value;
119
+ });
120
+ if (inertia)
121
+ return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
122
+ const { rootId, title, scripts, styles } = this.options;
123
+ // Only the first HTML is server rendered; Inertia visits above stay JSON.
124
+ const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
125
+ const safeJson = json
126
+ .replace(/</g, '\\u003c')
127
+ .replace(/\//g, '\\/')
128
+ .replace(/\u2028/g, '\\u2028')
129
+ .replace(/\u2029/g, '\\u2029');
130
+ // A <title> from the page's <Head> replaces the configured one.
131
+ const head = rendered?.head ?? [];
132
+ const titled = head.some((tag) => /^<title[\s>]/i.test(tag));
133
+ return reply
134
+ .type('text/html; charset=utf-8')
135
+ .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
+ }
137
+ /**
138
+ * The SSR bundle's head and root for this page, or undefined to answer client rendered
139
+ * (failure 'fallback'). The page object is a fresh copy, so the bundle cannot change the props.
140
+ */
141
+ async #serverRender(page, rootId) {
142
+ const ssr = this.ssr;
143
+ try {
144
+ const result = await ssr.render(page);
145
+ const { head, body } = (result ?? {});
146
+ if (!Array.isArray(head) ||
147
+ head.some((tag) => typeof tag !== 'string') ||
148
+ typeof body !== 'string')
149
+ throw new TypeError('SSR render must return { head: string[], body: string }');
150
+ // The client mounts on the root id; a bundle built for another id would never hydrate.
151
+ if (!body.includes(`id="${rootId}"`))
152
+ throw new TypeError(`SSR body has no element with id "${rootId}" (rootId option)`);
153
+ ssr.rendered?.();
154
+ return { head, body };
155
+ }
156
+ catch (error) {
157
+ ssr.failed?.(error, page.component);
158
+ if (ssr.failure === 'error')
159
+ throw error;
160
+ return undefined;
161
+ }
162
+ }
163
+ redirect(request, reply, url) {
164
+ this.#check();
165
+ localUrl(url);
166
+ vary(reply);
167
+ if (request.headers['x-inertia'] === 'true' && url.includes('#'))
168
+ return reply.code(409).header('X-Inertia-Redirect', url).send();
169
+ return reply.redirect(url, ['GET', 'HEAD'].includes(request.method) ? 302 : 303);
170
+ }
171
+ location(request, reply, url) {
172
+ this.#check();
173
+ if (url.startsWith('/'))
174
+ localUrl(url);
175
+ else if (!/^https?:$/.test(new URL(url).protocol) || /[\r\n]/.test(url))
176
+ throw new TypeError('Invalid location');
177
+ vary(reply);
178
+ return request.headers['x-inertia'] === 'true'
179
+ ? reply.code(409).header('X-Inertia-Location', url).send()
180
+ : reply.redirect(url, 302);
181
+ }
182
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "version": {
7
+ "type": "string"
8
+ },
9
+ "rootId": {
10
+ "type": "string",
11
+ "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"
12
+ },
13
+ "title": {
14
+ "type": "string"
15
+ },
16
+ "scripts": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "string",
20
+ "pattern": "^/[^/]"
21
+ }
22
+ },
23
+ "styles": {
24
+ "type": "array",
25
+ "items": {
26
+ "type": "string",
27
+ "pattern": "^/[^/]"
28
+ }
29
+ },
30
+ "vite": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["manifest", "entry"],
34
+ "description": "Read scripts, styles and version from a Vite build manifest at startup. Replaces version, scripts and styles.",
35
+ "properties": {
36
+ "manifest": {
37
+ "type": "string",
38
+ "minLength": 1,
39
+ "description": "Path of .vite/manifest.json, relative to the working directory"
40
+ },
41
+ "entry": {
42
+ "type": "string",
43
+ "minLength": 1,
44
+ "description": "Manifest key of the entry, e.g. src/apps/<app>/views/app.js"
45
+ },
46
+ "base": {
47
+ "type": "string",
48
+ "pattern": "^/(?:[^/\\\\\\s]+/)*$",
49
+ "description": "URL prefix of the built files (default /)"
50
+ }
51
+ }
52
+ },
53
+ "ssr": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["bundle"],
57
+ "description": "Render the first HTML of each page on the server (D-0039) with the SSR build output, imported once at start. A missing bundle stops the start.",
58
+ "properties": {
59
+ "bundle": {
60
+ "type": "string",
61
+ "minLength": 1,
62
+ "description": "SSR build output that exports render(page), relative to the working directory, e.g. dist/ssr/<app>/ssr.js"
63
+ },
64
+ "failure": {
65
+ "enum": ["fallback", "error"],
66
+ "description": "When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@dokebi/template-inertia",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "options.schema.json",
18
+ "README.md",
19
+ "AI.md",
20
+ "LICENSE",
21
+ "templates"
22
+ ],
23
+ "engines": {
24
+ "node": ">=24.0.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
29
+ "test": "tsc -p tsconfig.test.json && node --test .tmp/tests/tests/*.test.js"
30
+ },
31
+ "peerDependencies": {
32
+ "@dokebi/core": "^0.2.0",
33
+ "@dokebi/web": "^0.1.0",
34
+ "fastify": "^5.12.7"
35
+ },
36
+ "dokebi": {
37
+ "schemaVersion": 2,
38
+ "id": "dokebi-template-inertia",
39
+ "core": "^0.2.0",
40
+ "dependencies": [
41
+ {
42
+ "name": "dokebi-web",
43
+ "version": "^0.1.0"
44
+ }
45
+ ],
46
+ "install": {
47
+ "entry": {
48
+ "source": "templates/shared.ts",
49
+ "target": "src/infra/inertia/shared.ts"
50
+ }
51
+ },
52
+ "optionsSchema": "options.schema.json",
53
+ "documentation": "AI.md"
54
+ },
55
+ "devDependencies": {
56
+ "@dokebi/web": "0.1.1"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org/"
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+
3
+ // dokebi-template-inertia entry (src/infra/inertia/shared.ts). Props returned here are merged into every page, before the
4
+ // props passed to render(). Never put secrets here; derive user data from your own auth.
5
+ export async function shared(_request: FastifyRequest) {
6
+ return {};
7
+ }