@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.
- package/AI.md +147 -91
- package/README.md +52 -42
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +43 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +123 -67
- package/dist/options.d.ts +22 -0
- package/dist/options.js +84 -0
- package/dist/props.js +6 -5
- package/dist/responder.d.ts +30 -2
- package/dist/responder.js +174 -37
- package/dist/vite-dev.d.ts +50 -12
- package/dist/vite-dev.js +460 -27
- package/dist/vite-host.d.ts +1 -0
- package/dist/vite-host.js +62 -0
- package/options.schema.json +44 -17
- package/package.json +66 -11
- package/dist/repeats.d.ts +0 -18
- package/dist/repeats.js +0 -52
package/dist/responder.d.ts
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
|
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) => ![
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
144
|
+
throw new InertiaUsageError('INVALID_RENDER', 'Inertia render options are { encryptHistory?, clearHistory? } booleans');
|
|
83
145
|
if (!['GET', 'HEAD'].includes(request.method))
|
|
84
|
-
throw new
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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]
|
|
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
|
-
...(
|
|
292
|
+
...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
|
|
158
293
|
};
|
|
159
|
-
// Reject unsupported values rather than silently dropping application data.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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()
|
package/dist/vite-dev.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
16
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
62
|
+
export declare function openViteDev(logger: Logger, environment?: NodeJS.ProcessEnv, root?: string): Promise<ViteDev>;
|