@dokebi/template-inertia 0.4.2 → 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 +136 -23
- 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 +426 -63
- 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 +70 -11
- 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,29 +1,60 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
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
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
/**
|
|
22
|
-
|
|
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) {
|
|
23
40
|
const info = await stat(file);
|
|
24
|
-
return info.size >
|
|
41
|
+
return info.size > maxBytes ? null : readFile(file);
|
|
25
42
|
}
|
|
26
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
|
+
}
|
|
27
58
|
function parseVite(input) {
|
|
28
59
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
29
60
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
@@ -43,14 +74,90 @@ function parseSsr(input) {
|
|
|
43
74
|
return undefined;
|
|
44
75
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
45
76
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
46
|
-
const { bundle, failure = 'fallback', entry, ...rest } = input;
|
|
77
|
+
const { bundle, failure = 'fallback', entry, timeoutMs = SSR_TIMEOUT_MS, ...rest } = input;
|
|
47
78
|
if (Object.keys(rest).length ||
|
|
48
79
|
typeof bundle !== 'string' ||
|
|
49
80
|
!bundle ||
|
|
50
81
|
(failure !== 'fallback' && failure !== 'error') ||
|
|
51
|
-
(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)
|
|
52
86
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
53
|
-
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
|
+
}
|
|
54
161
|
}
|
|
55
162
|
/**
|
|
56
163
|
* Imports the SSR bundle. A missing file or a bundle without `render` stops the start: SSR that
|
|
@@ -93,30 +200,45 @@ const CONTENT_TYPES = {
|
|
|
93
200
|
* Reads the manifest once and returns the document asset settings for the entry, plus the built
|
|
94
201
|
* files the entry can load (its chunks, their CSS and assets) so they can be served.
|
|
95
202
|
*/
|
|
96
|
-
async function viteAssets(vite) {
|
|
203
|
+
async function viteAssets(vite, maxBytes) {
|
|
97
204
|
const path = resolve(vite.manifest);
|
|
98
205
|
const info = await stat(path).catch((cause) => {
|
|
99
206
|
if (cause.code !== 'ENOENT')
|
|
100
207
|
throw cause;
|
|
101
208
|
throw new InertiaError('VITE_MANIFEST_MISSING', `Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
|
|
102
209
|
});
|
|
103
|
-
if (!info.isFile() || info.size >
|
|
104
|
-
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');
|
|
105
214
|
// Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
|
|
106
215
|
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
107
216
|
const text = await readFile(path, 'utf8');
|
|
108
|
-
|
|
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`);
|
|
109
226
|
const entry = Object.hasOwn(manifest, vite.entry) ? manifest[vite.entry] : undefined;
|
|
110
227
|
if (entry?.isEntry !== true || typeof entry.file !== 'string')
|
|
111
228
|
throw new InertiaError('VITE_MANIFEST_INVALID', 'Vite manifest has no entry ' + JSON.stringify(vite.entry));
|
|
112
229
|
const styles = [];
|
|
113
230
|
const files = new Map();
|
|
231
|
+
const folders = new Map();
|
|
114
232
|
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
115
233
|
const serve = (file) => {
|
|
116
234
|
// Manifest paths are relative to outDir; anything that leaves it is not served.
|
|
117
235
|
if (typeof file !== 'string' || file.split('/').some((part) => part === '..' || !part))
|
|
118
236
|
return;
|
|
119
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));
|
|
120
242
|
};
|
|
121
243
|
const seen = new Set();
|
|
122
244
|
// Static imports decide the document CSS; dynamic imports are only served.
|
|
@@ -146,6 +268,7 @@ async function viteAssets(vite) {
|
|
|
146
268
|
styles,
|
|
147
269
|
version: createHash('sha256').update(text).digest('hex').slice(0, 32),
|
|
148
270
|
files,
|
|
271
|
+
folders,
|
|
149
272
|
};
|
|
150
273
|
}
|
|
151
274
|
/**
|
|
@@ -161,14 +284,14 @@ function hasCookie(header, name) {
|
|
|
161
284
|
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
162
285
|
}
|
|
163
286
|
/**
|
|
164
|
-
* dokebi-web 0.4.
|
|
287
|
+
* dokebi-web 0.4.3 or later: the validation redirect uses its form helpers (`formBackTarget`,
|
|
165
288
|
* `formOldInput`, D-0074 addendum 1), shared with @dokebi/app; 0.4.2 never answers a Referer
|
|
166
|
-
* path that reads as another host (ISSUE-0006 A02). A local descriptor instead of
|
|
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
|
|
167
290
|
* '@dokebi/web/dependency' (whose range stays ^0.4.0 for the plugins that need nothing newer).
|
|
168
291
|
*/
|
|
169
292
|
const web = providerDependency({
|
|
170
293
|
name: 'dokebi-web',
|
|
171
|
-
version: '^0.4.
|
|
294
|
+
version: '^0.4.3',
|
|
172
295
|
packageName: '@dokebi/web',
|
|
173
296
|
load: () => import('@dokebi/web'),
|
|
174
297
|
});
|
|
@@ -180,19 +303,32 @@ const session = optionalDependency({
|
|
|
180
303
|
function sealSsrConflict() {
|
|
181
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.');
|
|
182
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
|
+
}
|
|
183
313
|
/** Settings of the parsed options (the declaration checked them, D-0063 §3). */
|
|
184
314
|
function settings(parsed) {
|
|
185
|
-
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;
|
|
186
318
|
const document = normalizeInertiaOptions(rest);
|
|
187
319
|
return Object.freeze({
|
|
188
320
|
document,
|
|
321
|
+
routeIds: pluginRouteIds('dokebi-template-inertia', ['assets', 'viteDev'], routeIds),
|
|
322
|
+
limits: Object.freeze({ viteManifestMaxBytes, assetCacheMaxBytes, logRepeatWindowMs }),
|
|
189
323
|
defaultVersion: vite === undefined && rest.version === undefined,
|
|
324
|
+
devHosts: Object.freeze([...devHosts]),
|
|
190
325
|
...(vite ? { vite: Object.freeze({ ...vite }) } : {}),
|
|
191
326
|
...(ssr
|
|
192
327
|
? {
|
|
193
328
|
ssr: Object.freeze({
|
|
194
329
|
bundle: ssr.bundle,
|
|
195
330
|
failure: ssr.failure,
|
|
331
|
+
timeoutMs: ssr.timeoutMs,
|
|
196
332
|
...(ssr.entry ? { entry: ssr.entry } : {}),
|
|
197
333
|
}),
|
|
198
334
|
}
|
|
@@ -231,23 +367,34 @@ class InertiaRuntime {
|
|
|
231
367
|
this.shared = shared;
|
|
232
368
|
}
|
|
233
369
|
async setup(context) {
|
|
234
|
-
if (this.settings.defaultVersion)
|
|
235
|
-
context.logger.log('warn', 'Inertia asset version is not configured', {
|
|
236
|
-
code: 'INERTIA_VERSION_DEFAULT',
|
|
237
|
-
});
|
|
238
370
|
const server = context.getDependency(web);
|
|
239
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));
|
|
240
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).
|
|
241
391
|
const flash = sessions
|
|
242
|
-
? async (request) => (await
|
|
392
|
+
? async (request) => ((await sessionOf(request)?.takeFlash()) ?? {})
|
|
243
393
|
: undefined;
|
|
244
394
|
// D-0062: sealed requests (dokebi-web decides) and the session's history reset (logout, login).
|
|
245
395
|
// A page shown in the request that reset uses it at once; otherwise the reset cookie carries
|
|
246
396
|
// it to the next page, which also clears that cookie.
|
|
247
397
|
const resets = new WeakMap();
|
|
248
|
-
const sessionOf = (request) =>
|
|
249
|
-
// Static paths excluded by the session plugin have none (`request.session` is null).
|
|
250
|
-
request.session ? sessions?.of(request) : undefined;
|
|
251
398
|
const hooks = Object.freeze({
|
|
252
399
|
sealOf: (request) => server.sealOf(request),
|
|
253
400
|
...(sessions
|
|
@@ -262,7 +409,7 @@ class InertiaRuntime {
|
|
|
262
409
|
// D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
|
|
263
410
|
validation: async (request, reply, errors) => {
|
|
264
411
|
const current = sessionOf(request);
|
|
265
|
-
const target = formBackTarget(request, server.requestHost(request), '/');
|
|
412
|
+
const target = formBackTarget(request, server.requestHost(request), '/', server.formLimits);
|
|
266
413
|
if (!current?.flash || target === undefined) {
|
|
267
414
|
if (logger.isEnabled('debug'))
|
|
268
415
|
logger.log('debug', 'Inertia validation answered as JSON', {
|
|
@@ -273,7 +420,7 @@ class InertiaRuntime {
|
|
|
273
420
|
return false;
|
|
274
421
|
}
|
|
275
422
|
await current.flash('errors', { ...errors });
|
|
276
|
-
const old = formOldInput(request.body);
|
|
423
|
+
const old = formOldInput(request.body, server.formLimits);
|
|
277
424
|
if (old)
|
|
278
425
|
await current.flash('old', old);
|
|
279
426
|
if (logger.isEnabled('debug'))
|
|
@@ -314,12 +461,26 @@ class InertiaRuntime {
|
|
|
314
461
|
// Development (D-0045): one Vite for every scope, reached by the first `vite` (the host of
|
|
315
462
|
// `dokebi dev`, shared by every worker, or one started in this process).
|
|
316
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
|
+
});
|
|
317
476
|
logger.log('debug', 'Inertia settings resolved', {
|
|
318
477
|
develop,
|
|
319
478
|
vite: this.settings.vite ? { ...this.settings.vite } : false,
|
|
320
479
|
ssr: this.settings.ssr ? { ...this.settings.ssr } : false,
|
|
480
|
+
devHosts: this.settings.devHosts.length,
|
|
321
481
|
defaultVersion: this.settings.defaultVersion,
|
|
322
|
-
shared:
|
|
482
|
+
shared: entryShared !== undefined,
|
|
483
|
+
viewData: pluginData.keys,
|
|
323
484
|
flash: flash !== undefined,
|
|
324
485
|
historyReset: hooks.historyReset !== undefined,
|
|
325
486
|
encryptHistory: this.settings.document.encryptHistory,
|
|
@@ -327,30 +488,109 @@ class InertiaRuntime {
|
|
|
327
488
|
propsCheck: process.env.NODE_ENV === 'production' ? 'top-level' : 'deep',
|
|
328
489
|
});
|
|
329
490
|
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
330
|
-
const
|
|
491
|
+
const limits = this.settings.limits;
|
|
492
|
+
const routeIds = this.settings.routeIds;
|
|
493
|
+
const repeats = new Repeats({ intervalMs: limits.logRepeatWindowMs });
|
|
331
494
|
let devServer;
|
|
332
495
|
const devBases = new Set();
|
|
333
|
-
// Vite refuses hosts
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// 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).
|
|
337
499
|
const appDomains = context.app.config['dokebi-app']
|
|
338
500
|
?.domains;
|
|
339
|
-
const
|
|
501
|
+
const devHosts = new DevHosts(Object.keys(appDomains ?? {}), this.settings.devHosts);
|
|
340
502
|
const hostName = (request) => server
|
|
341
503
|
.requestHost(request)
|
|
504
|
+
.toLowerCase()
|
|
342
505
|
.replace(/:\d+$/, '')
|
|
343
506
|
.replace(/^\[(.*)\]$/, '$1');
|
|
344
|
-
const devHostAllowed = (host) => host === 'localhost' ||
|
|
345
|
-
host.endsWith('.localhost') ||
|
|
346
|
-
host === '::1' ||
|
|
347
|
-
/^127\.\d+\.\d+\.\d+$/.test(host) ||
|
|
348
|
-
pageHosts.has(host);
|
|
349
507
|
// Registered now: scopes are created later, from other plugins' setup.
|
|
350
508
|
context.defer(async () => {
|
|
351
509
|
if (devServer)
|
|
352
510
|
await (await devServer).close();
|
|
353
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
|
+
}
|
|
354
594
|
const viteDev = (base) => {
|
|
355
595
|
if (!devServer) {
|
|
356
596
|
const begin = performance.now();
|
|
@@ -359,7 +599,8 @@ class InertiaRuntime {
|
|
|
359
599
|
logger.log('debug', 'Vite dev server ready', {
|
|
360
600
|
ms: Math.round(performance.now() - begin),
|
|
361
601
|
shared: process.env.DOKEBI_VITE_DEV_URL !== undefined,
|
|
362
|
-
hosts:
|
|
602
|
+
hosts: devHosts.sample(),
|
|
603
|
+
configured: this.settings.devHosts.length,
|
|
363
604
|
});
|
|
364
605
|
return opened;
|
|
365
606
|
});
|
|
@@ -374,19 +615,40 @@ class InertiaRuntime {
|
|
|
374
615
|
server.route({
|
|
375
616
|
method: 'GET', // Fastify adds HEAD
|
|
376
617
|
url: `${base}*`,
|
|
618
|
+
config: publicAsset(routeIds, 'viteDev'),
|
|
377
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
|
+
}
|
|
378
628
|
const host = hostName(request);
|
|
379
|
-
if (!
|
|
629
|
+
if (!devHosts.allows(host)) {
|
|
380
630
|
if (logger.isEnabled('debug'))
|
|
381
631
|
logger.log('debug', 'Vite dev route refused a host', {
|
|
382
632
|
host: host.slice(0, 253),
|
|
383
633
|
base,
|
|
634
|
+
routeId: routeIds.ids.viteDev,
|
|
384
635
|
});
|
|
385
636
|
return reply
|
|
386
637
|
.code(403)
|
|
387
638
|
.type('text/plain; charset=utf-8')
|
|
388
|
-
.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).');
|
|
389
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';
|
|
390
652
|
const vite = await current;
|
|
391
653
|
reply.hijack();
|
|
392
654
|
// Status, headers and body as the Vite host sent them (504 included).
|
|
@@ -402,14 +664,21 @@ class InertiaRuntime {
|
|
|
402
664
|
});
|
|
403
665
|
},
|
|
404
666
|
});
|
|
405
|
-
logger.log('debug', 'Vite dev route registered', {
|
|
667
|
+
logger.log('debug', 'Vite dev route registered', {
|
|
668
|
+
url: `${base}*`,
|
|
669
|
+
routeId: routeIds.ids.viteDev,
|
|
670
|
+
});
|
|
406
671
|
}
|
|
407
672
|
return current;
|
|
408
673
|
};
|
|
409
674
|
// Built files are served at their exact URL (no wildcard), once even if apps share chunks.
|
|
410
675
|
const served = new Set();
|
|
411
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;
|
|
412
679
|
const built = async (options) => {
|
|
680
|
+
if (describing)
|
|
681
|
+
return { scripts: [], styles: [], version: 'describe' };
|
|
413
682
|
if (develop) {
|
|
414
683
|
await viteDev(options.base);
|
|
415
684
|
return {
|
|
@@ -418,7 +687,7 @@ class InertiaRuntime {
|
|
|
418
687
|
version: 'dev',
|
|
419
688
|
};
|
|
420
689
|
}
|
|
421
|
-
const { files, ...assets } = await viteAssets(options);
|
|
690
|
+
const { files, folders, ...assets } = await viteAssets(options, limits.viteManifestMaxBytes);
|
|
422
691
|
logger.log('debug', 'Vite manifest read', {
|
|
423
692
|
manifest: options.manifest,
|
|
424
693
|
entry: options.entry,
|
|
@@ -434,13 +703,14 @@ class InertiaRuntime {
|
|
|
434
703
|
const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
435
704
|
// Built files are hashed and immutable, and the manifest is read once at start: each is
|
|
436
705
|
// read on its first request and kept (one read shared by concurrent requests). A file
|
|
437
|
-
// above
|
|
706
|
+
// above assetCacheMaxBytes is streamed instead of held. A failed read is not kept.
|
|
438
707
|
let content;
|
|
439
708
|
server.route({
|
|
440
709
|
method: 'GET',
|
|
441
710
|
url,
|
|
711
|
+
config: publicAsset(routeIds, 'assets'),
|
|
442
712
|
handler: async (_request, reply) => {
|
|
443
|
-
const loading = content ?? builtAsset(file);
|
|
713
|
+
const loading = content ?? builtAsset(file, limits.assetCacheMaxBytes);
|
|
444
714
|
content = loading;
|
|
445
715
|
let body;
|
|
446
716
|
try {
|
|
@@ -466,6 +736,36 @@ class InertiaRuntime {
|
|
|
466
736
|
},
|
|
467
737
|
});
|
|
468
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
|
+
}
|
|
469
769
|
return assets;
|
|
470
770
|
};
|
|
471
771
|
const document = this.settings.vite
|
|
@@ -478,7 +778,7 @@ class InertiaRuntime {
|
|
|
478
778
|
const bundles = new Map();
|
|
479
779
|
const stats = { renders: 0, failures: 0 };
|
|
480
780
|
const serverRendering = async (ssr, vite) => {
|
|
481
|
-
if (!ssr)
|
|
781
|
+
if (!ssr || describing)
|
|
482
782
|
return undefined;
|
|
483
783
|
let render;
|
|
484
784
|
if (develop) {
|
|
@@ -494,7 +794,7 @@ class InertiaRuntime {
|
|
|
494
794
|
entrySet: ssr.entry !== undefined,
|
|
495
795
|
});
|
|
496
796
|
// The Vite host loads the source and renders (one Vite for every worker).
|
|
497
|
-
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)));
|
|
498
798
|
}
|
|
499
799
|
else {
|
|
500
800
|
const path = resolve(ssr.bundle);
|
|
@@ -511,8 +811,44 @@ class InertiaRuntime {
|
|
|
511
811
|
});
|
|
512
812
|
}
|
|
513
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 });
|
|
514
850
|
return Object.freeze({
|
|
515
|
-
render:
|
|
851
|
+
render: (page) => renderWithin(bundleRender, page, timeoutMs, late),
|
|
516
852
|
failure: ssr.failure,
|
|
517
853
|
rendered: () => {
|
|
518
854
|
stats.renders++;
|
|
@@ -540,14 +876,8 @@ class InertiaRuntime {
|
|
|
540
876
|
});
|
|
541
877
|
const api = (responder) => {
|
|
542
878
|
responders.push(responder);
|
|
543
|
-
const render = responder.render.bind(responder);
|
|
544
879
|
return Object.freeze({
|
|
545
|
-
render:
|
|
546
|
-
? (request, reply, component, props, options) => {
|
|
547
|
-
pageHosts.add(hostName(request));
|
|
548
|
-
return render(request, reply, component, props, options);
|
|
549
|
-
}
|
|
550
|
-
: render,
|
|
880
|
+
render: responder.render.bind(responder),
|
|
551
881
|
redirect: responder.redirect.bind(responder),
|
|
552
882
|
location: responder.location.bind(responder),
|
|
553
883
|
answerError: responder.answerError.bind(responder),
|
|
@@ -555,7 +885,7 @@ class InertiaRuntime {
|
|
|
555
885
|
};
|
|
556
886
|
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
557
887
|
const diagnostics = Object.freeze({ logger, repeats });
|
|
558
|
-
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));
|
|
559
889
|
// D-0074 §3: Inertia requests refused by validation go back with `errors` (with a session);
|
|
560
890
|
// the plugin's `errorPage` answers the rest. dokebi-app adds its own responder later, which
|
|
561
891
|
// dokebi-web asks first, so an app's requests use the app's scope.
|
|
@@ -563,13 +893,35 @@ class InertiaRuntime {
|
|
|
563
893
|
logger.log('debug', 'Inertia error answers', {
|
|
564
894
|
validationRedirect: hooks.validation !== undefined,
|
|
565
895
|
errorPage: document.errorPage,
|
|
896
|
+
bootFallback: bootLog(document),
|
|
566
897
|
});
|
|
567
|
-
|
|
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
|
+
};
|
|
568
915
|
const plugin = Object.freeze({
|
|
569
916
|
...root,
|
|
917
|
+
render: rootRender,
|
|
570
918
|
ssrStats: () => Object.freeze({ ...stats }),
|
|
571
919
|
scope: async (options) => {
|
|
572
|
-
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');
|
|
573
925
|
if (typeof sealed !== 'boolean')
|
|
574
926
|
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
|
|
575
927
|
const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
|
|
@@ -595,6 +947,15 @@ class InertiaRuntime {
|
|
|
595
947
|
: (extra ?? shared);
|
|
596
948
|
const scopedVite = vite === undefined ? this.settings.vite : parseVite(vite);
|
|
597
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
|
+
});
|
|
598
959
|
logger.log('debug', 'Inertia scope created', {
|
|
599
960
|
title: scoped.title,
|
|
600
961
|
rootId: scoped.rootId,
|
|
@@ -604,6 +965,7 @@ class InertiaRuntime {
|
|
|
604
965
|
sealed,
|
|
605
966
|
encryptHistory: scoped.encryptHistory,
|
|
606
967
|
errorPage: scoped.errorPage,
|
|
968
|
+
bootFallback: bootLog(scoped),
|
|
607
969
|
});
|
|
608
970
|
return api(responder);
|
|
609
971
|
},
|
|
@@ -618,11 +980,12 @@ class InertiaRuntime {
|
|
|
618
980
|
}
|
|
619
981
|
export const createPlugin = definePlugin({
|
|
620
982
|
name: 'dokebi-template-inertia',
|
|
621
|
-
version: '0.4.
|
|
622
|
-
core: '^0.4.
|
|
983
|
+
version: '0.4.4',
|
|
984
|
+
core: '^0.4.2',
|
|
623
985
|
options,
|
|
624
986
|
entry: parseEntry,
|
|
625
987
|
contributes: [viewEngines],
|
|
988
|
+
collects: [viewData],
|
|
626
989
|
dependencies: () => [web, session],
|
|
627
990
|
instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
|
|
628
991
|
});
|