@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/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type Awaitable } from '@dokebi/core';
|
|
2
2
|
import type { FastifyRequest } from 'fastify';
|
|
3
3
|
import { type InertiaApi, type Props } from './responder.js';
|
|
4
|
+
export { InertiaError, type InertiaErrorCode, InertiaUsageError, type InertiaUsageErrorCode, } from './errors.js';
|
|
5
|
+
export { type InertiaPluginOptions, options } from './options.js';
|
|
4
6
|
export { type DeferOptions, deepMerge, defer, InertiaProp, isInertiaProp, type MergeOptions, merge, type OnceOptions, once, type PropValue, } from './props.js';
|
|
5
|
-
export type { InertiaApi, InertiaFlash, InertiaOptions, InertiaPage, InertiaSsrResult, Json, Props, } from './responder.js';
|
|
7
|
+
export type { InertiaApi, InertiaErrorResponse, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
|
|
6
8
|
export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
|
|
7
9
|
export interface InertiaEntry {
|
|
8
10
|
readonly shared?: InertiaShared;
|
|
@@ -46,6 +48,23 @@ export interface InertiaScopeOptions {
|
|
|
46
48
|
readonly ssr?: InertiaSsrOptions | false;
|
|
47
49
|
/** Props merged after the plugin's shared props for every page of this scope. */
|
|
48
50
|
readonly shared?: InertiaShared;
|
|
51
|
+
/** History encryption of this scope's pages (default `'sealed'`: on for sealed requests). */
|
|
52
|
+
readonly encryptHistory?: boolean | 'sealed';
|
|
53
|
+
/**
|
|
54
|
+
* The error page component of this scope (D-0074); unset uses the plugin's `errorPage`,
|
|
55
|
+
* `false` turns it off for this scope.
|
|
56
|
+
*/
|
|
57
|
+
readonly errorPage?: string | false;
|
|
58
|
+
/**
|
|
59
|
+
* This scope answers seal hosts (dokebi-app sets it, D-0062). Server rendering would write the
|
|
60
|
+
* props as plain HTML, so `sealed` with SSR stops the start (`SEAL_SSR_CONFLICT`).
|
|
61
|
+
*/
|
|
62
|
+
readonly sealed?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** The `inertia` engine contributed to `dokebi-web:view-engine` (web `WebViewEngine` shape). */
|
|
65
|
+
export interface InertiaViewEngine {
|
|
66
|
+
readonly name: 'inertia';
|
|
67
|
+
scope(input: InertiaScopeOptions): Promise<InertiaApi>;
|
|
49
68
|
}
|
|
50
69
|
export interface InertiaPluginApi extends InertiaApi {
|
|
51
70
|
/**
|
package/dist/index.js
CHANGED
|
@@ -4,15 +4,22 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
4
4
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { types } from 'node:util';
|
|
7
|
-
import { definePlugin, optionalDependency,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { definePlugin, optionalDependency, } from '@dokebi/core';
|
|
8
|
+
import { diagnostics } from '@dokebi/core/libraries';
|
|
9
|
+
import { dependency as web } from '@dokebi/web/dependency';
|
|
10
|
+
import { viewEngines } from '@dokebi/web/points';
|
|
11
|
+
import { InertiaError, InertiaUsageError } from './errors.js';
|
|
12
|
+
import { options } from './options.js';
|
|
13
|
+
import { InertiaResponder, normalizeInertiaOptions, vary, } from './responder.js';
|
|
14
|
+
import { openViteDev, viteDevRequested } from './vite-dev.js';
|
|
15
|
+
export { InertiaError, InertiaUsageError, } from './errors.js';
|
|
16
|
+
export { options } from './options.js';
|
|
11
17
|
export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
|
|
12
18
|
const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
const { describeError, Repeats } = diagnostics;
|
|
13
20
|
function parseVite(input) {
|
|
14
21
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
15
|
-
throw new
|
|
22
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
16
23
|
const { manifest, entry, base = '/', ...rest } = input;
|
|
17
24
|
if (Object.keys(rest).length ||
|
|
18
25
|
typeof manifest !== 'string' ||
|
|
@@ -21,21 +28,21 @@ function parseVite(input) {
|
|
|
21
28
|
!entry ||
|
|
22
29
|
typeof base !== 'string' ||
|
|
23
30
|
!/^\/(?:[^/\\\s]+\/)*$/.test(base))
|
|
24
|
-
throw new
|
|
31
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
25
32
|
return Object.freeze({ manifest, entry, base });
|
|
26
33
|
}
|
|
27
34
|
function parseSsr(input) {
|
|
28
35
|
if (input === undefined || input === false)
|
|
29
36
|
return undefined;
|
|
30
37
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
31
|
-
throw new
|
|
38
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
32
39
|
const { bundle, failure = 'fallback', entry, ...rest } = input;
|
|
33
40
|
if (Object.keys(rest).length ||
|
|
34
41
|
typeof bundle !== 'string' ||
|
|
35
42
|
!bundle ||
|
|
36
43
|
(failure !== 'fallback' && failure !== 'error') ||
|
|
37
44
|
(entry !== undefined && (typeof entry !== 'string' || !entry || entry.includes('..'))))
|
|
38
|
-
throw new
|
|
45
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
39
46
|
return Object.freeze({ bundle, failure, ...(entry ? { entry: entry } : {}) });
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
@@ -47,13 +54,13 @@ async function loadBundle(bundle) {
|
|
|
47
54
|
const info = await stat(path).catch((cause) => {
|
|
48
55
|
if (cause.code !== 'ENOENT')
|
|
49
56
|
throw cause;
|
|
50
|
-
throw new
|
|
57
|
+
throw new InertiaError('SSR_BUNDLE_MISSING', `Inertia SSR bundle not found: ${bundle}. Build the views first (npm run build).`, { cause });
|
|
51
58
|
});
|
|
52
59
|
if (!info.isFile())
|
|
53
|
-
throw new
|
|
60
|
+
throw new InertiaError('SSR_BUNDLE_INVALID', `Inertia SSR bundle is not a file: ${bundle}`);
|
|
54
61
|
const module = (await import(pathToFileURL(path).href));
|
|
55
62
|
if (typeof module.render !== 'function')
|
|
56
|
-
throw new
|
|
63
|
+
throw new InertiaError('SSR_BUNDLE_INVALID', `Inertia SSR bundle ${bundle} does not export render(page)`);
|
|
57
64
|
return module.render;
|
|
58
65
|
}
|
|
59
66
|
const CONTENT_TYPES = {
|
|
@@ -84,17 +91,17 @@ async function viteAssets(vite) {
|
|
|
84
91
|
const info = await stat(path).catch((cause) => {
|
|
85
92
|
if (cause.code !== 'ENOENT')
|
|
86
93
|
throw cause;
|
|
87
|
-
throw new
|
|
94
|
+
throw new InertiaError('VITE_MANIFEST_MISSING', `Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
|
|
88
95
|
});
|
|
89
96
|
if (!info.isFile() || info.size > MANIFEST_MAX_BYTES)
|
|
90
|
-
throw new
|
|
97
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', 'Invalid Vite manifest file');
|
|
91
98
|
// Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
|
|
92
99
|
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
93
100
|
const text = await readFile(path, 'utf8');
|
|
94
101
|
const manifest = JSON.parse(text);
|
|
95
102
|
const entry = Object.hasOwn(manifest, vite.entry) ? manifest[vite.entry] : undefined;
|
|
96
103
|
if (entry?.isEntry !== true || typeof entry.file !== 'string')
|
|
97
|
-
throw new
|
|
104
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', 'Vite manifest has no entry ' + JSON.stringify(vite.entry));
|
|
98
105
|
const styles = [];
|
|
99
106
|
const files = new Map();
|
|
100
107
|
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
@@ -134,36 +141,92 @@ async function viteAssets(vite) {
|
|
|
134
141
|
files,
|
|
135
142
|
};
|
|
136
143
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Carries a history reset (login, logout) to the next page when the request that did it shows
|
|
146
|
+
* no page itself (a POST that redirects): `clearHistory` then goes out with that next page.
|
|
147
|
+
*/
|
|
148
|
+
const HISTORY_RESET_COOKIE = 'dokebi.history-reset';
|
|
149
|
+
const HISTORY_RESET_SECONDS = 600;
|
|
150
|
+
function hasCookie(header, name) {
|
|
151
|
+
const text = Array.isArray(header) ? header.join('; ') : (header ?? '');
|
|
152
|
+
if (text.length > 16_384)
|
|
153
|
+
return false;
|
|
154
|
+
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Where a validation redirect goes (D-0074 §3): the `Referer` when it is this host (another
|
|
158
|
+
* origin is never followed); otherwise `/` for a POST, PUT, PATCH or DELETE. A GET whose referer
|
|
159
|
+
* is missing or is itself has no way back (a redirect to itself would fail again, forever).
|
|
160
|
+
*/
|
|
161
|
+
function backTarget(request, host) {
|
|
162
|
+
const referer = request.headers.referer;
|
|
163
|
+
if (typeof referer === 'string' && referer.length <= 4096) {
|
|
164
|
+
let url;
|
|
165
|
+
if (URL.canParse(referer))
|
|
166
|
+
url = new URL(referer);
|
|
167
|
+
if (url &&
|
|
168
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
169
|
+
url.host.toLowerCase() === host.toLowerCase()) {
|
|
170
|
+
const target = url.pathname + url.search;
|
|
171
|
+
if (!(['GET', 'HEAD'].includes(request.method) && target === request.url))
|
|
172
|
+
return target;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return ['GET', 'HEAD'].includes(request.method) ? undefined : '/';
|
|
176
|
+
}
|
|
177
|
+
/** Field names whose values never go into the session as old input. */
|
|
178
|
+
const SECRET_FIELD = /pass(?:word|wd|phrase)?|secret|token|api[_-]?key|card|cvc|cvv|otp|pin$/i;
|
|
179
|
+
/** Old input kept at most (as JSON text): a larger body is not flashed. */
|
|
180
|
+
const OLD_MAX = 8192;
|
|
181
|
+
/**
|
|
182
|
+
* The old input of a refused form (D-0074 §3): the body's top-level strings, numbers, booleans
|
|
183
|
+
* and nulls, without password-like fields; undefined when nothing is left or it is too large.
|
|
184
|
+
*/
|
|
185
|
+
function oldInput(body) {
|
|
186
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
187
|
+
return undefined;
|
|
188
|
+
const old = {};
|
|
189
|
+
let kept = 0;
|
|
190
|
+
for (const [key, value] of Object.entries(body)) {
|
|
191
|
+
if (key === '__proto__' || SECRET_FIELD.test(key))
|
|
192
|
+
continue;
|
|
193
|
+
if (value === null ||
|
|
194
|
+
typeof value === 'boolean' ||
|
|
195
|
+
(typeof value === 'number' && Number.isFinite(value)) ||
|
|
196
|
+
typeof value === 'string') {
|
|
197
|
+
old[key] = value;
|
|
198
|
+
kept++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!kept || JSON.stringify(old).length > OLD_MAX)
|
|
202
|
+
return undefined;
|
|
203
|
+
return old;
|
|
204
|
+
}
|
|
144
205
|
const session = optionalDependency({
|
|
145
206
|
name: 'dokebi-session',
|
|
146
|
-
version: '^0.
|
|
207
|
+
version: '^0.4.0',
|
|
147
208
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
...(ssr ? { ssr } : {}),
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
// The manifest decides scripts, styles and version; setting them too would be ambiguous.
|
|
160
|
-
if (['version', 'scripts', 'styles'].some((key) => rest[key] !== undefined))
|
|
161
|
-
throw new TypeError('Inertia vite options replace version, scripts and styles');
|
|
209
|
+
/** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
|
|
210
|
+
function sealSsrConflict() {
|
|
211
|
+
return new InertiaError('SEAL_SSR_CONFLICT', 'An Inertia scope of seal hosts cannot use SSR: server rendering writes the props as plain HTML. Set ssr: false for this app or drop seal from its hosts.');
|
|
212
|
+
}
|
|
213
|
+
/** Settings of the parsed options (the declaration checked them, D-0063 §3). */
|
|
214
|
+
function settings(parsed) {
|
|
215
|
+
const { vite, ssr, ...rest } = parsed;
|
|
216
|
+
const document = normalizeInertiaOptions(rest);
|
|
162
217
|
return Object.freeze({
|
|
163
|
-
document
|
|
164
|
-
defaultVersion:
|
|
165
|
-
vite:
|
|
166
|
-
...(ssr
|
|
218
|
+
document,
|
|
219
|
+
defaultVersion: vite === undefined && rest.version === undefined,
|
|
220
|
+
...(vite ? { vite: Object.freeze({ ...vite }) } : {}),
|
|
221
|
+
...(ssr
|
|
222
|
+
? {
|
|
223
|
+
ssr: Object.freeze({
|
|
224
|
+
bundle: ssr.bundle,
|
|
225
|
+
failure: ssr.failure,
|
|
226
|
+
...(ssr.entry ? { entry: ssr.entry } : {}),
|
|
227
|
+
}),
|
|
228
|
+
}
|
|
229
|
+
: {}),
|
|
167
230
|
});
|
|
168
231
|
}
|
|
169
232
|
/** Accepts a plain object or the namespace of the user entry module (src/inertia/shared.ts). */
|
|
@@ -175,7 +238,7 @@ function parseEntry(input) {
|
|
|
175
238
|
Array.isArray(input) ||
|
|
176
239
|
types.isProxy(input) ||
|
|
177
240
|
![Object.prototype, null].includes(Object.getPrototypeOf(input)))
|
|
178
|
-
throw new
|
|
241
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
|
|
179
242
|
let shared;
|
|
180
243
|
for (const key of Reflect.ownKeys(input)) {
|
|
181
244
|
// Module namespaces carry Symbol.toStringTag; only string exports are entry fields.
|
|
@@ -183,11 +246,11 @@ function parseEntry(input) {
|
|
|
183
246
|
continue;
|
|
184
247
|
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
185
248
|
if (key !== 'shared' || !descriptor || !('value' in descriptor))
|
|
186
|
-
throw new
|
|
249
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
|
|
187
250
|
shared = descriptor.value;
|
|
188
251
|
}
|
|
189
252
|
if (shared !== undefined && typeof shared !== 'function')
|
|
190
|
-
throw new
|
|
253
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Inertia shared must be a function');
|
|
191
254
|
return shared === undefined ? {} : { shared: shared };
|
|
192
255
|
}
|
|
193
256
|
class InertiaRuntime {
|
|
@@ -208,7 +271,78 @@ class InertiaRuntime {
|
|
|
208
271
|
const flash = sessions
|
|
209
272
|
? async (request) => (await sessions.of(request).takeFlash())
|
|
210
273
|
: undefined;
|
|
211
|
-
//
|
|
274
|
+
// D-0062: sealed requests (dokebi-web decides) and the session's history reset (logout, login).
|
|
275
|
+
// A page shown in the request that reset uses it at once; otherwise the reset cookie carries
|
|
276
|
+
// it to the next page, which also clears that cookie.
|
|
277
|
+
const resets = new WeakMap();
|
|
278
|
+
const sessionOf = (request) =>
|
|
279
|
+
// Static paths excluded by the session plugin have none (`request.session` is null).
|
|
280
|
+
request.session ? sessions?.of(request) : undefined;
|
|
281
|
+
const hooks = Object.freeze({
|
|
282
|
+
sealOf: (request) => server.sealOf(request),
|
|
283
|
+
...(sessions
|
|
284
|
+
? {
|
|
285
|
+
historyReset: async (request) => {
|
|
286
|
+
const now = sessionOf(request)?.historyReset?.() === true;
|
|
287
|
+
const carried = hasCookie(request.headers.cookie, HISTORY_RESET_COOKIE);
|
|
288
|
+
if (now || carried)
|
|
289
|
+
resets.set(request, { served: now, read: carried });
|
|
290
|
+
return now || carried;
|
|
291
|
+
},
|
|
292
|
+
// D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
|
|
293
|
+
validation: async (request, reply, errors) => {
|
|
294
|
+
const current = sessionOf(request);
|
|
295
|
+
const target = backTarget(request, server.requestHost(request));
|
|
296
|
+
if (!current?.flash || target === undefined) {
|
|
297
|
+
if (logger.isEnabled('debug'))
|
|
298
|
+
logger.log('debug', 'Inertia validation answered as JSON', {
|
|
299
|
+
reason: current?.flash ? 'no-way-back' : 'no-session',
|
|
300
|
+
method: request.method,
|
|
301
|
+
route: request.routeOptions.url ?? 'unmatched',
|
|
302
|
+
});
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
await current.flash('errors', { ...errors });
|
|
306
|
+
const old = oldInput(request.body);
|
|
307
|
+
if (old)
|
|
308
|
+
await current.flash('old', old);
|
|
309
|
+
if (logger.isEnabled('debug'))
|
|
310
|
+
logger.log('debug', 'Inertia validation redirect', {
|
|
311
|
+
method: request.method,
|
|
312
|
+
route: request.routeOptions.url ?? 'unmatched',
|
|
313
|
+
fields: Object.keys(errors).slice(0, 32),
|
|
314
|
+
old: old ? Object.keys(old).length : 0,
|
|
315
|
+
target: target.split('?', 1)[0]?.slice(0, 200),
|
|
316
|
+
});
|
|
317
|
+
vary(reply);
|
|
318
|
+
reply.redirect(target, 303);
|
|
319
|
+
return true;
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
: {}),
|
|
323
|
+
});
|
|
324
|
+
if (sessions) {
|
|
325
|
+
const carry = async (scope) => {
|
|
326
|
+
scope.addHook('onSend', async (request, reply, payload) => {
|
|
327
|
+
const state = resets.get(request);
|
|
328
|
+
const reset = sessionOf(request)?.historyReset?.() === true;
|
|
329
|
+
if (!(reset && !state?.served) && !state?.read)
|
|
330
|
+
return payload;
|
|
331
|
+
const set = reset && !state?.served;
|
|
332
|
+
reply.header('set-cookie', `${HISTORY_RESET_COOKIE}=${set ? '1' : ''}; Path=/; Max-Age=${set ? HISTORY_RESET_SECONDS : 0}; HttpOnly; SameSite=Lax${request.protocol === 'https' ? '; Secure' : ''}`);
|
|
333
|
+
if (logger.isEnabled('debug'))
|
|
334
|
+
logger.log('debug', 'Inertia history reset cookie', {
|
|
335
|
+
operation: set ? 'set' : 'clear',
|
|
336
|
+
});
|
|
337
|
+
return payload;
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
// Without encapsulation (skip-override), so the hook sees every route.
|
|
341
|
+
carry[Symbol.for('skip-override')] = true;
|
|
342
|
+
server.register(carry, {});
|
|
343
|
+
}
|
|
344
|
+
// Development (D-0045): one Vite for every scope, reached by the first `vite` (the host of
|
|
345
|
+
// `dokebi dev`, shared by every worker, or one started in this process).
|
|
212
346
|
const develop = viteDevRequested();
|
|
213
347
|
logger.log('debug', 'Inertia settings resolved', {
|
|
214
348
|
develop,
|
|
@@ -217,6 +351,10 @@ class InertiaRuntime {
|
|
|
217
351
|
defaultVersion: this.settings.defaultVersion,
|
|
218
352
|
shared: this.shared !== undefined,
|
|
219
353
|
flash: flash !== undefined,
|
|
354
|
+
historyReset: hooks.historyReset !== undefined,
|
|
355
|
+
encryptHistory: this.settings.document.encryptHistory,
|
|
356
|
+
// D-0068: every page value is checked for JSON outside production, top-level props in it.
|
|
357
|
+
propsCheck: process.env.NODE_ENV === 'production' ? 'top-level' : 'deep',
|
|
220
358
|
});
|
|
221
359
|
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
222
360
|
const repeats = new Repeats();
|
|
@@ -247,12 +385,13 @@ class InertiaRuntime {
|
|
|
247
385
|
if (!devServer) {
|
|
248
386
|
const begin = performance.now();
|
|
249
387
|
// A start failure reaches setup or the request that awaits the server.
|
|
250
|
-
devServer =
|
|
251
|
-
logger.log('debug', 'Vite dev server
|
|
388
|
+
devServer = openViteDev(logger).then((opened) => {
|
|
389
|
+
logger.log('debug', 'Vite dev server ready', {
|
|
252
390
|
ms: Math.round(performance.now() - begin),
|
|
391
|
+
shared: process.env.DOKEBI_VITE_DEV_URL !== undefined,
|
|
253
392
|
hosts: [...pageHosts].slice(0, 64),
|
|
254
393
|
});
|
|
255
|
-
return
|
|
394
|
+
return opened;
|
|
256
395
|
});
|
|
257
396
|
context.logger.log('info', 'Inertia views are served by the Vite dev server', {
|
|
258
397
|
code: 'INERTIA_VITE_DEV',
|
|
@@ -280,16 +419,17 @@ class InertiaRuntime {
|
|
|
280
419
|
}
|
|
281
420
|
const vite = await current;
|
|
282
421
|
reply.hijack();
|
|
283
|
-
|
|
422
|
+
// Status, headers and body as the Vite host sent them (504 included).
|
|
423
|
+
vite.forward(request.raw, reply.raw, (failure) => {
|
|
284
424
|
const repeated = repeats.take(`vite ${failure instanceof Error ? failure.name : typeof failure}`);
|
|
285
425
|
if (repeated !== undefined)
|
|
286
|
-
logger.log('error', 'Vite dev
|
|
426
|
+
logger.log('error', 'Vite dev request failed', {
|
|
287
427
|
code: 'INERTIA_VITE_DEV_FAILED',
|
|
288
428
|
base,
|
|
289
|
-
error:
|
|
429
|
+
error: describeError(failure),
|
|
290
430
|
...(repeated ? { repeated } : {}),
|
|
291
431
|
});
|
|
292
|
-
})
|
|
432
|
+
});
|
|
293
433
|
},
|
|
294
434
|
});
|
|
295
435
|
logger.log('debug', 'Vite dev route registered', { url: `${base}*` });
|
|
@@ -350,19 +490,15 @@ class InertiaRuntime {
|
|
|
350
490
|
const fallback = vite ? `${dirname(vite.entry)}/ssr.js` : undefined;
|
|
351
491
|
const source = ssr.entry ?? (fallback && existsSync(resolve(fallback)) ? fallback : undefined);
|
|
352
492
|
if (!source || !vite)
|
|
353
|
-
throw new
|
|
493
|
+
throw new InertiaError('SSR_ENTRY_MISSING', `Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
|
|
354
494
|
const dev = viteDev(vite.base);
|
|
355
495
|
logger.log('debug', 'Inertia SSR renders the source through Vite', {
|
|
356
496
|
source,
|
|
357
497
|
bundle: ssr.bundle,
|
|
358
498
|
entrySet: ssr.entry !== undefined,
|
|
359
499
|
});
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (typeof module.render !== 'function')
|
|
363
|
-
throw new Error(`Inertia SSR entry ${source} does not export render(page)`);
|
|
364
|
-
return module.render(page);
|
|
365
|
-
});
|
|
500
|
+
// The Vite host loads the source and renders (one Vite for every worker).
|
|
501
|
+
render = Promise.resolve(async (page) => (await (await dev).render(source, page)));
|
|
366
502
|
}
|
|
367
503
|
else {
|
|
368
504
|
const path = resolve(ssr.bundle);
|
|
@@ -395,7 +531,7 @@ class InertiaRuntime {
|
|
|
395
531
|
bundle: ssr.bundle,
|
|
396
532
|
component,
|
|
397
533
|
fallback: ssr.failure === 'fallback',
|
|
398
|
-
error:
|
|
534
|
+
error: describeError(error),
|
|
399
535
|
...(repeated ? { repeated } : {}),
|
|
400
536
|
});
|
|
401
537
|
},
|
|
@@ -411,29 +547,48 @@ class InertiaRuntime {
|
|
|
411
547
|
const render = responder.render.bind(responder);
|
|
412
548
|
return Object.freeze({
|
|
413
549
|
render: develop
|
|
414
|
-
? (request, reply, component, props) => {
|
|
550
|
+
? (request, reply, component, props, options) => {
|
|
415
551
|
pageHosts.add(hostName(request));
|
|
416
|
-
return render(request, reply, component, props);
|
|
552
|
+
return render(request, reply, component, props, options);
|
|
417
553
|
}
|
|
418
554
|
: render,
|
|
419
555
|
redirect: responder.redirect.bind(responder),
|
|
420
556
|
location: responder.location.bind(responder),
|
|
557
|
+
answerError: responder.answerError.bind(responder),
|
|
421
558
|
});
|
|
422
559
|
};
|
|
423
560
|
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
424
561
|
const diagnostics = Object.freeze({ logger, repeats });
|
|
425
|
-
const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash));
|
|
562
|
+
const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
|
|
563
|
+
// D-0074 §3: Inertia requests refused by validation go back with `errors` (with a session);
|
|
564
|
+
// the plugin's `errorPage` answers the rest. dokebi-app adds its own responder later, which
|
|
565
|
+
// dokebi-web asks first, so an app's requests use the app's scope.
|
|
566
|
+
server.errorResponder((response) => root.answerError(response));
|
|
567
|
+
logger.log('debug', 'Inertia error answers', {
|
|
568
|
+
validationRedirect: hooks.validation !== undefined,
|
|
569
|
+
errorPage: document.errorPage,
|
|
570
|
+
});
|
|
426
571
|
const shared = this.shared;
|
|
427
|
-
|
|
572
|
+
const plugin = Object.freeze({
|
|
428
573
|
...root,
|
|
429
574
|
ssrStats: () => Object.freeze({ ...stats }),
|
|
430
575
|
scope: async (options) => {
|
|
431
|
-
const { shared: extra, vite, ssr: ssrInput, ...fields } = (options ?? {});
|
|
576
|
+
const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
|
|
577
|
+
if (typeof sealed !== 'boolean')
|
|
578
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
|
|
432
579
|
const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
|
|
580
|
+
if (sealed && ssr) {
|
|
581
|
+
logger.log('error', 'Inertia scope of seal hosts with SSR', {
|
|
582
|
+
code: 'SEAL_SSR_CONFLICT',
|
|
583
|
+
title: typeof fields.title === 'string' ? fields.title.slice(0, 100) : null,
|
|
584
|
+
fix: 'Set inertia.ssr: false for this app, or remove seal: true from its domains',
|
|
585
|
+
});
|
|
586
|
+
throw sealSsrConflict();
|
|
587
|
+
}
|
|
433
588
|
if (extra !== undefined && typeof extra !== 'function')
|
|
434
|
-
throw new
|
|
589
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope shared must be a function');
|
|
435
590
|
if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
|
|
436
|
-
throw new
|
|
591
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia vite options replace version, scripts and styles');
|
|
437
592
|
const scoped = normalizeInertiaOptions({
|
|
438
593
|
...document,
|
|
439
594
|
...fields,
|
|
@@ -443,25 +598,35 @@ class InertiaRuntime {
|
|
|
443
598
|
? async (request) => ({ ...(await shared(request)), ...(await extra(request)) })
|
|
444
599
|
: (extra ?? shared);
|
|
445
600
|
const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
|
|
446
|
-
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash);
|
|
601
|
+
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash, hooks);
|
|
447
602
|
logger.log('debug', 'Inertia scope created', {
|
|
448
603
|
title: scoped.title,
|
|
449
604
|
rootId: scoped.rootId,
|
|
450
605
|
vite: scopedVite ? scopedVite.entry : false,
|
|
451
606
|
ssr: ssr ? ssr.bundle : false,
|
|
452
607
|
shared: merged !== undefined,
|
|
608
|
+
sealed,
|
|
609
|
+
encryptHistory: scoped.encryptHistory,
|
|
610
|
+
errorPage: scoped.errorPage,
|
|
453
611
|
});
|
|
454
612
|
return api(responder);
|
|
455
613
|
},
|
|
456
614
|
});
|
|
615
|
+
// D-0063 §5: the view engine `inertia` for dokebi-app (inert until a plugin collects it).
|
|
616
|
+
context.contribute(viewEngines, Object.freeze({
|
|
617
|
+
name: 'inertia',
|
|
618
|
+
scope: (input) => plugin.scope(input),
|
|
619
|
+
}));
|
|
620
|
+
return plugin;
|
|
457
621
|
}
|
|
458
622
|
}
|
|
459
623
|
export const createPlugin = definePlugin({
|
|
460
624
|
name: 'dokebi-template-inertia',
|
|
461
|
-
version: '0.
|
|
462
|
-
core: '^0.
|
|
463
|
-
options
|
|
625
|
+
version: '0.4.0',
|
|
626
|
+
core: '^0.4.0',
|
|
627
|
+
options,
|
|
464
628
|
entry: parseEntry,
|
|
629
|
+
contributes: [viewEngines],
|
|
465
630
|
dependencies: () => [web, session],
|
|
466
|
-
instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
|
|
631
|
+
instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
|
|
467
632
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type OptionsOf } from '@dokebi/core';
|
|
2
|
+
export declare const options: import("@dokebi/core").OptionsDeclaration<{
|
|
3
|
+
readonly encryptHistory: "sealed" | boolean;
|
|
4
|
+
readonly errorPage?: string;
|
|
5
|
+
readonly rootId?: string;
|
|
6
|
+
readonly scripts?: readonly string[];
|
|
7
|
+
readonly ssr?: {
|
|
8
|
+
readonly bundle: string;
|
|
9
|
+
readonly entry?: string;
|
|
10
|
+
readonly failure: "error" | "fallback";
|
|
11
|
+
};
|
|
12
|
+
readonly styles?: readonly string[];
|
|
13
|
+
readonly title?: string;
|
|
14
|
+
readonly version?: string;
|
|
15
|
+
readonly vite?: {
|
|
16
|
+
readonly base: string;
|
|
17
|
+
readonly entry: string;
|
|
18
|
+
readonly manifest: string;
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
/** The parsed options of dokebi-template-inertia (`options.dokebi-template-inertia`). */
|
|
22
|
+
export type InertiaPluginOptions = OptionsOf<typeof options>;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defineOptions, option as o } from '@dokebi/core';
|
|
2
|
+
/*
|
|
3
|
+
* Options of dokebi-template-inertia (D-0063 §3): one declaration is the parser and, at build
|
|
4
|
+
* time, options.schema.json. The rules are the hand parser's before wave B; the asset URL rule
|
|
5
|
+
* (no `//`, backslash, space or control character) is declared as a refinement.
|
|
6
|
+
*/
|
|
7
|
+
/** A local absolute URL: `/x`, not `//host`, without backslash, space or control characters. */
|
|
8
|
+
const localAsset = o.string({
|
|
9
|
+
pattern: '^/[^/]',
|
|
10
|
+
refine: [
|
|
11
|
+
{
|
|
12
|
+
test: (value) => ![...value].some((char) => char === '\\' || char.charCodeAt(0) <= 32),
|
|
13
|
+
message: 'must be a local absolute URL without backslash, space or control characters',
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
export const options = defineOptions({
|
|
18
|
+
version: o.string({
|
|
19
|
+
description: 'Asset version sent with every page; a client with another one reloads (409). Unset: development (and a warning).',
|
|
20
|
+
}),
|
|
21
|
+
rootId: o.string({
|
|
22
|
+
pattern: '^[A-Za-z][A-Za-z0-9_-]*$',
|
|
23
|
+
description: 'Id of the element the client app mounts on (default app).',
|
|
24
|
+
}),
|
|
25
|
+
title: o.string({ description: 'Document title of the first HTML.' }),
|
|
26
|
+
scripts: o.array(localAsset, { description: 'Module scripts of the first HTML.' }),
|
|
27
|
+
styles: o.array(localAsset, { description: 'Stylesheets of the first HTML.' }),
|
|
28
|
+
vite: o.object({
|
|
29
|
+
manifest: o.string({
|
|
30
|
+
minLength: 1,
|
|
31
|
+
required: true,
|
|
32
|
+
description: 'Path of .vite/manifest.json, relative to the working directory',
|
|
33
|
+
}),
|
|
34
|
+
entry: o.string({
|
|
35
|
+
minLength: 1,
|
|
36
|
+
required: true,
|
|
37
|
+
description: 'Manifest key of the entry, e.g. src/apps/<app>/views/app.js',
|
|
38
|
+
}),
|
|
39
|
+
base: o.string({
|
|
40
|
+
pattern: '^/(?:[^/\\\\\\s]+/)*$',
|
|
41
|
+
default: '/',
|
|
42
|
+
description: 'URL prefix of the built files (default /)',
|
|
43
|
+
}),
|
|
44
|
+
}, {
|
|
45
|
+
description: 'Read scripts, styles and version from a Vite build manifest at startup. Replaces version, scripts and styles.',
|
|
46
|
+
}),
|
|
47
|
+
ssr: o.object({
|
|
48
|
+
bundle: o.string({
|
|
49
|
+
minLength: 1,
|
|
50
|
+
required: true,
|
|
51
|
+
description: 'SSR build output that exports render(page), relative to the working directory, e.g. dist/ssr/<app>/ssr.js',
|
|
52
|
+
}),
|
|
53
|
+
failure: o.enum(['fallback', 'error'], {
|
|
54
|
+
default: 'fallback',
|
|
55
|
+
description: 'When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)',
|
|
56
|
+
}),
|
|
57
|
+
entry: o.string({
|
|
58
|
+
minLength: 1,
|
|
59
|
+
pattern: '^(?!.*\\.\\.)',
|
|
60
|
+
description: 'Development (D-0045): source module of the bundle rendered through Vite, e.g. src/apps/admin/views/ssr.js. Default: ssr.js beside vite.entry',
|
|
61
|
+
}),
|
|
62
|
+
}, {
|
|
63
|
+
description: 'Render the first HTML of each page on the server (D-0039) with the SSR build output, imported once at start. A missing bundle stops the start.',
|
|
64
|
+
}),
|
|
65
|
+
encryptHistory: o.union([o.boolean(), o.literal('sealed')], {
|
|
66
|
+
default: 'sealed',
|
|
67
|
+
description: 'Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts',
|
|
68
|
+
}),
|
|
69
|
+
errorPage: o.string({
|
|
70
|
+
pattern: '^[A-Za-z0-9][A-Za-z0-9_-]*(?:/[A-Za-z0-9][A-Za-z0-9_-]*)*$',
|
|
71
|
+
description: 'Page component (for example Error) rendered with { status, error, message, hint } for error answers of Inertia visits and full page loads (D-0074). Unset: JSON for visits, a small HTML page for page loads.',
|
|
72
|
+
}),
|
|
73
|
+
}, {
|
|
74
|
+
checks: [
|
|
75
|
+
{
|
|
76
|
+
path: 'vite',
|
|
77
|
+
message: 'replaces version, scripts and styles; set only one of them',
|
|
78
|
+
test: (value) => value.vite === undefined ||
|
|
79
|
+
(value.version === undefined &&
|
|
80
|
+
value.scripts === undefined &&
|
|
81
|
+
value.styles === undefined),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|