@dokebi/template-inertia 0.1.3 → 0.4.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/dist/props.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { InertiaUsageError } from './errors.js';
1
2
  const BRAND = Symbol.for('dokebi.template-inertia.prop');
2
3
  /**
3
4
  * A prop value with Inertia protocol behaviour (D-0052), made by `defer`, `merge`, `deepMerge` or
@@ -30,7 +31,7 @@ export function isInertiaProp(value) {
30
31
  const NAME = /^[^,\s]{1,128}$/;
31
32
  function checkValue(value, helper) {
32
33
  if (value === undefined || isInertiaProp(value))
33
- throw new TypeError(`${helper}() takes a JSON value or a function, not ${String(value)}`);
34
+ throw new InertiaUsageError('INVALID_PROP_HELPER', `${helper}() takes a JSON value or a function, not ${String(value)}`);
34
35
  return value;
35
36
  }
36
37
  function mergeSettings(options) {
@@ -40,7 +41,7 @@ function mergeSettings(options) {
40
41
  !['append', 'prepend', 'deep'].includes(mode) ||
41
42
  !Array.isArray(fields) ||
42
43
  fields.some((field) => typeof field !== 'string' || !NAME.test(field)))
43
- throw new TypeError('Invalid Inertia merge options');
44
+ throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia merge options');
44
45
  return Object.freeze({ mode, matchOn: Object.freeze([...fields]) });
45
46
  }
46
47
  function onceSettings(options) {
@@ -48,7 +49,7 @@ function onceSettings(options) {
48
49
  if (Object.keys(rest).length ||
49
50
  (key !== undefined && (typeof key !== 'string' || !NAME.test(key))) ||
50
51
  (ttlMs !== undefined && !(typeof ttlMs === 'number' && Number.isFinite(ttlMs) && ttlMs > 0)))
51
- throw new TypeError('Invalid Inertia once options');
52
+ throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia once options');
52
53
  return Object.freeze({ key, ttlMs });
53
54
  }
54
55
  /**
@@ -57,14 +58,14 @@ function onceSettings(options) {
57
58
  */
58
59
  export function defer(fn, options) {
59
60
  if (typeof fn !== 'function')
60
- throw new TypeError('defer() takes a function');
61
+ throw new InertiaUsageError('INVALID_PROP_HELPER', 'defer() takes a function');
61
62
  const settings = typeof options === 'string' ? { group: options } : (options ?? {});
62
63
  const { group = 'default', merge, once, rescue = false, ...rest } = settings;
63
64
  if (Object.keys(rest).length ||
64
65
  typeof group !== 'string' ||
65
66
  !NAME.test(group) ||
66
67
  typeof rescue !== 'boolean')
67
- throw new TypeError('Invalid Inertia defer options');
68
+ throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia defer options');
68
69
  return new InertiaProp(fn, group, merge === undefined ? undefined : mergeSettings(merge), once === undefined ? undefined : onceSettings(once), rescue);
69
70
  }
70
71
  /** A prop the client merges into the value it holds on partial reloads (pagination, feeds). */
@@ -1,7 +1,8 @@
1
1
  import type { Awaitable, Logger } from '@dokebi/core';
2
+ import { diagnostics } from '@dokebi/core/libraries';
3
+ import type { WebErrorResponse } from '@dokebi/web';
2
4
  import type { FastifyReply, FastifyRequest } from 'fastify';
3
5
  import { type InertiaProp } from './props.js';
4
- import { type Repeats } from './repeats.js';
5
6
  export type Json = null | boolean | number | string | readonly Json[] | {
6
7
  readonly [key: string]: Json;
7
8
  };
