@dokebi/template-inertia 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +10 -2
- package/README.md +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +79 -8
- package/dist/responder.d.ts +29 -3
- package/dist/responder.js +40 -6
- package/options.schema.json +12 -0
- package/package.json +6 -6
package/AI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# AI 작업 안내 — @dokebi/template-inertia 0.
|
|
1
|
+
# AI 작업 안내 — @dokebi/template-inertia 0.2.0
|
|
2
2
|
|
|
3
3
|
Inertia(v3) 서버 어댑터다. 첫 요청은 HTML(+ `script[data-page]` JSON), 이후 `X-Inertia` 요청은 page JSON으로 답한다. Vite manifest를 읽어 스크립트·스타일·버전을 채우고 빌드 파일을 직접 내보낸다. 선택적으로 첫 HTML을 같은 프로세스에서 서버 렌더링(SSR, D-0039)한다. Vue·Vite는 서버 의존성이 아니다(앱의 개발 의존성). Node >=24.0.0, core ^0.2.0, @dokebi/web(peer). 세부는 [README](README.md)다.
|
|
4
4
|
|
|
@@ -22,6 +22,7 @@ npm run build # dist/(브라우저) + dist
|
|
|
22
22
|
| `version` · `scripts` · `styles` | 자산 버전과 URL(`/`로 시작하는 로컬 URL). 없으면 `INERTIA_VERSION_DEFAULT` 경고 |
|
|
23
23
|
| `vite: { manifest, entry, base? }` | manifest에서 scripts·styles·version 계산. 위 셋과 함께 쓰면 거부 |
|
|
24
24
|
| `ssr: { bundle, failure?, entry? }` | 서버 렌더링. `failure`: `fallback`(기본) · `error`. `entry`는 개발 모드에서 쓰는 번들의 원본 모듈(§5.1) |
|
|
25
|
+
| `encryptHistory` | Inertia history 암호화: `true`·`false`·`'sealed'`(기본: 봉인 요청이면 켬, D-0062) |
|
|
25
26
|
|
|
26
27
|
## 3. 페이지 렌더
|
|
27
28
|
|
|
@@ -39,7 +40,7 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
39
40
|
});
|
|
40
41
|
```
|
|
41
42
|
|
|
42
|
-
- `render(request, reply, component, props?)`: GET/HEAD에서만. 핸들러에서 반환·await. component는 `/`로 구분된 `[A-Za-z0-9][A-Za-z0-9_-]*` 조각.
|
|
43
|
+
- `render(request, reply, component, props?, { encryptHistory?, clearHistory? }?)`: GET/HEAD에서만. 다섯째 인수는 이 응답의 history 설정(없으면 범위 설정과 세션 신호). 핸들러에서 반환·await. component는 `/`로 구분된 `[A-Za-z0-9][A-Za-z0-9_-]*` 조각.
|
|
43
44
|
- props: JSON 값, 비동기 lazy 함수, 또는 §3.1 도우미. partial reload(`only`·`except`)는 선택된 것만 평가하고 `errors`는 항상 포함. 비JSON 값·순환은 실패.
|
|
44
45
|
- `redirect(request, reply, localUrl)`: GET/HEAD 302, mutation 303, Inertia의 `#` 이동은 409 + `X-Inertia-Redirect`.
|
|
45
46
|
- `location(request, reply, url)`: 로컬 또는 http(s) 외부 이동. Inertia 요청은 409 + `X-Inertia-Location`.
|
|
@@ -123,3 +124,10 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
123
124
|
- 앱 인스턴스마다 새 플러그인. 전체 drain 뒤에 responder가 닫힌다.
|
|
124
125
|
|
|
125
126
|
근거: `examples/24-template-inertia`(실제 Chrome: CSR·SSR·cluster, 측정), `examples/26-scaffold`(CLI가 만든 SSR 앱의 빌드·서버 렌더), `examples/25-playground`, `tests/flash.test.ts`(세션 flash), D-0027·D-0031·D-0039·D-0045·D-0052·D-0055.
|
|
127
|
+
|
|
128
|
+
## 봉인 호스트와 브라우저 기록(D-0062, 0.2.0)
|
|
129
|
+
|
|
130
|
+
- seal 호스트(@dokebi/app `domains.<host>.seal: true`)의 요청이면 첫 HTML의 `data-page`가 봉인 프레임 하나(`data-sealed="true"`, `E:<ts>:<b64>`)이고, 방문(JSON) 응답은 dokebi-web이 봉인한다. 판단은 dokebi-web `sealOf(request)`. 브라우저는 `views/app.js`에서 `createInertiaApp` 전에 `await sealInertia(http)`(`@dokebi/seal/http`; CLI 템플릿은 `data-sealed`일 때만 `/dokebi/seal/http.mjs`를 동적 import)로 연다.
|
|
131
|
+
- **페이지 모듈 맨 위에서 `data-page`를 읽지 않는다**: `import.meta.glob(..., { eager: true })`로 들어온 페이지·모듈은 `sealInertia`보다 먼저 실행되므로 아직 봉인된 글을 JSON으로 읽다 앱이 멈춘다. `usePage()`나 연결할 때 읽는다.
|
|
132
|
+
- SSR과 함께 쓸 수 없다: `scope({ sealed: true, ssr })`는 시작 실패 `SEAL_SSR_CONFLICT`(dokebi-app이 seal 도메인 앱에 `sealed`를 넘긴다), SSR 범위에 봉인 요청이 오면 그 요청이 `SEAL_SSR_CONFLICT`.
|
|
133
|
+
- `encryptHistory`(기본 `'sealed'`)가 켜진 페이지는 브라우저 기록에 암호화돼 저장된다(보안 문맥에서만: HTTPS·`*.localhost`). `clearHistory`는 @dokebi/session 0.2의 `historyReset()`(이 요청에서 `destroy`·`regenerate`)이면 그 페이지에, 페이지를 그리지 않는 요청(로그인 POST → 303)이면 쿠키 `dokebi.history-reset`(10분)으로 **다음 페이지**에 실린다. 로그아웃 뒤 「뒤로」는 예전 props를 보이지 못하고 서버에서 다시 받는다.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @dokebi/template-inertia 0.
|
|
1
|
+
# @dokebi/template-inertia 0.2.0
|
|
2
2
|
|
|
3
3
|
Node >=24.0.0, core ^0.2.0, 선택 패키지다(npm 공개). ID는 `dokebi-template-inertia`이며 `dokebi-web`에 의존한다. `@dokebi/web`은 peer 의존성이다(예전의 고정 버전 dependency가 아니다). EJS·Vue·Vite를 서버 패키지의 필수 의존성으로 설치하지 않는다.
|
|
4
4
|
|
|
@@ -78,3 +78,10 @@ Vue 페이지 이동·초기 props는 Inertia, 이후 조회·수정은 `fetch('
|
|
|
78
78
|
## 개발 모드(HMR, [D-0045](../../docs/decisions/D-0045-inertia-vite-dev.md))
|
|
79
79
|
|
|
80
80
|
`DOKEBI_VITE_DEV=1`(`dokebi dev`가 넣는다)이면 빌드 없이 프로젝트의 Vite를 미들웨어 모드로 띄워 `<base>*`를 Vite가 답하고, 페이지는 `@vite/client`와 원본 진입을 불러온다. `.vue`를 고치면 새로고침 없이 바뀐다(HMR 소켓은 프로세스마다 따로 잡은 빈 포트). SSR은 요청마다 Vite로 원본(`ssr.entry`, 없으면 `vite.entry` 옆 `ssr.js`)을 그린다. 운영에는 영향이 없다.
|
|
81
|
+
|
|
82
|
+
## 봉인 호스트와 history(D-0062, 0.2.0)
|
|
83
|
+
|
|
84
|
+
[D-0062](../../docs/decisions/D-0062-http-seal.md). 봉인 요청(dokebi-web `sealOf`)의 첫 HTML은 `<script data-page="app" type="application/json" data-sealed="true">E:…</script>`만 싣고(제목은 설정값), 방문 JSON은 dokebi-web이 봉인한다. 브라우저는 `@dokebi/seal/http`의 `sealInertia(http)`로 연다. SSR과 함께 쓰면 `SEAL_SSR_CONFLICT`.
|
|
85
|
+
|
|
86
|
+
- `encryptHistory: true | false | 'sealed'`(옵션·`scope`, 기본 `'sealed'`)와 `render(..., props, { encryptHistory, clearHistory })`.
|
|
87
|
+
- 로그인·로그아웃(@dokebi/session 0.2 `historyReset()`) 다음 페이지가 `clearHistory: true`를 싣는다. 페이지를 그리지 않은 요청에서 일어났으면 쿠키 `dokebi.history-reset`(Path=/, 10분, HttpOnly, SameSite=Lax, https면 Secure)이 다음 페이지까지 들고 가고 그 페이지가 지운다.
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Awaitable } from '@dokebi/core';
|
|
|
2
2
|
import type { FastifyRequest } from 'fastify';
|
|
3
3
|
import { type InertiaApi, type Props } from './responder.js';
|
|
4
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
|
+
export type { InertiaApi, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
|
|
6
6
|
export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
|
|
7
7
|
export interface InertiaEntry {
|
|
8
8
|
readonly shared?: InertiaShared;
|
|
@@ -46,6 +46,13 @@ export interface InertiaScopeOptions {
|
|
|
46
46
|
readonly ssr?: InertiaSsrOptions | false;
|
|
47
47
|
/** Props merged after the plugin's shared props for every page of this scope. */
|
|
48
48
|
readonly shared?: InertiaShared;
|
|
49
|
+
/** History encryption of this scope's pages (default `'sealed'`: on for sealed requests). */
|
|
50
|
+
readonly encryptHistory?: boolean | 'sealed';
|
|
51
|
+
/**
|
|
52
|
+
* This scope answers seal hosts (dokebi-app sets it, D-0062). Server rendering would write the
|
|
53
|
+
* props as plain HTML, so `sealed` with SSR stops the start (`SEAL_SSR_CONFLICT`).
|
|
54
|
+
*/
|
|
55
|
+
readonly sealed?: boolean;
|
|
49
56
|
}
|
|
50
57
|
export interface InertiaPluginApi extends InertiaApi {
|
|
51
58
|
/**
|
package/dist/index.js
CHANGED
|
@@ -137,14 +137,30 @@ async function viteAssets(vite) {
|
|
|
137
137
|
// Written here so the provider package resolves from @dokebi/template-inertia, not from core.
|
|
138
138
|
const web = providerDependency({
|
|
139
139
|
name: 'dokebi-web',
|
|
140
|
-
version: '^0.
|
|
140
|
+
version: '^0.2.0',
|
|
141
141
|
packageName: '@dokebi/web',
|
|
142
142
|
load: () => import('@dokebi/web'),
|
|
143
143
|
});
|
|
144
|
+
/**
|
|
145
|
+
* Carries a history reset (login, logout) to the next page when the request that did it shows
|
|
146
|
+
* no page itself (a POST that redirects): `clearHistory` then goes out with that next page.
|
|
147
|
+
*/
|
|
148
|
+
const HISTORY_RESET_COOKIE = 'dokebi.history-reset';
|
|
149
|
+
const HISTORY_RESET_SECONDS = 600;
|
|
150
|
+
function hasCookie(header, name) {
|
|
151
|
+
const text = Array.isArray(header) ? header.join('; ') : (header ?? '');
|
|
152
|
+
if (text.length > 16_384)
|
|
153
|
+
return false;
|
|
154
|
+
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
155
|
+
}
|
|
144
156
|
const session = optionalDependency({
|
|
145
157
|
name: 'dokebi-session',
|
|
146
|
-
version: '^0.1.0',
|
|
158
|
+
version: '^0.1.0 || ^0.2.0',
|
|
147
159
|
});
|
|
160
|
+
/** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
|
|
161
|
+
function sealSsrConflict() {
|
|
162
|
+
return Object.assign(new Error('An Inertia scope of seal hosts cannot use SSR: server rendering writes the props as plain HTML. Set ssr: false for this app or drop seal from its hosts.'), { code: 'SEAL_SSR_CONFLICT' });
|
|
163
|
+
}
|
|
148
164
|
function parseOptions(input) {
|
|
149
165
|
const { vite, ssr: ssrInput, ...rest } = (input ?? {});
|
|
150
166
|
const ssr = parseSsr(ssrInput);
|
|
@@ -208,6 +224,47 @@ class InertiaRuntime {
|
|
|
208
224
|
const flash = sessions
|
|
209
225
|
? async (request) => (await sessions.of(request).takeFlash())
|
|
210
226
|
: undefined;
|
|
227
|
+
// D-0062: sealed requests (dokebi-web decides) and the session's history reset (logout, login).
|
|
228
|
+
// A page shown in the request that reset uses it at once; otherwise the reset cookie carries
|
|
229
|
+
// it to the next page, which also clears that cookie.
|
|
230
|
+
const resets = new WeakMap();
|
|
231
|
+
const sessionOf = (request) =>
|
|
232
|
+
// Static paths excluded by the session plugin have none (`request.session` is null).
|
|
233
|
+
request.session ? sessions?.of(request) : undefined;
|
|
234
|
+
const hooks = Object.freeze({
|
|
235
|
+
sealOf: (request) => server.sealOf(request),
|
|
236
|
+
...(sessions
|
|
237
|
+
? {
|
|
238
|
+
historyReset: async (request) => {
|
|
239
|
+
const now = sessionOf(request)?.historyReset?.() === true;
|
|
240
|
+
const carried = hasCookie(request.headers.cookie, HISTORY_RESET_COOKIE);
|
|
241
|
+
if (now || carried)
|
|
242
|
+
resets.set(request, { served: now, read: carried });
|
|
243
|
+
return now || carried;
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: {}),
|
|
247
|
+
});
|
|
248
|
+
if (sessions) {
|
|
249
|
+
const carry = async (scope) => {
|
|
250
|
+
scope.addHook('onSend', async (request, reply, payload) => {
|
|
251
|
+
const state = resets.get(request);
|
|
252
|
+
const reset = sessionOf(request)?.historyReset?.() === true;
|
|
253
|
+
if (!(reset && !state?.served) && !state?.read)
|
|
254
|
+
return payload;
|
|
255
|
+
const set = reset && !state?.served;
|
|
256
|
+
reply.header('set-cookie', `${HISTORY_RESET_COOKIE}=${set ? '1' : ''}; Path=/; Max-Age=${set ? HISTORY_RESET_SECONDS : 0}; HttpOnly; SameSite=Lax${request.protocol === 'https' ? '; Secure' : ''}`);
|
|
257
|
+
if (logger.isEnabled('debug'))
|
|
258
|
+
logger.log('debug', 'Inertia history reset cookie', {
|
|
259
|
+
operation: set ? 'set' : 'clear',
|
|
260
|
+
});
|
|
261
|
+
return payload;
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
// Without encapsulation (skip-override), so the hook sees every route.
|
|
265
|
+
carry[Symbol.for('skip-override')] = true;
|
|
266
|
+
server.register(carry, {});
|
|
267
|
+
}
|
|
211
268
|
// Development (D-0045): one Vite dev server for every scope, started by the first `vite`.
|
|
212
269
|
const develop = viteDevRequested();
|
|
213
270
|
logger.log('debug', 'Inertia settings resolved', {
|
|
@@ -217,6 +274,8 @@ class InertiaRuntime {
|
|
|
217
274
|
defaultVersion: this.settings.defaultVersion,
|
|
218
275
|
shared: this.shared !== undefined,
|
|
219
276
|
flash: flash !== undefined,
|
|
277
|
+
historyReset: hooks.historyReset !== undefined,
|
|
278
|
+
encryptHistory: this.settings.document.encryptHistory,
|
|
220
279
|
});
|
|
221
280
|
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
222
281
|
const repeats = new Repeats();
|
|
@@ -411,9 +470,9 @@ class InertiaRuntime {
|
|
|
411
470
|
const render = responder.render.bind(responder);
|
|
412
471
|
return Object.freeze({
|
|
413
472
|
render: develop
|
|
414
|
-
? (request, reply, component, props) => {
|
|
473
|
+
? (request, reply, component, props, options) => {
|
|
415
474
|
pageHosts.add(hostName(request));
|
|
416
|
-
return render(request, reply, component, props);
|
|
475
|
+
return render(request, reply, component, props, options);
|
|
417
476
|
}
|
|
418
477
|
: render,
|
|
419
478
|
redirect: responder.redirect.bind(responder),
|
|
@@ -422,14 +481,24 @@ class InertiaRuntime {
|
|
|
422
481
|
};
|
|
423
482
|
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
424
483
|
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));
|
|
484
|
+
const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
|
|
426
485
|
const shared = this.shared;
|
|
427
486
|
return Object.freeze({
|
|
428
487
|
...root,
|
|
429
488
|
ssrStats: () => Object.freeze({ ...stats }),
|
|
430
489
|
scope: async (options) => {
|
|
431
|
-
const { shared: extra, vite, ssr: ssrInput, ...fields } = (options ?? {});
|
|
490
|
+
const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
|
|
491
|
+
if (typeof sealed !== 'boolean')
|
|
492
|
+
throw new TypeError('Inertia scope sealed must be a boolean');
|
|
432
493
|
const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
|
|
494
|
+
if (sealed && ssr) {
|
|
495
|
+
logger.log('error', 'Inertia scope of seal hosts with SSR', {
|
|
496
|
+
code: 'SEAL_SSR_CONFLICT',
|
|
497
|
+
title: typeof fields.title === 'string' ? fields.title.slice(0, 100) : null,
|
|
498
|
+
fix: 'Set inertia.ssr: false for this app, or remove seal: true from its domains',
|
|
499
|
+
});
|
|
500
|
+
throw sealSsrConflict();
|
|
501
|
+
}
|
|
433
502
|
if (extra !== undefined && typeof extra !== 'function')
|
|
434
503
|
throw new TypeError('Inertia scope shared must be a function');
|
|
435
504
|
if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
|
|
@@ -443,13 +512,15 @@ class InertiaRuntime {
|
|
|
443
512
|
? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
|
|
444
513
|
: (extra ?? shared);
|
|
445
514
|
const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
|
|
446
|
-
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash);
|
|
515
|
+
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash, hooks);
|
|
447
516
|
logger.log('debug', 'Inertia scope created', {
|
|
448
517
|
title: scoped.title,
|
|
449
518
|
rootId: scoped.rootId,
|
|
450
519
|
vite: scopedVite ? scopedVite.entry : false,
|
|
451
520
|
ssr: ssr ? ssr.bundle : false,
|
|
452
521
|
shared: merged !== undefined,
|
|
522
|
+
sealed,
|
|
523
|
+
encryptHistory: scoped.encryptHistory,
|
|
453
524
|
});
|
|
454
525
|
return api(responder);
|
|
455
526
|
},
|
|
@@ -458,7 +529,7 @@ class InertiaRuntime {
|
|
|
458
529
|
}
|
|
459
530
|
export const createPlugin = definePlugin({
|
|
460
531
|
name: 'dokebi-template-inertia',
|
|
461
|
-
version: '0.
|
|
532
|
+
version: '0.2.0',
|
|
462
533
|
core: '^0.2.0',
|
|
463
534
|
options: parseOptions,
|
|
464
535
|
entry: parseEntry,
|
package/dist/responder.d.ts
CHANGED
|
@@ -13,6 +13,31 @@ export interface InertiaOptions {
|
|
|
13
13
|
title?: string;
|
|
14
14
|
scripts?: readonly string[];
|
|
15
15
|
styles?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Inertia history encryption of the pages (D-0062): `true`, `false`, or `'sealed'` (default):
|
|
18
|
+
* on for sealed requests (seal hosts), so props kept in the browser history are encrypted too.
|
|
19
|
+
*/
|
|
20
|
+
encryptHistory?: boolean | 'sealed';
|
|
21
|
+
}
|
|
22
|
+
/** Per response history settings; unset values come from the scope (`encryptHistory`) and session. */
|
|
23
|
+
export interface InertiaRenderOptions {
|
|
24
|
+
/** Encrypt this page in the browser history (Inertia `encryptHistory`). */
|
|
25
|
+
readonly encryptHistory?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Clear the encrypted history before showing this page. Unset: true for the first page after
|
|
28
|
+
* the session was destroyed or regenerated (@dokebi/session 0.2 `historyReset`, logout, login).
|
|
29
|
+
*/
|
|
30
|
+
readonly clearHistory?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What the plugin gives a responder besides its options: the sealing of a request (dokebi-web
|
|
34
|
+
* `sealOf`) and the session's history reset signal.
|
|
35
|
+
*/
|
|
36
|
+
export interface InertiaHooks {
|
|
37
|
+
readonly sealOf?: (request: FastifyRequest) => {
|
|
38
|
+
encode(text: string): string;
|
|
39
|
+
} | undefined;
|
|
40
|
+
readonly historyReset?: (request: FastifyRequest) => Promise<boolean>;
|
|
16
41
|
}
|
|
17
42
|
/** The page object Inertia hands to the client and to the SSR bundle. */
|
|
18
43
|
export interface InertiaPage {
|
|
@@ -66,7 +91,7 @@ export interface InertiaSsr {
|
|
|
66
91
|
readonly failed?: (error: unknown, component: string) => void;
|
|
67
92
|
}
|
|
68
93
|
export interface InertiaApi {
|
|
69
|
-
render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props): Promise<FastifyReply>;
|
|
94
|
+
render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props, options?: InertiaRenderOptions): Promise<FastifyReply>;
|
|
70
95
|
redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
|
|
71
96
|
location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
|
|
72
97
|
}
|
|
@@ -79,10 +104,11 @@ export declare class InertiaResponder implements InertiaApi {
|
|
|
79
104
|
readonly ssr?: InertiaSsr | undefined;
|
|
80
105
|
readonly diagnostics?: InertiaDiagnostics | undefined;
|
|
81
106
|
readonly flash?: InertiaFlash | undefined;
|
|
107
|
+
readonly hooks: InertiaHooks;
|
|
82
108
|
readonly options: NormalizedInertiaOptions;
|
|
83
|
-
constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined);
|
|
109
|
+
constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
|
|
84
110
|
close(): void;
|
|
85
|
-
render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props): Promise<never>;
|
|
111
|
+
render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props, history?: InertiaRenderOptions): Promise<never>;
|
|
86
112
|
redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
|
|
87
113
|
location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
|
|
88
114
|
}
|
package/dist/responder.js
CHANGED
|
@@ -25,7 +25,7 @@ export function normalizeInertiaOptions(options = {}) {
|
|
|
25
25
|
if (!options ||
|
|
26
26
|
typeof options !== 'object' ||
|
|
27
27
|
Array.isArray(options) ||
|
|
28
|
-
Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles'].includes(key)))
|
|
28
|
+
Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles', 'encryptHistory'].includes(key)))
|
|
29
29
|
throw new TypeError('Invalid Inertia options');
|
|
30
30
|
for (const assets of [options.scripts, options.styles]) {
|
|
31
31
|
if (assets !== undefined &&
|
|
@@ -38,11 +38,13 @@ export function normalizeInertiaOptions(options = {}) {
|
|
|
38
38
|
title: options.title ?? '',
|
|
39
39
|
scripts: Object.freeze([...(options.scripts ?? [])]),
|
|
40
40
|
styles: Object.freeze([...(options.styles ?? [])]),
|
|
41
|
+
encryptHistory: options.encryptHistory ?? 'sealed',
|
|
41
42
|
};
|
|
42
43
|
if (typeof normalized.version !== 'string' ||
|
|
43
44
|
typeof normalized.title !== 'string' ||
|
|
44
45
|
typeof normalized.rootId !== 'string' ||
|
|
45
|
-
!/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId)
|
|
46
|
+
!/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId) ||
|
|
47
|
+
(typeof normalized.encryptHistory !== 'boolean' && normalized.encryptHistory !== 'sealed'))
|
|
46
48
|
throw new TypeError('Invalid Inertia document settings');
|
|
47
49
|
for (const asset of [...normalized.scripts, ...normalized.styles])
|
|
48
50
|
localUrl(asset);
|
|
@@ -53,13 +55,15 @@ export class InertiaResponder {
|
|
|
53
55
|
ssr;
|
|
54
56
|
diagnostics;
|
|
55
57
|
flash;
|
|
58
|
+
hooks;
|
|
56
59
|
options;
|
|
57
60
|
#closed = false;
|
|
58
|
-
constructor(options = {}, shared, ssr, diagnostics, flash) {
|
|
61
|
+
constructor(options = {}, shared, ssr, diagnostics, flash, hooks = {}) {
|
|
59
62
|
this.shared = shared;
|
|
60
63
|
this.ssr = ssr;
|
|
61
64
|
this.diagnostics = diagnostics;
|
|
62
65
|
this.flash = flash;
|
|
66
|
+
this.hooks = hooks;
|
|
63
67
|
this.options = normalizeInertiaOptions(options);
|
|
64
68
|
}
|
|
65
69
|
close() {
|
|
@@ -69,8 +73,13 @@ export class InertiaResponder {
|
|
|
69
73
|
if (this.#closed)
|
|
70
74
|
throw new Error('Inertia responder is closed');
|
|
71
75
|
}
|
|
72
|
-
async render(request, reply, component, input = {}) {
|
|
76
|
+
async render(request, reply, component, input = {}, history = {}) {
|
|
73
77
|
this.#check();
|
|
78
|
+
if (history === null ||
|
|
79
|
+
typeof history !== 'object' ||
|
|
80
|
+
(history.encryptHistory !== undefined && typeof history.encryptHistory !== 'boolean') ||
|
|
81
|
+
(history.clearHistory !== undefined && typeof history.clearHistory !== 'boolean'))
|
|
82
|
+
throw new TypeError('Inertia render options are { encryptHistory?, clearHistory? } booleans');
|
|
74
83
|
if (!['GET', 'HEAD'].includes(request.method))
|
|
75
84
|
throw new Error('Render pages using GET/HEAD; redirect mutations or use REST');
|
|
76
85
|
if (typeof component !== 'string' ||
|
|
@@ -88,6 +97,10 @@ export class InertiaResponder {
|
|
|
88
97
|
.header('X-Inertia-Version', this.options.version)
|
|
89
98
|
.send();
|
|
90
99
|
}
|
|
100
|
+
// A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML.
|
|
101
|
+
const sealer = this.hooks.sealOf?.(request);
|
|
102
|
+
if (sealer && this.ssr && !inertia)
|
|
103
|
+
throw Object.assign(new Error('A sealed page cannot be server rendered: SSR writes the props as plain HTML. Turn off ssr for this app or seal for its hosts.'), { code: 'SEAL_SSR_CONFLICT' });
|
|
91
104
|
const props = { ...(await this.shared?.(request)), ...input };
|
|
92
105
|
const partial = inertia && request.headers['x-inertia-partial-component'] === component;
|
|
93
106
|
const list = (key) => typeof request.headers[key] === 'string'
|
|
@@ -111,6 +124,11 @@ export class InertiaResponder {
|
|
|
111
124
|
const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
|
|
112
125
|
// Read after the props: a failed render leaves the flash for the next page.
|
|
113
126
|
const flash = this.flash && !deferredFetch ? await this.flash(request) : undefined;
|
|
127
|
+
// Read like the flash: once, by the page that shows it (logout or login happened before).
|
|
128
|
+
const clearHistory = history.clearHistory ??
|
|
129
|
+
(this.hooks.historyReset && !deferredFetch ? await this.hooks.historyReset(request) : false);
|
|
130
|
+
const configured = this.options.encryptHistory;
|
|
131
|
+
const encryptHistory = history.encryptHistory ?? (configured === 'sealed' ? sealer !== undefined : configured);
|
|
114
132
|
const resolved = Object.create(null);
|
|
115
133
|
resolved.errors = {};
|
|
116
134
|
meta.selected.forEach(([key], index) => {
|
|
@@ -132,8 +150,8 @@ export class InertiaResponder {
|
|
|
132
150
|
props: resolved,
|
|
133
151
|
url: request.url,
|
|
134
152
|
version: this.options.version,
|
|
135
|
-
clearHistory
|
|
136
|
-
encryptHistory
|
|
153
|
+
clearHistory,
|
|
154
|
+
encryptHistory,
|
|
137
155
|
...meta.page,
|
|
138
156
|
...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
|
|
139
157
|
...(flash && Object.keys(flash).length ? { flash } : {}),
|
|
@@ -148,9 +166,25 @@ export class InertiaResponder {
|
|
|
148
166
|
throw new TypeError('Page props must be JSON serializable');
|
|
149
167
|
return value;
|
|
150
168
|
});
|
|
169
|
+
if (logger?.isEnabled('debug') && (sealer || clearHistory))
|
|
170
|
+
logger.log('debug', 'Inertia page history and seal', {
|
|
171
|
+
component,
|
|
172
|
+
sealed: sealer !== undefined,
|
|
173
|
+
encryptHistory,
|
|
174
|
+
clearHistory,
|
|
175
|
+
});
|
|
176
|
+
// The XHR answer is JSON: dokebi-web seals it on the way out for a sealed request.
|
|
151
177
|
if (inertia)
|
|
152
178
|
return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
|
|
153
179
|
const { rootId, title, scripts, styles } = this.options;
|
|
180
|
+
if (sealer) {
|
|
181
|
+
// `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
|
|
182
|
+
// (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
|
|
183
|
+
const frame = sealer.encode(json);
|
|
184
|
+
return reply
|
|
185
|
+
.type('text/html; charset=utf-8')
|
|
186
|
+
.send(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}"></div></body></html>`);
|
|
187
|
+
}
|
|
154
188
|
// Only the first HTML is server rendered; Inertia visits above stay JSON.
|
|
155
189
|
const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
|
|
156
190
|
const safeJson = json
|
package/options.schema.json
CHANGED
|
@@ -66,6 +66,18 @@
|
|
|
66
66
|
"description": "When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
},
|
|
70
|
+
"encryptHistory": {
|
|
71
|
+
"default": "sealed",
|
|
72
|
+
"oneOf": [
|
|
73
|
+
{
|
|
74
|
+
"type": "boolean"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"const": "sealed"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"description": "Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts"
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/template-inertia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@dokebi/core": "^0.2.0",
|
|
33
|
-
"@dokebi/web": "^0.
|
|
33
|
+
"@dokebi/web": "^0.2.0",
|
|
34
34
|
"fastify": "^5.12.7"
|
|
35
35
|
},
|
|
36
36
|
"dokebi": {
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"dependencies": [
|
|
41
41
|
{
|
|
42
42
|
"name": "dokebi-web",
|
|
43
|
-
"version": "^0.
|
|
43
|
+
"version": "^0.2.0"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"name": "dokebi-session",
|
|
47
|
-
"version": "^0.1.0",
|
|
47
|
+
"version": "^0.1.0 || ^0.2.0",
|
|
48
48
|
"optional": true
|
|
49
49
|
}
|
|
50
50
|
],
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"documentation": "AI.md"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@dokebi/session": "0.
|
|
62
|
-
"@dokebi/web": "0.
|
|
61
|
+
"@dokebi/session": "0.2.0",
|
|
62
|
+
"@dokebi/web": "0.2.0"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public",
|