@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/vite-dev.js CHANGED
@@ -13,11 +13,14 @@
13
13
  import { randomBytes, timingSafeEqual } from 'node:crypto';
14
14
  import { Agent, createServer as createHttpServer, request as httpRequest, } from 'node:http';
15
15
  import { createRequire } from 'node:module';
16
- import { createServer as createNetServer, } from 'node:net';
16
+ import { connect as connectNet, createServer as createNetServer, } from 'node:net';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
19
  import { diagnostics } from '@dokebi/core/libraries';
20
20
  import { InertiaError } from './errors.js';
21
+ import { DependencyCache } from './vite-deps.js';
22
+ import { DEV_CLIENT_HEADER, dokebiDevFiles } from './vite-files.js';
23
+ import { ServedFiles, staleEventFilter } from './vite-watch.js';
21
24
  const { describeError } = diagnostics;
22
25
  export const viteDevRequested = () => process.env.DOKEBI_VITE_DEV === '1';
23
26
  /**
@@ -47,6 +50,11 @@ const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
47
50
  const PORT_ATTEMPTS = 8;
48
51
  /** WebSocket subprotocols of Vite's client; other upgrades on the host port are closed. */
49
52
  const VITE_PROTOCOLS = new Set(['vite-hmr', 'vite-ping']);
53
+ /** Whether an upgrade request is Vite's client (its HMR or ping socket), by its subprotocol. */
54
+ export function isViteUpgrade(request) {
55
+ const protocol = request.headers['sec-websocket-protocol'];
56
+ return typeof protocol === 'string' && VITE_PROTOCOLS.has(protocol.trim());
57
+ }
50
58
  /** Hop-by-hop headers (RFC 9110 §7.6.1) that a forwarder does not pass on. */