@@ -13,6 +14,44 @@ export interface InertiaOptions {
13
14
  title?: string;
14
15
  scripts?: readonly string[];
15
16
  styles?: readonly string[];
17
+ /**
18
+ * Inertia history encryption of the pages (D-0062): `true`, `false`, or `'sealed'` (default):
19
+ * on for sealed requests (seal hosts), so props kept in the browser history are encrypted too.
20
+ */
21
+ encryptHistory?: boolean | 'sealed';
22
+ /**
23
+ * The page component of error answers (D-0074), rendered with `{ status, error, message,
24
+ * hint }` (and `fix` while developing) for Inertia visits and full page loads; `false` or
25
+ * unset: JSON for visits, dokebi-web's small HTML page for page loads.
26
+ */
27
+ errorPage?: string | false;
28
+ }
29
+ /** What `answerError` reads of a dokebi-web error answer (`WebErrorResponse`). */
30
+ export type InertiaErrorResponse = Pick<WebErrorResponse, 'request' | 'reply' | 'status' | 'body' | 'format' | 'sealed'>;
31
+ /** Per response history settings; unset values come from the scope (`encryptHistory`) and session. */
32
+ export interface InertiaRenderOptions {
33
+ /** Encrypt this page in the browser history (Inertia `encryptHistory`). */
34
+ readonly encryptHistory?: boolean;
35
+ /**
36
+ * Clear the encrypted history before showing this page. Unset: true for the first page after
37
+ * the session was destroyed or regenerated (@dokebi/session 0.2 `historyReset`, logout, login).
38
+ */
39
+ readonly clearHistory?: boolean;
40
+ }
41
+ /**
42
+ * What the plugin gives a responder besides its options: the sealing of a request (dokebi-web
43
+ * `sealOf`) and the session's history reset signal.
44
+ */
45
+ export interface InertiaHooks {
46
+ readonly sealOf?: (request: FastifyRequest) => {
47
+ encode(text: string): string;
48
+ } | undefined;
49
+ readonly historyReset?: (request: FastifyRequest) => Promise<boolean>;
50
+ /**
51
+ * The validation redirect of an Inertia request (D-0074 §3): flashes `errors` (and `old`) in
52
+ * the session and answers 303 back; false when the request has no session or no way back.
53
+ */
54
+ readonly validation?: (request: FastifyRequest, reply: FastifyReply, errors: Readonly<Record<string, string>>) => Promise<boolean>;
16
55
  }
17
56
  /** The page object Inertia hands to the client and to the SSR bundle. */
18
57
  export interface InertiaPage {
@@ -48,7 +87,7 @@ export type InertiaFlash = (request: FastifyRequest) => Promise<Record<string, J
48
87
  /** Where the responder reports prop evaluation (debug) and rescued prop failures (error). */
49
88
  export interface InertiaDiagnostics {
50
89
  readonly logger: Logger;
51
- readonly repeats: Repeats;
90
+ readonly repeats: InstanceType<typeof diagnostics.Repeats>;
52
91
  }
53
92
  /** What an SSR bundle's `render(page)` returns: head tags and the root (script + element). */
54
93
  export interface InertiaSsrResult {
@@ -66,10 +105,17 @@ export interface InertiaSsr {
66
105
  readonly failed?: (error: unknown, component: string) => void;
67
106
  }
68
107
  export interface InertiaApi {
69
- render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props): Promise<FastifyReply>;
108
+ render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props, options?: InertiaRenderOptions): Promise<FastifyReply>;
70
109
  redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
71
110
  location(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
111
+ /**
112
+ * Answers an error of this scope's requests (D-0074 §3; dokebi-app and the plugin call it from
113
+ * dokebi-web's error responders): the validation redirect, or the `errorPage` component.
114
+ * Resolves to false when it wrote nothing.
115
+ */
116
+ answerError(response: InertiaErrorResponse): Promise<boolean>;
72
117
  }
118
+ export declare function vary(reply: FastifyReply): void;
73
119
  export type NormalizedInertiaOptions = Readonly<Required<InertiaOptions>>;
74
120
  /** Validates and fills defaults; the result is accepted again unchanged. */
75
121
  export declare function normalizeInertiaOptions(options?: InertiaOptions): NormalizedInertiaOptions;
@@ -79,10 +125,18 @@ export declare class InertiaResponder implements InertiaApi {
79
125
  readonly ssr?: InertiaSsr | undefined;
80
126
  readonly diagnostics?: InertiaDiagnostics | undefined;
81
127
  readonly flash?: InertiaFlash | undefined;
128
+ readonly hooks: InertiaHooks;
82
129
  readonly options: NormalizedInertiaOptions;
83
- constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined);
130
+ constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
84
131
  close(): void;
85
- render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props): Promise<never>;
132
+ render(request: FastifyRequest, reply: FastifyReply, component: string, input?: Props, history?: InertiaRenderOptions): Promise<never>;
133
+ /**
134
+ * The error answer of an Inertia request or page load (D-0074 §3), for dokebi-web's error
135
+ * responders: the validation redirect (an Inertia request refused with 422 VALIDATION_FAILED,
136
+ * with a session), then the `errorPage` component. False leaves the answer to dokebi-web (JSON
137
+ * for visits, a small HTML page for page loads).
138
+ */
139
+ answerError(response: InertiaErrorResponse): Promise<boolean>;
86
140
  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
