@dokebi/template-inertia 0.4.1 → 0.4.4
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 +138 -25
- package/README.md +23 -7
- package/dist/boot-fallback.d.ts +61 -0
- package/dist/boot-fallback.js +131 -0
- package/dist/built-files.d.ts +9 -0
- package/dist/built-files.js +44 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -1
- package/dist/i18n-store.d.ts +64 -0
- package/dist/i18n-store.js +368 -0
- package/dist/i18n.d.ts +216 -0
- package/dist/i18n.js +901 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +458 -61
- package/dist/options.d.ts +28 -0
- package/dist/options.js +97 -1
- package/dist/prune.d.ts +30 -0
- package/dist/prune.js +159 -0
- package/dist/responder.d.ts +14 -4
- package/dist/responder.js +166 -51
- package/dist/vite-deps.d.ts +76 -0
- package/dist/vite-deps.js +249 -0
- package/dist/vite-dev.d.ts +42 -4
- package/dist/vite-dev.js +162 -16
- package/dist/vite-files.d.ts +129 -0
- package/dist/vite-files.js +633 -0
- package/dist/vite-host.js +67 -10
- package/dist/vite-plugin.d.ts +69 -0
- package/dist/vite-plugin.js +173 -0
- package/dist/vite-watch.d.ts +44 -0
- package/dist/vite-watch.js +176 -0
- package/options.schema.json +127 -1
- package/package.json +38 -11
package/dist/index.js
CHANGED
|
@@ -1,22 +1,60 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
3
3
|
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
4
5
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
5
6
|
import { pathToFileURL } from 'node:url';
|
|
6
7
|
import { types } from 'node:util';
|
|
7
8
|
import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
|
|
8
9
|
import { diagnostics } from '@dokebi/core/libraries';
|
|
9
10
|
import { formBackTarget, formOldInput } from '@dokebi/web';
|
|
10
|
-
import { viewEngines } from '@dokebi/web/points';
|
|
11
|
+
import { collectViewData, pluginRouteIds, viewData, viewEngines, } from '@dokebi/web/points';
|
|
12
|
+
import { bootHashes } from './boot-fallback.js';
|
|
13
|
+
import { builtFolderFile, HASHED } from './built-files.js';
|
|
11
14
|
import { InertiaError, InertiaUsageError } from './errors.js';
|
|
12
15
|
import { options } from './options.js';
|
|
13
16
|
import { InertiaResponder, normalizeInertiaOptions, vary, } from './responder.js';
|
|
14
|
-
import { openViteDev, viteDevRequested } from './vite-dev.js';
|
|
17
|
+
import { isViteUpgrade, openViteDev, viteDevRequested } from './vite-dev.js';
|
|
18
|
+
import { DEV_CLIENT_HEADER, isLoopbackHost, isRemoteRequest } from './vite-files.js';
|
|
15
19
|
export { InertiaError, InertiaUsageError, } from './errors.js';
|
|
16
20
|
export { options } from './options.js';
|
|
17
21
|
export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
|
|
18
|
-
|
|
22
|
+
/** The SSR render deadline when `ssr.timeoutMs` is not set (D-0107 §4); the schema's default. */
|
|
23
|
+
const SSR_TIMEOUT_MS = 5000;
|
|
24
|
+
const SSR_TIMEOUT_MAX_MS = 600_000;
|
|
25
|
+
/** A scope's `app` (a log field): 1-64 printable ASCII characters, no control characters. */
|
|
26
|
+
const SCOPE_APP = /^[\x20-\x7e]{1,64}$/;
|
|
27
|
+
/**
|
|
28
|
+
* Route config of the built files and the Vite dev modules (D-0097 §1): a login page loads them
|
|
29
|
+
* before anyone is signed in, so they are public under `dokebi-auth.default: required` too. Read
|
|
30
|
+
* by @dokebi/auth only; without it the key is ignored. With the route id (D-0142). Served while
|
|
31
|
+
* a required health check is down or the app drains (`readiness: false`): the files need no
|
|
32
|
+
* database, and a page with `readiness: false` must open during an outage (community request A5).
|
|
33
|
+
*/
|
|
34
|
+
const publicAsset = (ids, key) => ids.config(key, { auth: false, readiness: false });
|
|
35
|
+
/**
|
|
36
|
+
* The bytes of a built file to keep, or null when it is larger than `maxBytes`
|
|
37
|
+
* (`assetCacheMaxBytes`; streamed per request).
|
|
38
|
+
*/
|
|
39
|
+
async function builtAsset(file, maxBytes) {
|
|
40
|
+
const info = await stat(file);
|
|
41
|
+
return info.size > maxBytes ? null : readFile(file);
|
|
42
|
+
}
|
|
19
43
|
const { describeError, Repeats } = diagnostics;
|
|
44
|
+
/**
|
|
45
|
+
* The bytes a client sent after its upgrade request, as @fastify/websocket kept them for a route
|
|
46
|
+
* (its private `Symbol('ws-head')`, 11.x); empty when there are none. A WebSocket client waits for
|
|
47
|
+
* the 101 before sending, so this is usually empty.
|
|
48
|
+
*/
|
|
49
|
+
function upgradeHead(request) {
|
|
50
|
+
for (const symbol of Object.getOwnPropertySymbols(request))
|
|
51
|
+
if (symbol.description === 'ws-head') {
|
|
52
|
+
const head = request[symbol];
|
|
53
|
+
if (Buffer.isBuffer(head))
|
|
54
|
+
return head;
|
|
55
|
+
}
|
|
56
|
+
return Buffer.alloc(0);
|
|
57
|
+
}
|
|
20
58
|
function parseVite(input) {
|
|
21
59
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
22
60
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
@@ -36,14 +74,90 @@ function parseSsr(input) {
|
|
|
36
74
|
return undefined;
|
|
37
75
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
38
76
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
39
|
-
const { bundle, failure = 'fallback', entry, ...rest } = input;
|
|
77
|
+
const { bundle, failure = 'fallback', entry, timeoutMs = SSR_TIMEOUT_MS, ...rest } = input;
|
|
40
78
|
if (Object.keys(rest).length ||
|
|
41
79
|
typeof bundle !== 'string' ||
|
|
42
80
|
!bundle ||
|
|
43
81
|
(failure !== 'fallback' && failure !== 'error') ||
|
|
44
|
-
(entry !== undefined && (typeof entry !== 'string' || !entry || entry.includes('..')))
|
|
82
|
+
(entry !== undefined && (typeof entry !== 'string' || !entry || entry.includes('..'))) ||
|
|
83
|
+
!Number.isInteger(timeoutMs) ||
|
|
84
|
+
timeoutMs < 1 ||
|
|
85
|
+
timeoutMs > SSR_TIMEOUT_MAX_MS)
|
|
45
86
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
46
|
-
return Object.freeze({
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
bundle,
|
|
89
|
+
failure,
|
|
90
|
+
timeoutMs: timeoutMs,
|
|
91
|
+
...(entry ? { entry: entry } : {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One render under the deadline (D-0107 §4). A render that passes it fails with `SSR_TIMEOUT`;
|
|
96
|
+
* the request goes on (fallback or error, as `failure` says) and the late outcome is only logged.
|
|
97
|
+
* Only a render that waits (I/O, the Vite host) can be cut: synchronous work blocks the process.
|
|
98
|
+
*/
|
|
99
|
+
function renderWithin(render, page, timeoutMs, late) {
|
|
100
|
+
const began = performance.now();
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
let expired = false;
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
expired = true;
|
|
105
|
+
reject(new InertiaError('SSR_TIMEOUT', `Inertia SSR render of ${page.component} took longer than ${timeoutMs} ms (ssr.timeoutMs)`, { data: { component: page.component, timeoutMs } }));
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
Promise.resolve()
|
|
108
|
+
.then(() => render(page))
|
|
109
|
+
.then((result) => {
|
|
110
|
+
if (expired)
|
|
111
|
+
late('resolved', Math.round(performance.now() - began));
|
|
112
|
+
else {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(result);
|
|
115
|
+
}
|
|
116
|
+
}, (error) => {
|
|
117
|
+
if (expired)
|
|
118
|
+
late('rejected', Math.round(performance.now() - began), error);
|
|
119
|
+
else {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The Vite dev routes answer loopback names, IP addresses (a page of another site cannot get
|
|
128
|
+
* either as its own origin through DNS rebinding), the domains of @dokebi/app and `devHosts`
|
|
129
|
+
* (exact names; `.example.com` also allows its subdomains), like Vite's `allowedHosts`.
|
|
130
|
+
* Nothing is learned from requests, so the set never grows (D-0107 §5).
|
|
131
|
+
*/
|
|
132
|
+
class DevHosts {
|
|
133
|
+
#exact;
|
|
134
|
+
#suffixes;
|
|
135
|
+
constructor(appDomains, configured) {
|
|
136
|
+
const exact = new Set(appDomains.map((host) => host.toLowerCase().replace(/:\d+$/, '')));
|
|
137
|
+
const suffixes = [];
|
|
138
|
+
for (const host of configured) {
|
|
139
|
+
const name = host.toLowerCase();
|
|
140
|
+
if (name.startsWith('.')) {
|
|
141
|
+
suffixes.push(name);
|
|
142
|
+
exact.add(name.slice(1));
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
exact.add(name);
|
|
146
|
+
}
|
|
147
|
+
this.#exact = exact;
|
|
148
|
+
this.#suffixes = Object.freeze(suffixes);
|
|
149
|
+
}
|
|
150
|
+
/** Some of the names, for a debug log. */
|
|
151
|
+
sample() {
|
|
152
|
+
return [...this.#exact].slice(0, 64);
|
|
153
|
+
}
|
|
154
|
+
allows(host) {
|
|
155
|
+
return (host === 'localhost' ||
|
|
156
|
+
host.endsWith('.localhost') ||
|
|
157
|
+
isIP(host) !== 0 ||
|
|
158
|
+
this.#exact.has(host) ||
|
|
159
|
+
this.#suffixes.some((suffix) => host.endsWith(suffix)));
|
|
160
|
+
}
|
|
47
161
|
}
|
|
48
162
|
/**
|
|
49
163
|
* Imports the SSR bundle. A missing file or a bundle without `render` stops the start: SSR that
|
|
@@ -86,30 +200,45 @@ const CONTENT_TYPES = {
|
|
|
86
200
|
* Reads the manifest once and returns the document asset settings for the entry, plus the built
|
|
87
201
|
* files the entry can load (its chunks, their CSS and assets) so they can be served.
|
|
88
202
|
*/
|
|
89
|
-
async function viteAssets(vite) {
|
|
203
|
+
async function viteAssets(vite, maxBytes) {
|
|
90
204
|
const path = resolve(vite.manifest);
|
|
91
205
|
const info = await stat(path).catch((cause) => {
|
|
92
206
|
if (cause.code !== 'ENOENT')
|
|
93
207
|
throw cause;
|
|
94
208
|
throw new InertiaError('VITE_MANIFEST_MISSING', `Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
|
|
95
209
|
});
|
|
96
|
-
if (!info.isFile() || info.size >
|
|
97
|
-
throw new InertiaError('VITE_MANIFEST_INVALID',
|
|
210
|
+
if (!info.isFile() || info.size > maxBytes)
|
|
211
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', info.isFile()
|
|
212
|
+
? `Vite manifest is larger than viteManifestMaxBytes (${maxBytes})`
|
|
213
|
+
: 'Invalid Vite manifest file');
|
|
98
214
|
// Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
|
|
99
215
|
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
100
216
|
const text = await readFile(path, 'utf8');
|
|
101
|
-
|
|
217
|
+
let manifest;
|
|
218
|
+
try {
|
|
219
|
+
manifest = JSON.parse(text);
|
|
220
|
+
}
|
|
221
|
+
catch (cause) {
|
|
222
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${vite.manifest} is not JSON. Build the views again (npm run build).`, { cause });
|
|
223
|
+
}
|
|
224
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
225
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${vite.manifest} is not an object of chunks`);
|
|
102
226
|
const entry = Object.hasOwn(manifest, vite.entry) ? manifest[vite.entry] : undefined;
|
|
103
227
|
if (entry?.isEntry !== true || typeof entry.file !== 'string')
|
|
104
228
|
throw new InertiaError('VITE_MANIFEST_INVALID', 'Vite manifest has no entry ' + JSON.stringify(vite.entry));
|
|
105
229
|
const styles = [];
|
|
106
230
|
const files = new Map();
|
|
231
|
+
const folders = new Map();
|
|
107
232
|
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
108
233
|
const serve = (file) => {
|
|
109
234
|
// Manifest paths are relative to outDir; anything that leaves it is not served.
|
|
110
235
|
if (typeof file !== 'string' || file.split('/').some((part) => part === '..' || !part))
|
|
111
236
|
return;
|
|
112
237
|
files.set(vite.base + file, join(outDir, file));
|
|
238
|
+
// A file in outDir itself names no folder: outDir also holds .vite/ and ssr/, never served.
|
|
239
|
+
const folder = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : undefined;
|
|
240
|
+
if (folder && !folder.split('/').some((part) => part.startsWith('.')))
|
|
241
|
+
folders.set(`${vite.base}${folder}/`, join(outDir, folder));
|
|
113
242
|
};
|
|
114
243
|
const seen = new Set();
|
|
115
244
|
// Static imports decide the document CSS; dynamic imports are only served.
|
|
@@ -139,6 +268,7 @@ async function viteAssets(vite) {
|
|
|
139
268
|
styles,
|
|
140
269
|
version: createHash('sha256').update(text).digest('hex').slice(0, 32),
|
|
141
270
|
files,
|
|
271
|
+
folders,
|
|
142
272
|
};
|
|
143
273
|
}
|
|
144
274
|
/**
|
|
@@ -154,13 +284,14 @@ function hasCookie(header, name) {
|
|
|
154
284
|
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
155
285
|
}
|
|
156
286
|
/**
|
|
157
|
-
* dokebi-web 0.4.
|
|
158
|
-
* `formOldInput`, D-0074 addendum 1), shared with @dokebi/app.
|
|
287
|
+
* dokebi-web 0.4.3 or later: the validation redirect uses its form helpers (`formBackTarget`,
|
|
288
|
+
* `formOldInput`, D-0074 addendum 1), shared with @dokebi/app; 0.4.2 never answers a Referer
|
|
289
|
+
* path that reads as another host (ISSUE-0006 A02); 0.4.3 has the view data point (D-0076 §5). A local descriptor instead of
|
|
159
290
|
* '@dokebi/web/dependency' (whose range stays ^0.4.0 for the plugins that need nothing newer).
|
|
160
291
|
*/
|
|
161
292
|
const web = providerDependency({
|
|
162
293
|
name: 'dokebi-web',
|
|
163
|
-
version: '^0.4.
|
|
294
|
+
version: '^0.4.3',
|
|
164
295
|
packageName: '@dokebi/web',
|
|
165
296
|
load: () => import('@dokebi/web'),
|
|
166
297
|
});
|
|
@@ -172,19 +303,32 @@ const session = optionalDependency({
|
|
|
172
303
|
function sealSsrConflict() {
|
|
173
304
|
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.');
|
|
174
305
|
}
|
|
306
|
+
/** The boot text of a scope for its debug record: the CSP hashes of its inline style and script. */
|
|
307
|
+
function bootLog(options) {
|
|
308
|
+
const boot = options.bootFallback;
|
|
309
|
+
return boot.enabled
|
|
310
|
+
? { timeoutMs: boot.timeoutMs, cspHashes: bootHashes(options.rootId, boot.timeoutMs) }
|
|
311
|
+
: false;
|
|
312
|
+
}
|
|
175
313
|
/** Settings of the parsed options (the declaration checked them, D-0063 §3). */
|
|
176
314
|
function settings(parsed) {
|
|
177
|
-
const { vite, ssr,
|
|
315
|
+
const { vite, ssr, devHosts = [], viteManifestMaxBytes = 4_194_304, assetCacheMaxBytes = 4_194_304, logRepeatWindowMs = 10_000,
|
|
316
|
+
// Read by the Vite dev host of dokebi dev (vite-host.ts, D-0128), not by the app.
|
|
317
|
+
devDepsRepairIntervalMs: _devDepsRepairIntervalMs, routeIds, ...rest } = parsed;
|
|
178
318
|
const document = normalizeInertiaOptions(rest);
|
|
179
319
|
return Object.freeze({
|
|
180
320
|
document,
|
|
321
|
+
routeIds: pluginRouteIds('dokebi-template-inertia', ['assets', 'viteDev'], routeIds),
|
|
322
|
+
limits: Object.freeze({ viteManifestMaxBytes, assetCacheMaxBytes, logRepeatWindowMs }),
|
|
181
323
|
defaultVersion: vite === undefined && rest.version === undefined,
|
|
324
|
+
devHosts: Object.freeze([...devHosts]),
|
|
182
325
|
...(vite ? { vite: Object.freeze({ ...vite }) } : {}),
|
|
183
326
|
...(ssr
|
|
184
327
|
? {
|
|
185
328
|
ssr: Object.freeze({
|
|
186
329
|
bundle: ssr.bundle,
|
|
187
330
|
failure: ssr.failure,
|
|
331
|
+
timeoutMs: ssr.timeoutMs,
|
|
188
332
|
...(ssr.entry ? { entry: ssr.entry } : {}),
|
|
189
333
|
}),
|
|
190
334
|
}
|
|
@@ -223,23 +367,34 @@ class InertiaRuntime {
|
|
|
223
367
|
this.shared = shared;
|
|
224
368
|
}
|
|
225
369
|
async setup(context) {
|
|
226
|
-
if (this.settings.defaultVersion)
|
|
227
|
-
context.logger.log('warn', 'Inertia asset version is not configured', {
|
|
228
|
-
code: 'INERTIA_VERSION_DEFAULT',
|
|
229
|
-
});
|
|
230
370
|
const server = context.getDependency(web);
|
|
231
371
|
const logger = context.logger;
|
|
372
|
+
// D-0076 §5: plugin view data (dokebi-auth `auth`) under the entry's shared props.
|
|
373
|
+
const pluginData = collectViewData(context.collect(viewData), {
|
|
374
|
+
plugin: 'dokebi-template-inertia',
|
|
375
|
+
reserved: ['errors', 'flash'],
|
|
376
|
+
});
|
|
377
|
+
const entryShared = this.shared;
|
|
378
|
+
const shared = !pluginData.keys.length
|
|
379
|
+
? entryShared
|
|
380
|
+
: entryShared
|
|
381
|
+
? async (request) => ({
|
|
382
|
+
...(await pluginData.values(request)),
|
|
383
|
+
...(await entryShared(request)),
|
|
384
|
+
})
|
|
385
|
+
: async (request) => (await pluginData.values(request));
|
|
232
386
|
const sessions = context.getOptionalDependency(session);
|
|
387
|
+
const sessionOf = (request) =>
|
|
388
|
+
// Static paths excluded by the session plugin have none (`request.session` is null).
|
|
389
|
+
request.session ? sessions?.of(request) : undefined;
|
|
390
|
+
// A page on an excluded path has no flash (`of` would throw NOT_ATTACHED there).
|
|
233
391
|
const flash = sessions
|
|
234
|
-
? async (request) => (await
|
|
392
|
+
? async (request) => ((await sessionOf(request)?.takeFlash()) ?? {})
|
|
235
393
|
: undefined;
|
|
236
394
|
// D-0062: sealed requests (dokebi-web decides) and the session's history reset (logout, login).
|
|
237
395
|
// A page shown in the request that reset uses it at once; otherwise the reset cookie carries
|
|
238
396
|
// it to the next page, which also clears that cookie.
|
|
239
397
|
const resets = new WeakMap();
|
|
240
|
-
const sessionOf = (request) =>
|
|
241
|
-
// Static paths excluded by the session plugin have none (`request.session` is null).
|
|
242
|
-
request.session ? sessions?.of(request) : undefined;
|
|
243
398
|
const hooks = Object.freeze({
|
|
244
399
|
sealOf: (request) => server.sealOf(request),
|
|
245
400
|
...(sessions
|
|
@@ -254,7 +409,7 @@ class InertiaRuntime {
|
|
|
254
409
|
// D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
|
|
255
410
|
validation: async (request, reply, errors) => {
|
|
256
411
|
const current = sessionOf(request);
|
|
257
|
-
const target = formBackTarget(request, server.requestHost(request), '/');
|
|
412
|
+
const target = formBackTarget(request, server.requestHost(request), '/', server.formLimits);
|
|
258
413
|
if (!current?.flash || target === undefined) {
|
|
259
414
|
if (logger.isEnabled('debug'))
|
|
260
415
|
logger.log('debug', 'Inertia validation answered as JSON', {
|
|
@@ -265,7 +420,7 @@ class InertiaRuntime {
|
|
|
265
420
|
return false;
|
|
266
421
|
}
|
|
267
422
|
await current.flash('errors', { ...errors });
|
|
268
|
-
const old = formOldInput(request.body);
|
|
423
|
+
const old = formOldInput(request.body, server.formLimits);
|
|
269
424
|
if (old)
|
|
270
425
|
await current.flash('old', old);
|
|
271
426
|
if (logger.isEnabled('debug'))
|
|
@@ -306,12 +461,26 @@ class InertiaRuntime {
|
|
|
306
461
|
// Development (D-0045): one Vite for every scope, reached by the first `vite` (the host of
|
|
307
462
|
// `dokebi dev`, shared by every worker, or one started in this process).
|
|
308
463
|
const develop = viteDevRequested();
|
|
464
|
+
// D-0149: the HMR socket of the Vite dev host is on its own port of the page's host name;
|
|
465
|
+
// the default CSP (connect-src 'self') would block it. When the dev host restarts, Vite's
|
|
466
|
+
// client pings it from a `blob:` SharedWorker, which script-src would block without
|
|
467
|
+
// worker-src (community request A4). Development only.
|
|
468
|
+
if (develop)
|
|
469
|
+
server.contentSecurityPolicy({
|
|
470
|
+
plugin: 'dokebi-template-inertia',
|
|
471
|
+
directives: {
|
|
472
|
+
'connect-src': ['ws://*:*', 'wss://*:*'],
|
|
473
|
+
'worker-src': ["'self'", 'blob:'],
|
|
474
|
+
},
|
|
475
|
+
});
|
|
309
476
|
logger.log('debug', 'Inertia settings resolved', {
|
|
310
477
|
develop,
|
|
311
478
|
vite: this.settings.vite ? { ...this.settings.vite } : false,
|
|
312
479
|
ssr: this.settings.ssr ? { ...this.settings.ssr } : false,
|
|
480
|
+
devHosts: this.settings.devHosts.length,
|
|
313
481
|
defaultVersion: this.settings.defaultVersion,
|
|
314
|
-
shared:
|
|
482
|
+
shared: entryShared !== undefined,
|
|
483
|
+
viewData: pluginData.keys,
|
|
315
484
|
flash: flash !== undefined,
|
|
316
485
|
historyReset: hooks.historyReset !== undefined,
|
|
317
486
|
encryptHistory: this.settings.document.encryptHistory,
|
|
@@ -319,30 +488,109 @@ class InertiaRuntime {
|
|
|
319
488
|
propsCheck: process.env.NODE_ENV === 'production' ? 'top-level' : 'deep',
|
|
320
489
|
});
|
|
321
490
|
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
322
|
-
const
|
|
491
|
+
const limits = this.settings.limits;
|
|
492
|
+
const routeIds = this.settings.routeIds;
|
|
493
|
+
const repeats = new Repeats({ intervalMs: limits.logRepeatWindowMs });
|
|
323
494
|
let devServer;
|
|
324
495
|
const devBases = new Set();
|
|
325
|
-
// Vite refuses hosts
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// file or a tunnel) works; an unknown host does not.
|
|
496
|
+
// Vite refuses hosts it does not know (DNS rebinding). Its check is off (one Vite serves
|
|
497
|
+
// every app); instead the dev routes check the host themselves: loopback names, IPs, the
|
|
498
|
+
// domains of @dokebi/app (the same in every cluster worker) and `devHosts` (D-0107 §5).
|
|
329
499
|
const appDomains = context.app.config['dokebi-app']
|
|
330
500
|
?.domains;
|
|
331
|
-
const
|
|
501
|
+
const devHosts = new DevHosts(Object.keys(appDomains ?? {}), this.settings.devHosts);
|
|
332
502
|
const hostName = (request) => server
|
|
333
503
|
.requestHost(request)
|
|
504
|
+
.toLowerCase()
|
|
334
505
|
.replace(/:\d+$/, '')
|
|
335
506
|
.replace(/^\[(.*)\]$/, '$1');
|
|
336
|
-
const devHostAllowed = (host) => host === 'localhost' ||
|
|
337
|
-
host.endsWith('.localhost') ||
|
|
338
|
-
host === '::1' ||
|
|
339
|
-
/^127\.\d+\.\d+\.\d+$/.test(host) ||
|
|
340
|
-
pageHosts.has(host);
|
|
341
507
|
// Registered now: scopes are created later, from other plugins' setup.
|
|
342
508
|
context.defer(async () => {
|
|
343
509
|
if (devServer)
|
|
344
510
|
await (await devServer).close();
|
|
345
511
|
});
|
|
512
|
+
// HMR through the page's own origin (2026-10-28): Vite's client connects to `<base>` on the
|
|
513
|
+
// port the page came from (a tunnel's 443, a proxy, `localhost:<app port>`), and this process
|
|
514
|
+
// joins that upgrade to the Vite host on loopback. The same host check as the dev routes
|
|
515
|
+
// runs first, and Vite checks its token. Upgrades reach Fastify only through a WebSocket
|
|
516
|
+
// plugin's listener (@fastify/websocket routes them); without one this listener takes them.
|
|
517
|
+
let nodeServer;
|
|
518
|
+
const VITE_UPGRADE = Symbol.for('dokebi.template-inertia.vite-upgrade');
|
|
519
|
+
const devBaseOf = (url) => {
|
|
520
|
+
const path = (url ?? '').split('?', 1)[0] ?? '';
|
|
521
|
+
for (const base of devBases)
|
|
522
|
+
if (path.startsWith(base))
|
|
523
|
+
return base;
|
|
524
|
+
return undefined;
|
|
525
|
+
};
|
|
526
|
+
const reportUpgrade = (base) => (failure) => {
|
|
527
|
+
const repeated = repeats.take(`vite upgrade ${failure instanceof Error ? failure.name : typeof failure}`);
|
|
528
|
+
if (repeated !== undefined)
|
|
529
|
+
logger.log('error', 'Vite HMR socket could not reach the Vite dev host', {
|
|
530
|
+
code: 'INERTIA_VITE_DEV_FAILED',
|
|
531
|
+
base,
|
|
532
|
+
error: describeError(failure),
|
|
533
|
+
...(repeated ? { repeated } : {}),
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
/** Joins one Vite upgrade to the host after the dev routes' host check. */
|
|
537
|
+
const joinUpgrade = (request, socket, head, base) => {
|
|
538
|
+
const host = hostName(request);
|
|
539
|
+
if (!devHosts.allows(host)) {
|
|
540
|
+
if (logger.isEnabled('debug'))
|
|
541
|
+
logger.log('debug', 'Vite HMR upgrade refused a host', {
|
|
542
|
+
host: host.slice(0, 253),
|
|
543
|
+
base,
|
|
544
|
+
});
|
|
545
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const opened = devServer;
|
|
549
|
+
if (!opened) {
|
|
550
|
+
socket.destroy();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
opened.then((vite) => vite.upgrade(request, socket, head, reportUpgrade(base)), (error) => {
|
|
554
|
+
reportUpgrade(base)(error);
|
|
555
|
+
socket.destroy();
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
/** Upgrade listeners that own upgrades besides this one (dokebi-web's own only marks). */
|
|
559
|
+
const otherUpgradeOwners = () => (nodeServer?.listeners('upgrade') ?? []).some((listener) => listener !== onUpgrade &&
|
|
560
|
+
listener[Symbol.for('dokebi.web.upgrade-mark')] !== true);
|
|
561
|
+
function onUpgrade(request, socket, head) {
|
|
562
|
+
const base = devBaseOf(request.url);
|
|
563
|
+
if (otherUpgradeOwners()) {
|
|
564
|
+
// A WebSocket plugin routes every upgrade through Fastify: the dev route joins Vite's
|
|
565
|
+
// there (after web's request hooks), so the socket has one owner.
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (base !== undefined && isViteUpgrade(request)) {
|
|
569
|
+
request[VITE_UPGRADE] = true;
|
|
570
|
+
joinUpgrade(request, socket, head, base);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// This listener is the only owner: without it Node would have answered the upgrade as a
|
|
574
|
+
// plain request, and no route here upgrades it.
|
|
575
|
+
if (logger.isEnabled('debug'))
|
|
576
|
+
logger.log('debug', 'Upgrade that is not Vite HMR refused in development', {
|
|
577
|
+
path: String(request.url ?? '')
|
|
578
|
+
.split('?', 1)[0]
|
|
579
|
+
?.slice(0, 200),
|
|
580
|
+
});
|
|
581
|
+
socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
582
|
+
}
|
|
583
|
+
if (develop) {
|
|
584
|
+
// The Node server comes with a Fastify scope (WebApi has no server of its own to hand out).
|
|
585
|
+
server.register(async (scope) => {
|
|
586
|
+
nodeServer = scope.server;
|
|
587
|
+
nodeServer.prependListener('upgrade', onUpgrade);
|
|
588
|
+
logger.log('debug', 'Vite HMR upgrades are joined through the app port');
|
|
589
|
+
}, {});
|
|
590
|
+
context.defer(async () => {
|
|
591
|
+
nodeServer?.removeListener('upgrade', onUpgrade);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
346
594
|
const viteDev = (base) => {
|
|
347
595
|
if (!devServer) {
|
|
348
596
|
const begin = performance.now();
|
|
@@ -351,7 +599,8 @@ class InertiaRuntime {
|
|
|
351
599
|
logger.log('debug', 'Vite dev server ready', {
|
|
352
600
|
ms: Math.round(performance.now() - begin),
|
|
353
601
|
shared: process.env.DOKEBI_VITE_DEV_URL !== undefined,
|
|
354
|
-
hosts:
|
|
602
|
+
hosts: devHosts.sample(),
|
|
603
|
+
configured: this.settings.devHosts.length,
|
|
355
604
|
});
|
|
356
605
|
return opened;
|
|
357
606
|
});
|
|
@@ -366,19 +615,40 @@ class InertiaRuntime {
|
|
|
366
615
|
server.route({
|
|
367
616
|
method: 'GET', // Fastify adds HEAD
|
|
368
617
|
url: `${base}*`,
|
|
618
|
+
config: publicAsset(routeIds, 'viteDev'),
|
|
369
619
|
handler: async (request, reply) => {
|
|
620
|
+
// An HMR upgrade a WebSocket plugin routed here (see onUpgrade): joined to the host
|
|
621
|
+
// with the socket it arrived on; nothing is answered through Fastify.
|
|
622
|
+
const raw = request.raw;
|
|
623
|
+
if (raw.upgrade === true && isViteUpgrade(raw)) {
|
|
624
|
+
reply.hijack();
|
|
625
|
+
joinUpgrade(raw, raw.socket, upgradeHead(raw), base);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
370
628
|
const host = hostName(request);
|
|
371
|
-
if (!
|
|
629
|
+
if (!devHosts.allows(host)) {
|
|
372
630
|
if (logger.isEnabled('debug'))
|
|
373
631
|
logger.log('debug', 'Vite dev route refused a host', {
|
|
374
632
|
host: host.slice(0, 253),
|
|
375
633
|
base,
|
|
634
|
+
routeId: routeIds.ids.viteDev,
|
|
376
635
|
});
|
|
377
636
|
return reply
|
|
378
637
|
.code(403)
|
|
379
638
|
.type('text/plain; charset=utf-8')
|
|
380
|
-
.send('This host
|
|
639
|
+
.send('This host may not load the development views. Add it to devHosts of dokebi-template-inertia (or to the domains of @dokebi/app).');
|
|
381
640
|
}
|
|
641
|
+
// D-0129: the Vite host answers a remote client (a devHosts tunnel, a proxy, a LAN
|
|
642
|
+
// address) a short 403 without paths and no open-in-editor. Loopback only when the
|
|
643
|
+
// socket peer is loopback, no forwarding header is present and the host name judged
|
|
644
|
+
// here (trust-proxy aware) is a loopback name (§12: a tunnel connects from loopback
|
|
645
|
+
// and may rewrite Host to localhost). What the browser sent in the header is replaced.
|
|
646
|
+
delete request.raw.headers[DEV_CLIENT_HEADER];
|
|
647
|
+
request.raw.headers[DEV_CLIENT_HEADER] =
|
|
648
|
+
isLoopbackHost(host) &&
|
|
649
|
+
!isRemoteRequest(request.raw.headers, request.raw.socket.remoteAddress)
|
|
650
|
+
? 'loopback'
|
|
651
|
+
: 'remote';
|
|
382
652
|
const vite = await current;
|
|
383
653
|
reply.hijack();
|
|
384
654
|
// Status, headers and body as the Vite host sent them (504 included).
|
|
@@ -394,13 +664,21 @@ class InertiaRuntime {
|
|
|
394
664
|
});
|
|
395
665
|
},
|
|
396
666
|
});
|
|
397
|
-
logger.log('debug', 'Vite dev route registered', {
|
|
667
|
+
logger.log('debug', 'Vite dev route registered', {
|
|
668
|
+
url: `${base}*`,
|
|
669
|
+
routeId: routeIds.ids.viteDev,
|
|
670
|
+
});
|
|
398
671
|
}
|
|
399
672
|
return current;
|
|
400
673
|
};
|
|
401
674
|
// Built files are served at their exact URL (no wildcard), once even if apps share chunks.
|
|
402
675
|
const served = new Set();
|
|
676
|
+
const loaded = new Set();
|
|
677
|
+
// D-0077 B: `dokebi describe` never serves a page: the build is not needed (or read).
|
|
678
|
+
const describing = context.runtime.describing === true;
|
|
403
679
|
const built = async (options) => {
|
|
680
|
+
if (describing)
|
|
681
|
+
return { scripts: [], styles: [], version: 'describe' };
|
|
404
682
|
if (develop) {
|
|
405
683
|
await viteDev(options.base);
|
|
406
684
|
return {
|
|
@@ -409,7 +687,7 @@ class InertiaRuntime {
|
|
|
409
687
|
version: 'dev',
|
|
410
688
|
};
|
|
411
689
|
}
|
|
412
|
-
const { files, ...assets } = await viteAssets(options);
|
|
690
|
+
const { files, folders, ...assets } = await viteAssets(options, limits.viteManifestMaxBytes);
|
|
413
691
|
logger.log('debug', 'Vite manifest read', {
|
|
414
692
|
manifest: options.manifest,
|
|
415
693
|
entry: options.entry,
|
|
@@ -423,15 +701,71 @@ class InertiaRuntime {
|
|
|
423
701
|
continue;
|
|
424
702
|
served.add(url);
|
|
425
703
|
const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
704
|
+
// Built files are hashed and immutable, and the manifest is read once at start: each is
|
|
705
|
+
// read on its first request and kept (one read shared by concurrent requests). A file
|
|
706
|
+
// above assetCacheMaxBytes is streamed instead of held. A failed read is not kept.
|
|
707
|
+
let content;
|
|
426
708
|
server.route({
|
|
427
709
|
method: 'GET',
|
|
428
710
|
url,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
711
|
+
config: publicAsset(routeIds, 'assets'),
|
|
712
|
+
handler: async (_request, reply) => {
|
|
713
|
+
const loading = content ?? builtAsset(file, limits.assetCacheMaxBytes);
|
|
714
|
+
content = loading;
|
|
715
|
+
let body;
|
|
716
|
+
try {
|
|
717
|
+
body = await loading;
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
if (content === loading)
|
|
721
|
+
content = undefined;
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
if (logger.isEnabled('debug') && !loaded.has(url)) {
|
|
725
|
+
loaded.add(url);
|
|
726
|
+
logger.log('debug', 'Vite built file loaded', {
|
|
727
|
+
url,
|
|
728
|
+
cached: body !== null,
|
|
729
|
+
bytes: body?.length ?? null,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return reply
|
|
733
|
+
.type(type)
|
|
734
|
+
.header('cache-control', 'public, max-age=31536000, immutable')
|
|
735
|
+
.send(body ?? createReadStream(file));
|
|
736
|
+
},
|
|
433
737
|
});
|
|
434
738
|
}
|
|
739
|
+
// A rolling deploy runs two builds at once: a page of the new build loads its chunks from
|
|
740
|
+
// workers that read the old manifest, and a page of the old one the other way round. Every
|
|
741
|
+
// worker therefore also serves the other hashed files of the build folders (a deploy keeps
|
|
742
|
+
// the chunks of the previous builds: vite build.emptyOutDir false), streamed from disk;
|
|
743
|
+
// the manifest, .vite/, ssr/ and anything outside those folders stay unserved.
|
|
744
|
+
for (const [prefix, folder] of folders) {
|
|
745
|
+
const url = `${prefix}*`;
|
|
746
|
+
if (served.has(url))
|
|
747
|
+
continue;
|
|
748
|
+
served.add(url);
|
|
749
|
+
server.route({
|
|
750
|
+
method: 'GET',
|
|
751
|
+
url,
|
|
752
|
+
config: publicAsset(routeIds, 'assets'),
|
|
753
|
+
handler: async (request, reply) => {
|
|
754
|
+
const file = await builtFolderFile(folder, request.params['*']);
|
|
755
|
+
if (!file)
|
|
756
|
+
return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
757
|
+
if (logger.isEnabled('debug'))
|
|
758
|
+
logger.log('debug', 'Vite built file served from its folder', {
|
|
759
|
+
url: request.url.split('?', 1)[0]?.slice(0, 200),
|
|
760
|
+
});
|
|
761
|
+
return reply
|
|
762
|
+
.type(CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream')
|
|
763
|
+
.header('cache-control', HASHED.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache')
|
|
764
|
+
.send(createReadStream(file));
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
logger.log('debug', 'Vite build folder served', { url, folder });
|
|
768
|
+
}
|
|
435
769
|
return assets;
|
|
436
770
|
};
|
|
437
771
|
const document = this.settings.vite
|
|
@@ -444,7 +778,7 @@ class InertiaRuntime {
|
|
|
444
778
|
const bundles = new Map();
|
|
445
779
|
const stats = { renders: 0, failures: 0 };
|
|
446
780
|
const serverRendering = async (ssr, vite) => {
|
|
447
|
-
if (!ssr)
|
|
781
|
+
if (!ssr || describing)
|
|
448
782
|
return undefined;
|
|
449
783
|
let render;
|
|
450
784
|
if (develop) {
|
|
@@ -460,7 +794,7 @@ class InertiaRuntime {
|
|
|
460
794
|
entrySet: ssr.entry !== undefined,
|
|
461
795
|
});
|
|
462
796
|
// The Vite host loads the source and renders (one Vite for every worker).
|
|
463
|
-
render = Promise.resolve(async (page) => (await (await dev).render(source, page)));
|
|
797
|
+
render = Promise.resolve(async (page) => (await (await dev).render(source, page, ssr.timeoutMs)));
|
|
464
798
|
}
|
|
465
799
|
else {
|
|
466
800
|
const path = resolve(ssr.bundle);
|
|
@@ -477,8 +811,44 @@ class InertiaRuntime {
|
|
|
477
811
|
});
|
|
478
812
|
}
|
|
479
813
|
}
|
|
814
|
+
const bundleRender = await render;
|
|
815
|
+
const timeoutMs = ssr.timeoutMs;
|
|
816
|
+
// A render that settles after its deadline: the request already went on (D-0107 §4).
|
|
817
|
+
const late = (outcome, ms, error) => {
|
|
818
|
+
// In development the request to the Vite host is cut at the same deadline (vite-dev.ts):
|
|
819
|
+
// that end is the timeout already counted and logged, not a second failure.
|
|
820
|
+
if (outcome === 'rejected' &&
|
|
821
|
+
error?.code === 'SSR_TIMEOUT') {
|
|
822
|
+
if (logger.isEnabled('debug'))
|
|
823
|
+
logger.log('debug', 'Inertia SSR render request cut at its deadline', {
|
|
824
|
+
bundle: ssr.bundle,
|
|
825
|
+
ms,
|
|
826
|
+
timeoutMs,
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (outcome === 'rejected') {
|
|
831
|
+
const repeated = repeats.take(`ssr late ${ssr.bundle}`);
|
|
832
|
+
if (repeated !== undefined)
|
|
833
|
+
context.logger.log('error', 'Inertia SSR render failed after its deadline', {
|
|
834
|
+
code: 'INERTIA_SSR_FAILED',
|
|
835
|
+
bundle: ssr.bundle,
|
|
836
|
+
ms,
|
|
837
|
+
timeoutMs,
|
|
838
|
+
error: describeError(error),
|
|
839
|
+
...(repeated ? { repeated } : {}),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
else if (logger.isEnabled('debug'))
|
|
843
|
+
logger.log('debug', 'Inertia SSR render finished after its deadline', {
|
|
844
|
+
bundle: ssr.bundle,
|
|
845
|
+
ms,
|
|
846
|
+
timeoutMs,
|
|
847
|
+
});
|
|
848
|
+
};
|
|
849
|
+
logger.log('debug', 'Inertia SSR render deadline', { bundle: ssr.bundle, timeoutMs });
|
|
480
850
|
return Object.freeze({
|
|
481
|
-
render:
|
|
851
|
+
render: (page) => renderWithin(bundleRender, page, timeoutMs, late),
|
|
482
852
|
failure: ssr.failure,
|
|
483
853
|
rendered: () => {
|
|
484
854
|
stats.renders++;
|
|
@@ -506,14 +876,8 @@ class InertiaRuntime {
|
|
|
506
876
|
});
|
|
507
877
|
const api = (responder) => {
|
|
508
878
|
responders.push(responder);
|
|
509
|
-
const render = responder.render.bind(responder);
|
|
510
879
|
return Object.freeze({
|
|
511
|
-
render:
|
|
512
|
-
? (request, reply, component, props, options) => {
|
|
513
|
-
pageHosts.add(hostName(request));
|
|
514
|
-
return render(request, reply, component, props, options);
|
|
515
|
-
}
|
|
516
|
-
: render,
|
|
880
|
+
render: responder.render.bind(responder),
|
|
517
881
|
redirect: responder.redirect.bind(responder),
|
|
518
882
|
location: responder.location.bind(responder),
|
|
519
883
|
answerError: responder.answerError.bind(responder),
|
|
@@ -521,7 +885,7 @@ class InertiaRuntime {
|
|
|
521
885
|
};
|
|
522
886
|
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
523
887
|
const diagnostics = Object.freeze({ logger, repeats });
|
|
524
|
-
const root = api(new InertiaResponder(document,
|
|
888
|
+
const root = api(new InertiaResponder(document, shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
|
|
525
889
|
// D-0074 §3: Inertia requests refused by validation go back with `errors` (with a session);
|
|
526
890
|
// the plugin's `errorPage` answers the rest. dokebi-app adds its own responder later, which
|
|
527
891
|
// dokebi-web asks first, so an app's requests use the app's scope.
|
|
@@ -529,13 +893,35 @@ class InertiaRuntime {
|
|
|
529
893
|
logger.log('debug', 'Inertia error answers', {
|
|
530
894
|
validationRedirect: hooks.validation !== undefined,
|
|
531
895
|
errorPage: document.errorPage,
|
|
896
|
+
bootFallback: bootLog(document),
|
|
532
897
|
});
|
|
533
|
-
|
|
898
|
+
// INERTIA_VERSION_DEFAULT where pages really get the default version (2026-10-28: it was said
|
|
899
|
+
// at setup from the plugin's options alone, once per worker, also when every app template has
|
|
900
|
+
// its own vite; nothing said which app). A scope without vite or version, when the plugin has
|
|
901
|
+
// neither: at its creation, with the app. The plugin's own renderer: at its first render.
|
|
902
|
+
const VERSION_FIX = "set vite: { manifest, entry } (or version) in this app's inertia options (src/apps/<app>/app.ts), or in options.dokebi-template-inertia for every app";
|
|
903
|
+
let rootWarned = false;
|
|
904
|
+
const rootRender = (...args) => {
|
|
905
|
+
if (this.settings.defaultVersion && !rootWarned) {
|
|
906
|
+
rootWarned = true;
|
|
907
|
+
logger.log('warn', 'Inertia asset version is not configured', {
|
|
908
|
+
code: 'INERTIA_VERSION_DEFAULT',
|
|
909
|
+
where: 'plugin',
|
|
910
|
+
fix: 'set vite: { manifest, entry } (or version) in options.dokebi-template-inertia',
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
return root.render(...args);
|
|
914
|
+
};
|
|
534
915
|
const plugin = Object.freeze({
|
|
535
916
|
...root,
|
|
917
|
+
render: rootRender,
|
|
536
918
|
ssrStats: () => Object.freeze({ ...stats }),
|
|
537
919
|
scope: async (options) => {
|
|
538
|
-
const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
|
|
920
|
+
const { shared: extra, vite, ssr: ssrInput, sealed = false, app: appName, ...fields } = (options ?? {});
|
|
921
|
+
// Audit r6: `app` goes into log records as it is, so it is checked like the other fields
|
|
922
|
+
// (it used to be refused as an unknown key; anything was accepted once it became one).
|
|
923
|
+
if (appName !== undefined && (typeof appName !== 'string' || !SCOPE_APP.test(appName)))
|
|
924
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope app must be 1-64 printable ASCII characters');
|
|
539
925
|
if (typeof sealed !== 'boolean')
|
|
540
926
|
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
|
|
541
927
|
const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
|
|
@@ -561,6 +947,15 @@ class InertiaRuntime {
|
|
|
561
947
|
: (extra ?? shared);
|
|
562
948
|
const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
|
|
563
949
|
const responder = new InertiaResponder(scoped, merged, await serverRendering(ssr, scopedVite), diagnostics, flash, hooks);
|
|
950
|
+
// Once the scope is valid: a scope refused above serves no page with the default version.
|
|
951
|
+
if (this.settings.defaultVersion && vite === undefined && fields.version === undefined)
|
|
952
|
+
logger.log('warn', 'Inertia asset version is not configured', {
|
|
953
|
+
code: 'INERTIA_VERSION_DEFAULT',
|
|
954
|
+
where: 'scope',
|
|
955
|
+
app: appName ?? null,
|
|
956
|
+
title: scoped.title.slice(0, 100),
|
|
957
|
+
fix: VERSION_FIX,
|
|
958
|
+
});
|
|
564
959
|
logger.log('debug', 'Inertia scope created', {
|
|
565
960
|
title: scoped.title,
|
|
566
961
|
rootId: scoped.rootId,
|
|
@@ -570,6 +965,7 @@ class InertiaRuntime {
|
|
|
570
965
|
sealed,
|
|
571
966
|
encryptHistory: scoped.encryptHistory,
|
|
572
967
|
errorPage: scoped.errorPage,
|
|
968
|
+
bootFallback: bootLog(scoped),
|
|
573
969
|
});
|
|
574
970
|
return api(responder);
|
|
575
971
|
},
|
|
@@ -584,11 +980,12 @@ class InertiaRuntime {
|
|
|
584
980
|
}
|
|
585
981
|
export const createPlugin = definePlugin({
|
|
586
982
|
name: 'dokebi-template-inertia',
|
|
587
|
-
version: '0.4.
|
|
588
|
-
core: '^0.4.
|
|
983
|
+
version: '0.4.4',
|
|
984
|
+
core: '^0.4.2',
|
|
589
985
|
options,
|
|
590
986
|
entry: parseEntry,
|
|
591
987
|
contributes: [viewEngines],
|
|
988
|
+
collects: [viewData],
|
|
592
989
|
dependencies: () => [web, session],
|
|
593
990
|
instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
|
|
594
991
|
});
|