@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 CHANGED
@@ -1,4 +1,4 @@
1
- # AI 작업 안내 — @dokebi/template-inertia 0.1.3
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.3
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.1.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.1.3',
532
+ version: '0.2.0',
462
533
  core: '^0.2.0',
463
534
  options: parseOptions,
464
535
  entry: parseEntry,
@@ -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: false,
136
- encryptHistory: false,
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
@@ -66,6 +66,18 @@
66
66
  "description": "When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)"
67
67
  }
68
68
  }
69
+ },
70
+ "encryptHistory": {
71
+ "default": "sealed",
72
+ "oneOf": [
73
+ {
74
+ "type": "boolean"
75
+ },
76
+ {
77
+ "const": "sealed"
78
+ }
79
+ ],
80
+ "description": "Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts"
69
81
  }
70
82
  }
71
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/template-inertia",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@dokebi/core": "^0.2.0",
33
- "@dokebi/web": "^0.1.0",
33
+ "@dokebi/web": "^0.2.0",
34
34
  "fastify": "^5.12.7"
35
35
  },
36
36
  "dokebi": {
@@ -40,11 +40,11 @@
40
40
  "dependencies": [
41
41
  {
42
42
  "name": "dokebi-web",
43
- "version": "^0.1.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.1.0",
62
- "@dokebi/web": "0.1.2"
61
+ "@dokebi/session": "0.2.0",
62
+ "@dokebi/web": "0.2.0"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public",