141
  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
142
  }
package/dist/responder.js CHANGED
@@ -1,15 +1,16 @@
1
+ import { diagnostics } from '@dokebi/core/libraries';
2
+ import { InertiaError, InertiaUsageError } from './errors.js';
1
3
  import { isInertiaProp } from './props.js';
2
- import { errorText } from './repeats.js';
3
4
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
4
5
  function localUrl(value) {
5
6
  if (typeof value !== 'string' ||
6
7
  !value.startsWith('/') ||
7
8
  value.startsWith('//') ||
8
9
  [...value].some((char) => char === '\\' || char.charCodeAt(0) <= 32))
9
- throw new TypeError('Expected a local absolute URL');
10
+ throw new InertiaUsageError('INVALID_URL', 'Expected a local absolute URL');
10
11
  return value;
11
12
  }
12
- function vary(reply) {
13
+ export function vary(reply) {
13
14
  const values = String(reply.getHeader('vary') ?? '')
14
15
  .split(',')
15
16
  .map((value) => value.trim())
@@ -20,17 +21,31 @@ function vary(reply) {
20
21
  // Page props may contain user-specific state, including on version mismatch responses.
21
22
  reply.header('Cache-Control', 'no-store');
22
23
  }
24
+ /** A page component name: `Error`, `Pages/Error`. */
25
+ function validComponent(component) {
26
+ return (typeof component === 'string' &&
27
+ component.length <= 256 &&
28
+ component.split('/').every((part) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(part)));
29
+ }
23
30
  /** Validates and fills defaults; the result is accepted again unchanged. */
24
31
  export function normalizeInertiaOptions(options = {}) {
25
32
  if (!options ||
26
33
  typeof options !== 'object' ||
27
34
  Array.isArray(options) ||
28
- Object.keys(options).some((key) => !['version', 'rootId', 'title', 'scripts', 'styles'].includes(key)))
29
- throw new TypeError('Invalid Inertia options');
35
+ Object.keys(options).some((key) => ![
36
+ 'version',
37
+ 'rootId',
38
+ 'title',
39
+ 'scripts',
40
+ 'styles',
41
+ 'encryptHistory',
42
+ 'errorPage',
43
+ ].includes(key)))
44
+ throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia options');
30
45
  for (const assets of [options.scripts, options.styles]) {
31
46
  if (assets !== undefined &&
32
47
  (!Array.isArray(assets) || assets.some((asset) => typeof asset !== 'string')))
33
- throw new TypeError('Assets must be arrays of URLs');
48
+ throw new InertiaUsageError('INVALID_SCOPE', 'Assets must be arrays of URLs');
34
49
  }
35
50
  const normalized = {
36
51
  version: options.version ?? 'development',
@@ -38,28 +53,79 @@ export function normalizeInertiaOptions(options = {}) {
38
53
  title: options.title ?? '',
39
54
  scripts: Object.freeze([...(options.scripts ?? [])]),
40
55
  styles: Object.freeze([...(options.styles ?? [])]),
56
+ encryptHistory: options.encryptHistory ?? 'sealed',
57
+ errorPage: options.errorPage ?? false,
41
58
  };
42
59
  if (typeof normalized.version !== 'string' ||
43
60
  typeof normalized.title !== 'string' ||
44
61
  typeof normalized.rootId !== 'string' ||
45
- !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId))
46
- throw new TypeError('Invalid Inertia document settings');
62
+ !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId) ||
63
+ (typeof normalized.encryptHistory !== 'boolean' && normalized.encryptHistory !== 'sealed') ||
64
+ (normalized.errorPage !== false && !validComponent(normalized.errorPage)))
65
+ throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia document settings');
47
66
  for (const asset of [...normalized.scripts, ...normalized.styles])
