@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/AI.md +146 -83
- package/README.md +55 -38
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +43 -0
- package/dist/index.d.ts +20 -1
- package/dist/index.js +236 -71
- package/dist/options.d.ts +22 -0
- package/dist/options.js +84 -0
- package/dist/props.js +6 -5
- package/dist/responder.d.ts +59 -5
- package/dist/responder.js +209 -38
- 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 +54 -15
- package/package.json +66 -11
- package/dist/repeats.d.ts +0 -18
- package/dist/repeats.js +0 -52
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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). */
|
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
|
};
|
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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',
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
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]
|
|
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
|
|
136
|
-
encryptHistory
|
|
288
|
+
clearHistory,
|
|
289
|
+
encryptHistory,
|
|
137
290
|
...meta.page,
|
|
138
291
|
...(meta.rescued.length ? { rescuedProps: meta.rescued } : {}),
|
|
139
|
-
...(
|
|
292
|
+
...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
|
|
140
293
|
};
|
|
141
|
-
// Reject unsupported values rather than silently dropping application data.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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()
|
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>;
|