@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/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
- const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
19
- /** Built files up to this size are kept in memory after their first request; larger ones are streamed. */
20
- const ASSET_CACHE_MAX_BYTES = 4 * 1024 * 1024;
21
- /** The bytes of a built file to keep, or null when it is too large to keep (streamed per request). */
22
- async function builtAsset(file) {
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 > ASSET_CACHE_MAX_BYTES ? null : readFile(file);
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({ bundle, failure, ...(entry ? { entry: entry } : {}) });
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 > MANIFEST_MAX_BYTES)
104
- throw new InertiaError('VITE_MANIFEST_INVALID', 'Invalid Vite manifest file');
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
- const manifest = JSON.parse(text);
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.2 or later: the validation redirect uses its form helpers (`formBackTarget`,
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.2',
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, ...rest } = parsed;
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 sessions.of(request).takeFlash())
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: this.shared !== undefined,
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 repeats = new Repeats();
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 other than localhost (DNS rebinding). Its check is off; instead the dev
334
- // routes serve loopback names, the domains of @dokebi/app (the same in every cluster worker)
335
- // and hosts this process answered with an Inertia page. An app domain (a.com through a hosts
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 pageHosts = new Set(Object.keys(appDomains ?? {}).map((host) => host.toLowerCase().replace(/:\d+$/, '')));
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: [...pageHosts].slice(0, 64),
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 (!devHostAllowed(host)) {
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 has not been served an Inertia page by this app.');
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', { url: `${base}*` });
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 ASSET_CACHE_MAX_BYTES is streamed instead of held. A failed read is not kept.
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: await 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: develop
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, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
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
- const shared = this.shared;
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.2',
622
- core: '^0.4.0',
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
  });