51
59
  const HOP_BY_HOP = [
52
60
  'connection',
@@ -59,6 +67,8 @@ const HOP_BY_HOP = [
59
67
  'transfer-encoding',
60
68
  'upgrade',
61
69
  ];
70
+ /** Default of `devDepsRepairIntervalMs` (D-0128). */
71
+ export const DEFAULT_DEPS_REPAIR_INTERVAL_MS = 10_000;
62
72
  function listenExclusive(host, port) {
63
73
  return new Promise((resolve, reject) => {
64
74
  const listener = createNetServer();
@@ -206,14 +216,22 @@ function findVite(root) {
206
216
  * - WebSocket upgrades of Vite's client (HMR): Vite answers them and keeps its token check
207
217
  * (cross-site WebSocket hijacking). @dokebi/websocket answers every upgrade on the app's port,
208
218
  * so HMR cannot share that one.
219
+ *
220
+ * Watcher events for files whose state did not change since Vite read them (FSEvents delivering
221
+ * writes from before the start, late) are dropped before Vite sees them (`vite-watch.ts`).
222
+ *
223
+ * The pre-bundled dependencies (node_modules/.vite/deps) are compared with what Vite serves at
224
+ * the start, whenever an app process connects (every app restart) and after a 504; when the
225
+ * folder changed under Vite, the host re-optimizes (`vite-deps.ts`, D-0128).
209
226
  */
210
- export async function startViteHost(logger, root = process.cwd()) {
227
+ export async function startViteHost(logger, root = process.cwd(), settings = {}) {
211
228
  const begin = performance.now();
212
229
  const entry = findVite(root);
213
230
  const vite = (await import(pathToFileURL(entry).href));
214
231
  const key = randomBytes(24).toString('base64url');
215
232
  const expected = Buffer.from(key);
216
233
  let started;
234
+ let deps;
217
235
  const report = (operation) => (error) => logger.log('error', 'Vite dev host request failed', {
218
236
  code: 'INERTIA_VITE_HOST_FAILED',
219
237
  operation,
@@ -259,25 +277,48 @@ export async function startViteHost(logger, root = process.cwd()) {
259
277
  return;
260
278
  }
261
279
  const path = String(request.url ?? '').split('?', 1)[0];
262
- if (path === HOST_PATH)
263
- sendJson(response, 200, { root, pid: process.pid });
264
- else if (path === SSR_PATH && request.method === 'POST')
280
+ // D-0129: a browser request the app forwarded (it carries DEV_CLIENT_HEADER) never reaches
281
+ // the host-only paths: with the Vite base `/` they sit under the dev route, and the ping
282
+ // names the project root.
283
+ const browser = request.headers[DEV_CLIENT_HEADER] !== undefined;
284
+ const cache = deps;
285
+ if (path === HOST_PATH && !browser) {
286
+ // An app process connects (it started or restarted): it is answered once the pre-bundled
287
+ // dependencies match what Vite serves, so its pages get hashes that load (D-0128).
288
+ void (cache ? cache.ensure('client').catch(report('deps')) : Promise.resolve()).then(() => sendJson(response, 200, { root, pid: process.pid }));
289
+ }
290
+ else if (path === SSR_PATH && request.method === 'POST' && !browser)
265
291
  void ssr(request, response, server);
266
- else
292
+ else {
293
+ // Vite answers 504 for a pre-bundled file it cannot serve: if the folder changed under it,
294
+ // re-optimize so the next page load gets files that exist (D-0128).
295
+ if (cache)
296
+ response.once('finish', () => {
297
+ if (response.statusCode === 504)
298
+ void cache.ensure('outdated').catch(report('deps'));
299
+ });
267
300
  server.middlewares(request, response, (error) => viteFallthrough(response, error, report('vite')));
301
+ }
268
302
  });
303
+ // Events FSEvents still holds from before the start are no edits (D-0045 addendum 4).
304
+ const served = new ServedFiles(logger);
305
+ served.recordConfig(root);
269
306
  try {
270
307
  started = await vite.createServer({
271
308
  root,
272
309
  appType: 'custom',
310
+ // D-0129: the dev file policy for a vite.config without dokebiInertia() (which carries it).
311
+ plugins: [staleEventFilter(served, logger), dokebiDevFiles(logger)],
273
312
  server: {
274
313
  middlewareMode: true,
275
314
  // Hosts are checked by the dev routes of the plugin (see index.ts) and HTTP needs the key,
276
315
  // so Vite's host check is off. The HMR socket keeps Vite's token check.
277
316
  allowedHosts: true,
278
- // Vite 8 `server.ws`: Vite answers upgrades on `server`; its client connects to `port`
279
- // on the page's host name.
280
- ws: { server: endpoint.server, port: endpoint.port },
317
+ // Vite 8 `server.ws`: Vite answers upgrades on `server`. No `port`: its client then
318
+ // connects to the page's own origin and `<base>` (the port the page came from, a tunnel's
319
+ // 443 included), and the app forwards that upgrade here (ViteDev.upgrade). With a port
320
+ // it went to `<page host>:<this loopback port>`, which no tunnel or other device reaches.
321
+ ws: { server: endpoint.server },
281
322
  },
282
323
  });
283
324
  }
@@ -287,6 +328,24 @@ export async function startViteHost(logger, root = process.cwd()) {
287
328
  throw error;
288
329
  }
289
330
  const server = started;
331
+ const cache = new DependencyCache(logger, server, {
332
+ repairIntervalMs: settings.depsRepairIntervalMs ?? DEFAULT_DEPS_REPAIR_INTERVAL_MS,
333
+ });
334
+ try {
335
+ await cache.claim();
336
+ // A cache left broken by an earlier session would answer every dependency 504 (D-0128).
337
+ await cache.ensure('start');
338
+ }
339
+ catch (error) {
340
+ try {
341
+ await server.close();
342
+ }
343
+ finally {
344
+ await endpoint.close();
345
+ }
346
+ throw error;
347
+ }
348
+ deps = cache;
290
349
  logger.log('debug', 'Vite dev host started', {
291
350
  port: endpoint.port,
292
351
  hosts: endpoint.hosts,
@@ -304,7 +363,12 @@ export async function startViteHost(logger, root = process.cwd()) {
304
363
  await server.close();
305
364
  }
306
365
  finally {
307
- await endpoint.close();
366
+ try {
367
+ await cache.release();
368
+ }
369
+ finally {
370
+ await endpoint.close();
371
+ }
308
372
  }
309
373
  })()),
310
374
  };
@@ -339,6 +403,21 @@ function hostUrl(url) {
339
403
  throw new InertiaError('VITE_DEV_UNAVAILABLE', 'DOKEBI_VITE_DEV_URL must be http://127.0.0.1:<port> (a loopback address and port)');
340
404
  return parsed;
341
405
  }
406
+ /**
407
+ * Destroys a request to the host without leaving a listener on a pooled connection. Node 24.15
408
+ * (`_http_client` onSocket, onSocketNT) adds the connection's 'error' listener when the agent
409
+ * hands it to a request and, when that request was destroyed while it waited in the agent's
410
+ * queue, puts the connection back in the pool with the listener still on it: one more per such
411
+ * request for as long as the keep-alive connection lives (audit r6: MaxListenersExceededWarning
412
+ * after renders cut at their deadline). A request without its connection yet is destroyed once
413
+ * it has one, which closes that connection instead.
414
+ */
415
+ export function destroyRequest(outgoing, error) {
416
+ if (outgoing.socket || outgoing.destroyed)
417
+ outgoing.destroy(error);
418
+ else
419
+ outgoing.once('socket', () => outgoing.destroy(error));
420
+ }
342
421
  /**
343
422
  * Connects this process to a Vite host and checks that it answers with the key. Requests share
344
423
  * keep-alive connections; `close` ends them (the host keeps running).
@@ -348,13 +427,16 @@ export async function connectViteDev(logger, url, key) {
348
427
  const host = target.hostname.replace(/^\[(.*)\]$/, '$1');
349
428
  const port = Number(target.port);
350
429
  const agent = new Agent({ keepAlive: true, maxSockets: 64 });
351
- const call = (method, path, body) => new Promise((resolve, reject) => {
430
+ // Server renders on connections of their own: renders that wait (a slow or hung page) never
431
+ // hold the connections the browser's module requests are forwarded on.
432
+ const renders = new Agent({ keepAlive: true, maxSockets: 64 });
433
+ const call = (method, path, body, through = agent, timeoutMs) => new Promise((resolve, reject) => {
352
434
  const outgoing = httpRequest({
353
435
  host,
354
436
  port,
355
437
  method,
356
438
  path,
357
- agent,
439
+ agent: through,
358
440
  headers: {
359
441
  [VITE_HOST_KEY_HEADER]: key,
360
442
  ...(body === undefined
@@ -374,6 +456,15 @@ export async function connectViteDev(logger, url, key) {
374
456
  answer.once('error', reject);
375
457
  });
376
458
  outgoing.once('error', reject);
459
+ if (timeoutMs !== undefined) {
460
+ const timer = setTimeout(() => {
461
+ const error = new InertiaError('SSR_TIMEOUT', `The Vite dev host did not finish a server render within ${timeoutMs} ms (ssr.timeoutMs)`, { data: { timeoutMs } });
462
+ // Settled now, also for a request still waiting for a connection (destroyRequest).
463
+ reject(error);
464
+ destroyRequest(outgoing, error);
465
+ }, timeoutMs);
466
+ outgoing.once('close', () => clearTimeout(timer));
467
+ }
377
468
  outgoing.end(body);
378
469
  });
379
470
  try {
@@ -383,12 +474,57 @@ export async function connectViteDev(logger, url, key) {
383
474
  }
384
475
  catch (cause) {
385
476
  agent.destroy();
477
+ renders.destroy();
386
478
  if (cause instanceof InertiaError)
387
479
  throw cause;
388
480
  throw new InertiaError('VITE_DEV_UNAVAILABLE', `The Vite dev host at ${target.origin} cannot be reached`, { cause });
389
481
  }
390
482
  logger.log('debug', 'Vite dev host connected', { url: target.origin });
483
+ /** HMR sockets joined to the host, ended by close(). */
484
+ const joined = new Set();
391
485
  return {
486
+ upgrade(request, socket, head, report) {
487
+ const upstream = connectNet({ host, port });
488
+ joined.add(socket);
489
+ joined.add(upstream);
490
+ let ended = false;
491
+ const end = () => {
492
+ if (ended)
493
+ return;
494
+ ended = true;
495
+ joined.delete(socket);
496
+ joined.delete(upstream);
497
+ socket.destroy();
498
+ upstream.destroy();
499
+ };
500
+ upstream.once('connect', () => {
501
+ // The handshake as the browser sent it (rawHeaders keeps names, order and repeats; Node
502
+ // refused any line break in them): Vite checks the token and the subprotocol itself.
503
+ const lines = [`${request.method ?? 'GET'} ${request.url ?? '/'} HTTP/1.1`];
504
+ const raw = request.rawHeaders;
505
+ for (let index = 0; index + 1 < raw.length; index += 2)
506
+ lines.push(`${raw[index]}: ${raw[index + 1]}`);
507
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
508
+ if (head.length)
509
+ upstream.write(head);
510
+ socket.pipe(upstream);
511
+ upstream.pipe(socket);
512
+ });
513
+ upstream.once('error', (error) => {
514
+ // The host is gone or refused: the browser's socket ends (its client retries).
515
+ if (!ended)
516
+ report(error);
517
+ end();
518
+ });
519
+ socket.once('error', (error) => {
520
+ // The browser or tunnel closed the connection: no fault.
521
+ if (logger.isEnabled('debug'))
522
+ logger.log('debug', 'Vite HMR socket ended by the browser', { code: error.code });
523
+ end();
524
+ });
525
+ socket.once('close', end);
526
+ upstream.once('close', end);
527
+ },
392
528
  forward(request, response, report) {
393
529
  const headers = { ...request.headers };
394
530
  for (const name of HOP_BY_HOP)
@@ -399,7 +535,11 @@ export async function connectViteDev(logger, url, key) {
399
535
  const out = { ...answer.headers };
400
536
  for (const name of HOP_BY_HOP)
401
537
  delete out[name];
402
- response.writeHead(answer.statusCode ?? 502, out);
538
+ // The reason too: Vite's 504s say which (`Outdated Optimize Dep`, D-0128).
539
+ if (answer.statusMessage)
540
+ response.writeHead(answer.statusCode ?? 502, answer.statusMessage, out);
541
+ else
542
+ response.writeHead(answer.statusCode ?? 502, out);
403
543
  answer.pipe(response);
404
544
  answer.once('error', (error) => {
405
545
  if (!aborted)
@@ -428,12 +568,12 @@ export async function connectViteDev(logger, url, key) {
428
568
  if (response.writableFinished)
429
569
  return;
430
570
  aborted = true;
431
- outgoing.destroy();
571
+ destroyRequest(outgoing);
432
572
  });
433
573
  outgoing.end();
434
574
  },
435
- async render(entry, page) {
436
- const { status, text } = await call('POST', SSR_PATH, JSON.stringify({ entry, page }));
575
+ async render(entry, page, timeoutMs) {
576
+ const { status, text } = await call('POST', SSR_PATH, JSON.stringify({ entry, page }), renders, timeoutMs);
437
577
  let answer;
438
578
  try {
439
579
  answer = JSON.parse(text);
@@ -447,6 +587,11 @@ export async function connectViteDev(logger, url, key) {
447
587
  },
448
588
  close: async () => {
449
589
  agent.destroy();
590
+ renders.destroy();
591
+ // Open HMR sockets would keep the process (and a draining worker) alive.
592
+ for (const socket of [...joined])
593
+ socket.destroy();
594
+ joined.clear();
450
595
  logger.log('debug', 'Vite dev host disconnected', { url: target.origin });
451
596
  },
452
597
  };
@@ -478,6 +623,7 @@ export async function openViteDev(logger, environment = process.env, root = proc
478
623
  return {
479
624
  forward: client.forward,
480
625
  render: client.render,
626
+ upgrade: client.upgrade,
481
627
  close: async () => {
482
628
  try {
483
629
  await client.close();
@@ -0,0 +1,129 @@
1
+ import type { Logger } from '@dokebi/core';
2
+ /**
3
+ * Files the dev server never serves, whatever the app's config says (Vite globs: a pattern
4
+ * without `/` matches the name in every folder; letter case is ignored). Vite's own defaults are
5
+ * included, because a deny list replaces them.
6
+ */
7
+ export declare const DEV_FILES_DENY: readonly string[];
8
+ /**
9
+ * Header the app process sets on every request it forwards to the Vite host: `loopback` or
10
+ * `remote`, from the browser's socket peer, the forwarding headers and the host name it judged
11
+ * (isRemoteRequest, §12). Any value the browser sent is replaced. Without it (Vite run on its
12
+ * own) the Vite host judges its own socket, the forwarding headers and the Host header.
13
+ */
14
+ export declare const DEV_CLIENT_HEADER = "x-dokebi-dev-client";
15
+ /** The whole 403 a remote host gets: no path, no allow list. */
16
+ export declare const FORBIDDEN_SHORT = "403 Forbidden\n";
17
+ /** Whether a host name (no port, no brackets) is this machine: a remote one gets no details. */
18
+ export declare function isLoopbackHost(name: string): boolean;
19
+ /**
20
+ * Headers a proxy or tunnel adds (D-0129 §12). Any of them makes a request remote: a tunnel
21
+ * client runs on this machine (its socket is loopback) and may rewrite Host to localhost, so
22
+ * neither the socket nor the Host header alone tells a local browser from a forwarded one.
23
+ */
24
+ export declare const FORWARDING_HEADERS: readonly string[];
25
+ /** Whether a socket peer address is this machine (127.0.0.0/8, ::1, ::ffff:127.x). */
26
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
27
+ /**
28
+ * Whether a request comes from another machine (a `devHosts` tunnel, a proxy, a LAN address).
29
+ * Local, and so shown details, only when the socket peer is loopback, no forwarding header is
30
+ * present, the Host is a loopback name and the app process (DEV_CLIENT_HEADER, when set) said
31
+ * loopback too. An unknown peer counts as remote.
32
+ */
33
+ export declare function isRemoteRequest(headers: Readonly<Record<string, string | string[] | undefined>>, peer: string | undefined): boolean;
34
+ /**
35
+ * A Vite deny glob as a regular expression on an absolute `/` path, matched like Vite matches it
36
+ * (picomatch with `dot` and `nocase`; a pattern without `/` matches in every folder). Supports
37
+ * `*`, `**`, `?` and `{a,b}`, which is all DEV_FILES_DENY and ordinary app entries use.
38
+ */
39
+ export declare function denyPattern(glob: string): RegExp;
40
+ /** The part of Vite's resolved config this policy reads (and repairs). */
41
+ export interface ResolvedFilesShape {
42
+ readonly command: 'build' | 'serve';
43
+ readonly base: string;
44
+ readonly root?: string;
45
+ readonly cacheDir?: string;
46
+ readonly plugins?: readonly {
47
+ readonly name?: string;
48
+ }[];
49
+ readonly server?: {
50
+ readonly fs?: {
51
+ strict: boolean;
52
+ allow: string[];
53
+ deny: string[];
54
+ };
55
+ };
56
+ /** Vite's set of files the served modules import (internal, read when present). */
57
+ readonly safeModulePaths?: ReadonlySet<string>;
58
+ }
59
+ /** The request and response of a connect middleware, as far as the policy needs them. */
60
+ export interface DevRequestShape {
61
+ url?: string;
62
+ method?: string;
63
+ headers?: Record<string, string | string[] | undefined>;
64
+ /** The socket: its peer address is part of the loopback judgement (§12). */
65
+ readonly socket?: {
66
+ readonly remoteAddress?: string;
67
+ };
68
+ }
69
+ export interface DevResponseShape {
70
+ statusCode: number;
71
+ readonly headersSent?: boolean;
72
+ setHeader(name: string, value: string): unknown;
73
+ removeHeader?(name: string): unknown;
74
+ write?(chunk: unknown, ...rest: unknown[]): boolean;
75
+ end(body?: string): unknown;
76
+ }
77
+ export type DevMiddleware = (request: DevRequestShape, response: DevResponseShape, next: (error?: unknown) => void) => void;
78
+ /** What the `config` hook adds for the policy (Vite merges it into the app's config). */
79
+ export interface DevFilesPatch {
80
+ readonly server: {
81
+ readonly fs: {
82
+ readonly strict: true;
83
+ readonly allow: string[];
84
+ readonly deny: string[];
85
+ };
86
+ };
87
+ }
88
+ export interface DevFilesOptions {
89
+ /** Further files or folders the browser loads (relative to the Vite root, or absolute). */
90
+ readonly allow?: readonly string[];
91
+ /** Further Vite globs the dev server never serves (added to DEV_FILES_DENY). */
92
+ readonly deny?: readonly string[];
93
+ }
94
+ /**
95
+ * The dev file policy of one Vite config (one plugin instance). `owner` `dev-host` is the copy the
96
+ * Vite host of `dokebi dev` adds: it stays inactive when the app's config has `dokebiInertia()`.
97
+ */
98
+ export declare class DevFilesPolicy {
99
+ #private;
100
+ constructor(logger: Logger, options: DevFilesOptions, owner: 'dokebi-inertia' | 'dev-host');
101
+ /** The deny entries this policy always keeps (DEV_FILES_DENY and the plugin's `deny`). */
102
+ get required(): string[];
103
+ /** The `config` hook (dev server only): the policy as a patch, and warnings for a weaker config. */
104
+ config(raw: unknown, command: 'build' | 'serve'): DevFilesPatch | null;
105
+ /** The `configResolved` hook: repairs what a later plugin loosened and keeps the live config. */
106
+ configResolved(config: ResolvedFilesShape): void;
107
+ /** The middleware to add before Vite's own (undefined when this copy is inactive). */
108
+ middleware(): DevMiddleware | undefined;
109
+ }
110
+ /** The Vite plugin of the policy alone (what the Vite host of `dokebi dev` adds). */
111
+ export interface DokebiDevFilesPlugin {
112
+ readonly name: 'dokebi-inertia:dev-files';
113
+ readonly enforce: 'pre';
114
+ config(config: unknown, env: {
115
+ command: 'build' | 'serve';
116
+ }): DevFilesPatch | null;
117
+ configResolved(config: ResolvedFilesShape): void;
118
+ configureServer(server: {
119
+ readonly middlewares: {
120
+ use(handler: DevMiddleware): unknown;
121
+ };
122
+ }): void;
123
+ }
124
+ /**
125
+ * The dev file policy as a plugin of its own, for a vite.config without `dokebiInertia()` (which
126
+ * carries the same policy): the Vite host of `dokebi dev` adds it; it stays inactive next to
127
+ * `dokebiInertia()`.
128
+ */
129
+ export declare function dokebiDevFiles(logger: Logger, options?: DevFilesOptions): DokebiDevFilesPlugin;