@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/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
- const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
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({ 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
+ }
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 > MANIFEST_MAX_BYTES)
97
- 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');
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
- 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`);
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.1 or later: the validation redirect uses its form helpers (`formBackTarget`,
158
- * `formOldInput`, D-0074 addendum 1), shared with @dokebi/app. A local descriptor instead of
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.1',
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, ...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;
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 sessions.of(request).takeFlash())
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: this.shared !== undefined,
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 repeats = new Repeats();
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 other than localhost (DNS rebinding). Its check is off; instead the dev
326
- // routes serve loopback names, the domains of @dokebi/app (the same in every cluster worker)
327
- // and hosts this process answered with an Inertia page. An app domain (a.com through a hosts
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 pageHosts = new Set(Object.keys(appDomains ?? {}).map((host) => host.toLowerCase().replace(/:\d+$/, '')));
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: [...pageHosts].slice(0, 64),
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 (!devHostAllowed(host)) {
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 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).');
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', { url: `${base}*` });
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
- handler: async (_request, reply) => reply
430
- .type(type)
431
- .header('cache-control', 'public, max-age=31536000, immutable')
432
- .send(await readFile(file)),
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: await 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: develop
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, 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));
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
- 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
+ };
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.1',
588
- core: '^0.4.0',
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
  });