@dokebi/template-inertia 0.2.0 → 0.4.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.
@@ -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
  };
@@ -18,7 +19,15 @@ export interface InertiaOptions {
18
19
  * on for sealed requests (seal hosts), so props kept in the browser history are encrypted too.
19
20
  */
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;
21
28
  }
29
+ /** What `answerError` reads of a dokebi-web error answer (`WebErrorResponse`). */
30
+ export type InertiaErrorResponse = Pick<WebErrorResponse, 'request' | 'reply' | 'status' | 'body' | 'format' | 'sealed'>;
22
31
  /** Per response history settings; unset values come from the scope (`encryptHistory`) and session. */
23
32
  export interface InertiaRenderOptions {
24
33
  /** Encrypt this page in the browser history (Inertia `encryptHistory`). */
@@ -38,6 +47,11 @@ export interface InertiaHooks {
38
47
  encode(text: string): string;
39
48
  } | undefined;
40
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>;
41
55
  }
42
56
  /** The page object Inertia hands to the client and to the SSR bundle. */
43
57
  export interface InertiaPage {
@@ -73,7 +87,7 @@ export type InertiaFlash = (request: FastifyRequest) => Promise<Record<string, J
73
87
  /** Where the responder reports prop evaluation (debug) and rescued prop failures (error). */
74
88
  export interface InertiaDiagnostics {
75
89
  readonly logger: Logger;
76
- readonly repeats: Repeats;
90
+ readonly repeats: InstanceType<typeof diagnostics.Repeats>;
77
91
  }
78
92
  /** What an SSR bundle's `render(page)` returns: head tags and the root (script + element). */
79
93
  export interface InertiaSsrResult {
@@ -94,7 +108,14 @@ export interface InertiaApi {
94
108
  render(request: FastifyRequest, reply: FastifyReply, component: string, props?: Props, options?: InertiaRenderOptions): Promise<FastifyReply>;
95
109
  redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply;
96
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>;
97
117
  }
118
+ export declare function vary(reply: FastifyReply): void;
98
119
  export type NormalizedInertiaOptions = Readonly<Required<InertiaOptions>>;
99
120
  /** Validates and fills defaults; the result is accepted again unchanged. */
100
121
  export declare function normalizeInertiaOptions(options?: InertiaOptions): NormalizedInertiaOptions;
@@ -109,6 +130,13 @@ export declare class InertiaResponder implements InertiaApi {
109
130
  constructor(options?: InertiaOptions, shared?: ((request: FastifyRequest) => Awaitable<Props>) | undefined, ssr?: InertiaSsr | undefined, diagnostics?: InertiaDiagnostics | undefined, flash?: InertiaFlash | undefined, hooks?: InertiaHooks);
110
131
  close(): void;
111
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>;
112
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>;
113
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>;
114
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', 'encryptHistory'].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',
@@ -39,17 +54,59 @@ export function normalizeInertiaOptions(options = {}) {
39
54
  scripts: Object.freeze([...(options.scripts ?? [])]),
40
55
  styles: Object.freeze([...(options.styles ?? [])]),
41
56
  encryptHistory: options.encryptHistory ?? 'sealed',
57
+ errorPage: options.errorPage ?? false,
42
58
  };
43
59
  if (typeof normalized.version !== 'string' ||
44
60
  typeof normalized.title !== 'string' ||
45
61
  typeof normalized.rootId !== 'string' ||
46
62
  !/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId) ||
47
- (typeof normalized.encryptHistory !== 'boolean' && normalized.encryptHistory !== 'sealed'))
48
- throw new TypeError('Invalid Inertia document settings');
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');
49
66
  for (const asset of [...normalized.scripts, ...normalized.styles])
50
67
  localUrl(asset);
51
68
  return Object.freeze(normalized);
52
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
+ }
53
110
  export class InertiaResponder {
54
111
  shared;
55
112
  ssr;
@@ -57,6 +114,11 @@ export class InertiaResponder {
57
114
  flash;
58
115
  hooks;
59
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';
60
122
  #closed = false;
61
123
  constructor(options = {}, shared, ssr, diagnostics, flash, hooks = {}) {
62
124
  this.shared = shared;
@@ -71,7 +133,7 @@ export class InertiaResponder {
71
133
  }
72
134
  #check() {
73
135
  if (this.#closed)
74
- throw new Error('Inertia responder is closed');
136
+ throw new InertiaError('CLOSED', 'Inertia responder is closed');
75
137
  }
76
138
  async render(request, reply, component, input = {}, history = {}) {
77
139
  this.#check();
@@ -79,13 +141,58 @@ export class InertiaResponder {
79
141
  typeof history !== 'object' ||
80
142
  (history.encryptHistory !== undefined && typeof history.encryptHistory !== 'boolean') ||
81
143
  (history.clearHistory !== undefined && typeof history.clearHistory !== 'boolean'))
82
- throw new TypeError('Inertia render options are { encryptHistory?, clearHistory? } booleans');
144
+ throw new InertiaUsageError('INVALID_RENDER', 'Inertia render options are { encryptHistory?, clearHistory? } booleans');
83
145
  if (!['GET', 'HEAD'].includes(request.method))
84
- throw new Error('Render pages using GET/HEAD; redirect mutations or use REST');
85
- if (typeof component !== 'string' ||
86
- component.length > 256 ||
87
- !component.split('/').every((part) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(part)))
88
- 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) {
89
196
  vary(reply);
90
197
  const inertia = request.headers['x-inertia'] === 'true';
91
198
  if (inertia &&
@@ -100,7 +207,7 @@ export class InertiaResponder {
100
207
  // A sealed request (seal host, D-0062): the props leave as one sealed frame, never as HTML.
101
208
  const sealer = this.hooks.sealOf?.(request);
102
209
  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' });
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.');
104
211
  const props = { ...(await this.shared?.(request)), ...input };
105
212
  const partial = inertia && request.headers['x-inertia-partial-component'] === component;
106
213
  const list = (key) => typeof request.headers[key] === 'string'
@@ -122,19 +229,47 @@ export class InertiaResponder {
122
229
  return isInertiaProp(value) && value.group !== undefined;
123
230
  });
124
231
  const values = await Promise.all(meta.selected.map(([key, value, prop]) => this.#evaluate(component, key, value, prop, meta.rescued)));
125
- // Read after the props: a failed render leaves the flash for the next page.
126
- const flash = this.flash && !deferredFetch ? await this.flash(request) : undefined;
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;
127
235
  // Read like the flash: once, by the page that shows it (logout or login happened before).
128
236
  const clearHistory = history.clearHistory ??
129
- (this.hooks.historyReset && !deferredFetch ? await this.hooks.historyReset(request) : false);
237
+ (this.hooks.historyReset && session && !deferredFetch
238
+ ? await this.hooks.historyReset(request)
239
+ : false);
130
240
  const configured = this.options.encryptHistory;
131
241
  const encryptHistory = history.encryptHistory ?? (configured === 'sealed' ? sealer !== undefined : configured);
132
- const resolved = Object.create(null);
133
- resolved.errors = {};
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: {} };
134
245
  meta.selected.forEach(([key], index) => {
135
- 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
136
256
  resolved[key] = values[index];
137
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
+ }
138
273
  const logger = this.diagnostics?.logger;
139
274
  if (logger?.isEnabled('debug') && (meta.lazy.length || meta.announced))
140
275
  logger.log('debug', 'Inertia props resolved', {
@@ -154,18 +289,20 @@ export class InertiaResponder {
154
289
  encryptHistory,
155
290
  ...meta.page,
156
291
  ...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
157
- ...(flash && Object.keys(flash).length ? { flash } : {}),
292
+ ...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
158
293
  };
159
- // Reject unsupported values rather than silently dropping application data.
160
- const json = JSON.stringify(page, (_key, value) => {
161
- if (value === undefined ||
162
- typeof value === 'function' ||
163
- typeof value === 'symbol' ||
164
- typeof value === 'bigint' ||
165
- (typeof value === 'number' && !Number.isFinite(value)))
166
- throw new TypeError('Page props must be JSON serializable');
167
- return value;
168
- });
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);
169
306
  if (logger?.isEnabled('debug') && (sealer || clearHistory))
170
307
  logger.log('debug', 'Inertia page history and seal', {
171
308
  component,
@@ -223,7 +360,7 @@ export class InertiaResponder {
223
360
  code: 'INERTIA_PROP_RESCUED',
224
361
  component,
225
362
  prop: key,
226
- error: errorText(error),
363
+ error: diagnostics.describeError(error),
227
364
  ...(repeated ? { repeated } : {}),
228
365
  });
229
366
  return RESCUED;
@@ -241,10 +378,10 @@ export class InertiaResponder {
241
378
  if (!Array.isArray(head) ||
242
379
  head.some((tag) => typeof tag !== 'string') ||
243
380
  typeof body !== 'string')
244
- 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 }');
245
382
  // The client mounts on the root id; a bundle built for another id would never hydrate.
246
383
  if (!body.includes(`id="${rootId}"`))
247
- 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)`);
248
385
  ssr.rendered?.();
249
386
  return { head, body };
250
387
  }
@@ -268,7 +405,7 @@ export class InertiaResponder {
268
405
  if (url.startsWith('/'))
269
406
  localUrl(url);
270
407
  else if (!/^https?:$/.test(new URL(url).protocol) || /[\r\n]/.test(url))
271
- throw new TypeError('Invalid location');
408
+ throw new InertiaUsageError('INVALID_URL', 'Invalid location');
272
409
  vary(reply);
273
410
  return request.headers['x-inertia'] === 'true'
274
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>;