48
67
  localUrl(asset);
49
68
  return Object.freeze(normalized);
50
69
  }
70
+ const UNSUPPORTED = 'Page props must be JSON serializable';
71
+ /**
72
+ * Fails on a value JSON.stringify would drop or change silently (undefined, a function, a
73
+ * symbol, NaN or ±Infinity; a BigInt or a cycle fails in stringify itself). A plain walk before
74
+ * a stringify without replacer: a replacer takes V8 off its fast path (4.8× the stringify time
75
+ * for a 30 KB page, D-0068). Objects with toJSON are checked as what toJSON returns.
76
+ */
77
+ function checkJson(value, depth) {
78
+ if (depth > 512)
79
+ return; // deeper than this is a cycle or abuse; stringify reports it
80
+ if (Array.isArray(value)) {
81
+ for (let index = 0; index < value.length; index++)
82
+ checkValue(value[index], index, depth);
83
+ return;
84
+ }
85
+ const object = value;
86
+ for (const key in object)
87
+ if (Object.hasOwn(object, key))
88
+ checkValue(object[key], key, depth);
89
+ }
90
+ function checkValue(value, key, depth) {
91
+ const type = typeof value;
92
+ if (type === 'string' || type === 'boolean')
93
+ return;
94
+ if (type === 'number') {
95
+ if (Number.isFinite(value))
96
+ return;
97
+ }
98
+ else if (type === 'object') {
99
+ if (value === null)
100
+ return;
101
+ const json = value.toJSON;
102
+ if (typeof json === 'function')
103
+ checkValue(json.call(value, String(key)), key, depth);
104
+ else
105
+ checkJson(value, depth + 1);
106
+ return;
107
+ }
108
+ throw new InertiaUsageError('INVALID_PROPS', UNSUPPORTED);
109
+ }
51
110
  export class InertiaResponder {
52
111
  shared;
53
112
  ssr;
54
113
  diagnostics;
55
114
  flash;
115
+ hooks;
56
116
  options;
117
+ /**
118
+ * Every value of a page is checked outside production (where the mistake is made and seen);
119
+ * in production only the top-level props are, and the page goes to JSON.stringify as it is.
120
+ */
121
+ #deepCheck = process.env.NODE_ENV !== 'production';
57
122
  #closed = false;
58
- constructor(options = {}, shared, ssr, diagnostics, flash) {
123
+ constructor(options = {}, shared, ssr, diagnostics, flash, hooks = {}) {
59
124
  this.shared = shared;
60
125
  this.ssr = ssr;
61
126
  this.diagnostics = diagnostics;
62
127
  this.flash = flash;
128
+ this.hooks = hooks;
63
129
  this.options = normalizeInertiaOptions(options);
64
130
  }
65
131
  close() {
@@ -67,16 +133,66 @@ export class InertiaResponder {
67
133
  }
68
134
  #check() {
69
135
  if (this.#closed)
70
- throw new Error('Inertia responder is closed');
136
+ throw new InertiaError('CLOSED', 'Inertia responder is closed');
71
137
  }
72
- async render(request, reply, component, input = {}) {
138
+ async render(request, reply, component, input = {}, history = {}) {
73
139
  this.#check();
140
+ if (history === null ||
141
+ typeof history !== 'object' ||
142
+ (history.encryptHistory !== undefined && typeof history.encryptHistory !== 'boolean') ||
143
+ (history.clearHistory !== undefined && typeof history.clearHistory !== 'boolean'))
144
+ throw new InertiaUsageError('INVALID_RENDER', 'Inertia render options are { encryptHistory?, clearHistory? } booleans');
74
145
  if (!['GET', 'HEAD'].includes(request.method))
75
- throw new Error('Render pages using GET/HEAD; redirect mutations or use REST');
76
- if (typeof component !== 'string' ||
77
- component.length > 256 ||
78
- !component.split('/').every((part) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(part)))
79
- throw new TypeError('Invalid page component');
146
+ throw new InertiaError('METHOD_NOT_ALLOWED', 'Render pages using GET/HEAD; redirect mutations or use REST');
147
+ if (!validComponent(component))
148
+ throw new InertiaUsageError('INVALID_RENDER', 'Invalid page component');
149
+ return this.#page(request, reply, component, input, history, true);
150
+ }
151
+ /**
152
+ * The error answer of an Inertia request or page load (D-0074 §3), for dokebi-web's error
153
+ * responders: the validation redirect (an Inertia request refused with 422 VALIDATION_FAILED,
154
+ * with a session), then the `errorPage` component. False leaves the answer to dokebi-web (JSON
155
+ * for visits, a small HTML page for page loads).
156
+ */
157
+ async answerError(response) {
158
+ if (this.#closed)
159
+ return false;
160
+ const { request, reply, status, body } = response;
161
+ const inertia = request.headers['x-inertia'] === 'true';
162
+ const validation = body.error === 'VALIDATION_FAILED' &&
163
+ body.errors !== null &&
164
+ typeof body.errors === 'object' &&
165
+ !Array.isArray(body.errors);
166
+ if (inertia && validation && this.hooks.validation) {
167
+ if (await this.hooks.validation(request, reply, body.errors))
168
+ return true;
169
+ // No session or no way back: the 422 JSON with `errors` (onHttpException reads it).
170
+ return false;
171
+ }
172
+ const component = this.options.errorPage;
173
+ if (component === false || validation)
174
+ return false;
175
+ const accept = request.headers.accept;
176
+ // A page load (GET) of a sealed host is a page too: the error page leaves sealed like any.
177
+ const pageLoad = response.format === 'html' ||
178
+ (response.sealed &&
179
+ ['GET', 'HEAD'].includes(request.method) &&
180
+ typeof accept === 'string' &&
181
+ accept.includes('text/html'));
182
+ if (!inertia && !pageLoad)
183
+ return false;
184
+ const text = (value) => (typeof value === 'string' ? value : null);
185
+ reply.code(status);
186
+ await this.#page(request, reply, component, {
187
+ status,
188
+ error: body.error,
189
+ message: text(body.message),
190
+ hint: text(body.hint),
191
+ ...(typeof body.fix === 'string' ? { fix: body.fix } : {}),
192
+ }, {}, false);
193
+ return true;
194
+ }
195
+ async #page(request, reply, component, input, history, session) {
80
196
  vary(reply);
81
197
  const inertia = request.headers['x-inertia'] === 'true';
82
198
  if (inertia &&
@@ -88,6 +204,10 @@ export class InertiaResponder {
88
204
  .header('X-Inertia-Version', this.options.version)
89
205
  .send();
90
206
  }
207
+ // A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML.
208
+ const sealer = this.hooks.sealOf?.(request);
209
+ if (sealer && this.ssr && !inertia)
210
+ throw new InertiaError('SEAL_SSR_CONFLICT', '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.');
91
211
  const props = { ...(await this.shared?.(request)), ...input };
92
212
  const partial = inertia && request.headers['x-inertia-partial-component'] === component;
93
213
  const list = (key) => typeof request.headers[key] === 'string'
@@ -109,14 +229,47 @@ export class InertiaResponder {
109
229
  return isInertiaProp(value) && value.group !== undefined;
110
230
  });
111
231
  const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
112
- // Read after the props: a failed render leaves the flash for the next page.
113
- const flash = this.flash && !deferredFetch ? await this.flash(request) : undefined;
114
- const resolved = Object.create(null);
115
- resolved.errors = {};
232
+ // Read after the props: a failed render leaves the flash for the next page. An error page
233
+ // leaves it (and the history reset) for the page the user goes to next.
234
+ const flash = this.flash && session && !deferredFetch ? await this.flash(request) : undefined;
235
+ // Read like the flash: once, by the page that shows it (logout or login happened before).
236
+ const clearHistory = history.clearHistory ??
237
+ (this.hooks.historyReset && session && !deferredFetch
238
+ ? await this.hooks.historyReset(request)
239
+ : false);
240
+ const configured = this.options.encryptHistory;
241
+ const encryptHistory = history.encryptHistory ?? (configured === 'sealed' ? sealer !== undefined : configured);
242
+ // An ordinary object: JSON.stringify of a null-prototype object leaves V8's fast path (2×
243
+ // slower for a 30 KB page, D-0068). `__proto__` is defined as an own key, never assigned.
244
+ const resolved = { errors: {} };
116
245
  meta.selected.forEach(([key], index) => {
117
- if (values[index] !== RESCUED)
246
+ if (values[index] === RESCUED)
247
+ return;
248
+ if (key === '__proto__')
249
+ Object.defineProperty(resolved, key, {
250
+ value: values[index],
251
+ enumerable: true,
252
+ writable: true,
253
+ configurable: true,
254
+ });
255
+ else
118
256
  resolved[key] = values[index];
119
257
  });
258
+ // D-0074 §3: the `errors` of a validation redirect become the `errors` prop of this page
259
+ // (under errors the handler passed itself); the rest of the flash (`old`, notices) stays flash.
260
+ let pageFlash = flash;
261
+ const flashed = flash?.errors;
262
+ if (flash && flashed !== undefined) {
263
+ const { errors: _taken, ...rest } = flash;
264
+ pageFlash = rest;
265
+ if (flashed !== null && typeof flashed === 'object' && !Array.isArray(flashed)) {
266
+ const own = resolved.errors;
267
+ resolved.errors =
268
+ own !== null && typeof own === 'object' && !Array.isArray(own)
269
+ ? { ...flashed, ...own }
270
+ : flashed;
271
+ }
272
+ }
120
273
  const logger = this.diagnostics?.logger;
121
274
  if (logger?.isEnabled('debug') && (meta.lazy.length || meta.announced))
122
275
  logger.log('debug', 'Inertia props resolved', {
@@ -132,25 +285,43 @@ export class InertiaResponder {
132
285
  props: resolved,
133
286
  url: request.url,
134
287
  version: this.options.version,
135
- clearHistory: false,
136
- encryptHistory: false,
288
+ clearHistory,
289
+ encryptHistory,
137
290
  ...meta.page,
138
291
  ...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
139
- ...(flash && Object.keys(flash).length ? { flash } : {}),
292
+ ...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
140
293
  };
141
- // Reject unsupported values rather than silently dropping application data.
142
- const json = JSON.stringify(page, (_key, value) => {
143
- if (value === undefined ||
144
- typeof value === 'function' ||
145
- typeof value === 'symbol' ||
146
- typeof value === 'bigint' ||
147
- (typeof value === 'number' && !Number.isFinite(value)))
148
- throw new TypeError('Page props must be JSON serializable');
149
- return value;
150
- });
294
+ // Reject unsupported values rather than silently dropping application data (D-0047).
295
+ if (this.#deepCheck)
296
+ checkJson(page, 0);
297
+ else
298
+ for (const key in resolved) {
299
+ // Shallow: a missing (undefined) or non-JSON top-level prop, the usual mistake.
300
+ const value = resolved[key];
301
+ if (value === null || typeof value === 'object')
302
+ continue;
303
+ checkValue(value, key, 1);
304
+ }
305
+ const json = JSON.stringify(page);
306
+ if (logger?.isEnabled('debug') && (sealer || clearHistory))
307
+ logger.log('debug', 'Inertia page history and seal', {
308
+ component,
309
+ sealed: sealer !== undefined,
310
+ encryptHistory,
311
+ clearHistory,
312
+ });
313
+ // The XHR answer is JSON: dokebi-web seals it on the way out for a sealed request.
151
314
  if (inertia)
152
315
  return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
153
316
  const { rootId, title, scripts, styles } = this.options;
317
+ if (sealer) {
318
+ // `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
319
+ // (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
320
+ const frame = sealer.encode(json);
321
+ return reply
322
+ .type('text/html; charset=utf-8')
323
+ .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>`);
324
+ }
154
325
  // Only the first HTML is server rendered; Inertia visits above stay JSON.
155
326
  const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
156
327
  const safeJson = json
@@ -189,7 +360,7 @@ export class InertiaResponder {
189
360
  code: 'INERTIA_PROP_RESCUED',
190
361
  component,
191
362
  prop: key,
192
- error: errorText(error),
363
+ error: diagnostics.describeError(error),
193
364
  ...(repeated ? { repeated } : {}),
194
365
  });
195
366
  return RESCUED;
@@ -207,10 +378,10 @@ export class InertiaResponder {
207
378
  if (!Array.isArray(head) ||
208
379
  head.some((tag) => typeof tag !== 'string') ||
209
380
  typeof body !== 'string')
210
- throw new TypeError('SSR render must return { head: string[], body: string }');
381
+ throw new InertiaUsageError('SSR_INVALID_RESULT', 'SSR render must return { head: string[], body: string }');
211
382
  // The client mounts on the root id; a bundle built for another id would never hydrate.
212
383
  if (!body.includes(`id="${rootId}"`))
213
- throw new TypeError(`SSR body has no element with id "${rootId}" (rootId option)`);
384
+ throw new InertiaUsageError('SSR_INVALID_RESULT', `SSR body has no element with id "${rootId}" (rootId option)`);
214
385
  ssr.rendered?.();
215
386
  return { head, body };
216
387
  }
@@ -234,7 +405,7 @@ export class InertiaResponder {
234
405
  if (url.startsWith('/'))
235
406
  localUrl(url);
236
407
  else if (!/^https?:$/.test(new URL(url).protocol) || /[\r\n]/.test(url))
237
- throw new TypeError('Invalid location');
408
+ throw new InertiaUsageError('INVALID_URL', 'Invalid location');
238
409
  vary(reply);
239
410
  return request.headers['x-inertia'] === 'true'
240
411
  ? reply.code(409).header('X-Inertia-Location', url).send()
@@ -1,9 +1,5 @@
1
- /** The part of Vite's dev server used here. */
2
- export interface ViteDevServer {
3
- middlewares(request: unknown, response: unknown, next: (error?: unknown) => void): void;
4
- ssrLoadModule(url: string): Promise<Record<string, unknown>>;
5
- close(): Promise<void>;
6
- }
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import type { Logger } from '@dokebi/core';
7
3
  export declare const viteDevRequested: () => boolean;
8
4
  /** The part of a Node response the dev route finishes. */
9
5
  export interface DevResponse {
@@ -12,13 +8,55 @@ export interface DevResponse {
12
8
  end(): unknown;
13
9
  }
14
10
  /**
15
- * The `next` of Vite's middlewares on a dev route: no error means Vite has nothing for the URL
16
- * (404); an error is reported (logged by the caller) and answered 500, never dropped (D-0047).
11
+ * The `next` of Vite's middlewares: no error means Vite has nothing for the URL (404); an error is
12
+ * reported (logged by the caller) and answered 500, never dropped (D-0047).
17
13
  */
18
14
  export declare function viteFallthrough(response: DevResponse, error: unknown, report: (error: unknown) => void): void;
15
+ /** Header that carries the host key; the host answers no HTTP request without it. */
16
+ export declare const VITE_HOST_KEY_HEADER = "x-dokebi-vite-key";
17
+ /** The one Vite of a development session and the loopback port it answers on. */
18
+ export interface ViteHost {
19
+ /** Where clients reach the host: `http://127.0.0.1:<port>`. */
20
+ readonly url: string;
21
+ /** Secret every HTTP request must carry in `x-dokebi-vite-key`. */
22
+ readonly key: string;
23
+ /** The port of HTTP and of the HMR socket (Vite's client connects to it). */
24
+ readonly port: number;
25
+ /** The loopback addresses listening on `port`. */
26
+ readonly hosts: readonly string[];
27
+ /** Closes Vite, the listeners and every connection; resolves once, however often called. */
28
+ close(): Promise<void>;
29
+ }
30
+ /** What an app process uses: the host's files for `<base>*` and server rendering. */
31
+ export interface ViteDev {
32
+ /** Answers a dev route request from the host (status, headers and body as Vite sent them). */
33
+ forward(request: IncomingMessage, response: ServerResponse, report: (error: unknown) => void): void;
34
+ /** Loads `entry` (relative to the project root) through Vite and calls its `render(page)`. */
35
+ render(entry: string, page: unknown): Promise<unknown>;
36
+ /** Releases this process's connections (and its own host, when it started one). */
37
+ close(): Promise<void>;
38
+ }
39
+ /**
40
+ * Starts the project's Vite (its vite.config.*) in middleware mode behind a loopback HTTP server
41
+ * of its own (127.0.0.1 and ::1 on one free port, `exclusive` so a cluster worker binds it
42
+ * itself). On that port:
43
+ * - HTTP requests with the key: Vite's middlewares (modules, pre-bundled dependencies,
44
+ * `@vite/client`), a ping (`/__dokebi/vite-host`) and server rendering
45
+ * (`POST /__dokebi/vite-host/ssr`). Without the key: 403 (a page cannot read sources through
46
+ * DNS rebinding; app processes check page hosts before forwarding).
47
+ * - WebSocket upgrades of Vite's client (HMR): Vite answers them and keeps its token check
48
+ * (cross-site WebSocket hijacking). @dokebi/websocket answers every upgrade on the app's port,
49
+ * so HMR cannot share that one.
50
+ */
51
+ export declare function startViteHost(logger: Logger, root?: string): Promise<ViteHost>;
52
+ /**
53
+ * Connects this process to a Vite host and checks that it answers with the key. Requests share
54
+ * keep-alive connections; `close` ends them (the host keeps running).
55
+ */
56
+ export declare function connectViteDev(logger: Logger, url: string, key: string): Promise<ViteDev>;
19
57
  /**
20
- * Starts the project's Vite (its vite.config.*) in middleware mode. The HMR socket gets a free
21
- * port of its own: @dokebi/websocket answers every upgrade on the app's HTTP server and closes
22
- * the ones that are not its routes, so HMR cannot share that server.
58
+ * The Vite of this process: the host of `dokebi dev` when `DOKEBI_VITE_DEV_URL` and
59
+ * `DOKEBI_VITE_DEV_KEY` name one, otherwise a host started here (then each process, each cluster
60
+ * worker, has its own Vite: run cluster apps through `dokebi dev`).
23
61
  */
24
- export declare function startViteDev(root?: string): Promise<ViteDevServer>;
62
+ export declare function openViteDev(logger: Logger, environment?: NodeJS.ProcessEnv, root?: string): Promise<ViteDev>;