@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.
@@ -0,0 +1,249 @@
1
+ // The pre-bundled dependencies of the Vite dev host (D-0128). Vite keeps what its dependency
2
+ // optimizer produced in memory (each dependency's file under node_modules/.vite/deps and the
3
+ // `?v=` hash the browser is given) and trusts node_modules/.vite/deps/_metadata.json at start when
4
+ // its lockfile and config hashes match; it never looks at the files again. A cache changed under
5
+ // it (another Vite or `dokebi dev` on the same folder re-optimizing at the same time, a reinstall)
6
+ // leaves metadata that names files that are gone. Vite answers a gone file requested with `?v=` as
7
+ // 504 "Outdated Optimize Dep", and nothing ever heals that: the host outlives app restarts (D-0045
8
+ // addendum 3) and the next host trusts the same metadata.
9
+ //
10
+ // So the host compares its optimizer's state with the folder when it starts, whenever an app
11
+ // process connects (every app or worker restart) and after a 504, and re-optimizes when they
12
+ // differ: it removes the deps folder and restarts its Vite (a new optimizer, a new module graph;
13
+ // open pages reconnect HMR and reload, a new page load gets the new hashes). A repair at most once
14
+ // per `devDepsRepairIntervalMs`; a cache that keeps changing faster is reported instead.
15
+ import { constants } from 'node:fs';
16
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import { dirname, join } from 'node:path';
18
+ import { diagnostics } from '@dokebi/core/libraries';
19
+ const { describeError } = diagnostics;
20
+ /** Vite's metadata file in the deps folder. */
21
+ const METADATA_FILE = '_metadata.json';
22
+ /**
23
+ * The marker of the host that uses a cache folder (its pid), in Vite's cacheDir: under `.dokebi/`,
24
+ * which the dev server never serves (the `.dokebi` rule of DEV_FILES_DENY, D-0129).
25
+ */
26
+ const HOST_MARKER = join('.dokebi', 'vite-host.json');
27
+ /** Missing files named in a log record at most. */
28
+ const SAMPLE = 5;
29
+ async function present(file) {
30
+ try {
31
+ await access(file, constants.F_OK);
32
+ return true;
33
+ }
34
+ catch (error) {
35
+ if (error.code === 'ENOENT')
36
+ return false;
37
+ throw error;
38
+ }
39
+ }
40
+ /** `process.kill(pid, 0)`: whether the process exists (EPERM: it exists, owned by another user). */
41
+ function alive(pid) {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ return error.code === 'EPERM';
48
+ }
49
+ }
50
+ /**
51
+ * The host's check of its dependency cache. `ensure` re-optimizes when the folder no longer holds
52
+ * what the optimizer serves; one check or repair runs at a time (callers share it).
53
+ */
54
+ export class DependencyCache {
55
+ #logger;
56
+ #server;
57
+ #intervalMs;
58
+ #now;
59
+ #running;
60
+ #repairedAt = Number.NEGATIVE_INFINITY;
61
+ #repairs = 0;
62
+ #skippedWarnedAt = Number.NEGATIVE_INFINITY;
63
+ #skipped = 0;
64
+ constructor(logger, server, options) {
65
+ this.#logger = logger;
66
+ this.#server = server;
67
+ this.#intervalMs = options.repairIntervalMs;
68
+ this.#now = options.now ?? Date.now;
69
+ }
70
+ /** Repairs so far. */
71
+ get repairs() {
72
+ return this.#repairs;
73
+ }
74
+ /** The deps folder of the browser environment (`<cacheDir>/deps`, Vite's default name). */
75
+ get folder() {
76
+ return join(this.#server.config.cacheDir, 'deps');
77
+ }
78
+ /**
79
+ * Compares the optimizer's state with the folder: every served file is there, and the
80
+ * metadata on disk is the one this optimizer wrote or loaded (same hash and file hashes).
81
+ */
82
+ async inspect() {
83
+ const optimizer = this.#server.environments?.client?.depsOptimizer;
84
+ if (!optimizer)
85
+ return { drift: undefined, files: 0, missing: [] };
86
+ const { metadata } = optimizer;
87
+ const served = [...Object.values(metadata.optimized), ...Object.values(metadata.chunks)];
88
+ // Nothing served from the folder yet (a first optimization still running): nothing to lose.
89
+ if (served.length === 0)
90
+ return { drift: undefined, files: 0, missing: [], metadata };
91
+ const found = await Promise.all(served.map((info) => present(info.file)));
92
+ const missing = served.filter((_, index) => !found[index]).map((info) => info.file);
93
+ if (missing.length)
94
+ return { drift: 'missing', files: served.length, missing, metadata };
95
+ const folder = dirname(served[0]?.file ?? join(this.folder, METADATA_FILE));
96
+ let text;
97
+ try {
98
+ text = await readFile(join(folder, METADATA_FILE), 'utf8');
99
+ }
100
+ catch (error) {
101
+ // The files are there and Vite never reads the metadata again while it runs; the next
102
+ // host start finds no metadata and optimizes afresh.
103
+ if (error.code === 'ENOENT')
104
+ return { drift: undefined, files: served.length, missing, metadata };
105
+ throw error;
106
+ }
107
+ let disk;
108
+ try {
109
+ disk = JSON.parse(text);
110
+ }
111
+ catch (error) {
112
+ if (this.#logger.isEnabled('debug'))
113
+ this.#logger.log('debug', 'Vite dev host found unreadable dependency metadata', {
114
+ folder,
115
+ error: describeError(error),
116
+ });
117
+ return { drift: 'rewritten', files: served.length, missing, metadata };
118
+ }
119
+ const same = disk.hash === metadata.hash &&
120
+ Object.entries(metadata.optimized).every(([id, info]) => disk.optimized?.[id]?.fileHash === info.fileHash);
121
+ return { drift: same ? undefined : 'rewritten', files: served.length, missing, metadata };
122
+ }
123
+ /**
124
+ * Checks the cache and re-optimizes when it drifted; resolves true after a repair. A check or
125
+ * repair already running is shared. Failures reject (the caller logs them).
126
+ */
127
+ ensure(trigger) {
128
+ this.#running ??= this.#ensure(trigger).finally(() => {
129
+ this.#running = undefined;
130
+ });
131
+ return this.#running;
132
+ }
133
+ async #ensure(trigger) {
134
+ let state = await this.inspect();
135
+ // Vite commits a new optimization by renaming folders and then replacing its metadata; a
136
+ // check between the two saw the old metadata against the new files. Compare again.
137
+ if (state.drift !== undefined &&
138
+ state.metadata !== this.#server.environments?.client?.depsOptimizer?.metadata) {
139
+ if (this.#logger.isEnabled('debug'))
140
+ this.#logger.log('debug', 'Vite dev host compares its dependency cache again', {
141
+ trigger,
142
+ drift: state.drift,
143
+ });
144
+ state = await this.inspect();
145
+ }
146
+ if (state.drift === undefined) {
147
+ if (this.#logger.isEnabled('debug'))
148
+ this.#logger.log('debug', 'Vite dev host dependency cache matches', {
149
+ trigger,
150
+ files: state.files,
151
+ });
152
+ return false;
153
+ }
154
+ const now = this.#now();
155
+ if (now - this.#repairedAt < this.#intervalMs) {
156
+ // Something rewrites the folder faster than the host repairs it: say so, once a window.
157
+ this.#skipped++;
158
+ if (now - this.#skippedWarnedAt >= this.#intervalMs) {
159
+ this.#logger.log('warn', 'Vite dev host dependency cache keeps changing; is another dokebi dev or Vite using this project?', {
160
+ code: 'INERTIA_VITE_DEPS_CHANGING',
161
+ trigger,
162
+ drift: state.drift,
163
+ folder: this.folder,
164
+ repairIntervalMs: this.#intervalMs,
165
+ repeated: this.#skipped,
166
+ });
167
+ this.#skippedWarnedAt = now;
168
+ this.#skipped = 0;
169
+ }
170
+ return false;
171
+ }
172
+ this.#repairedAt = now;
173
+ this.#repairs++;
174
+ this.#logger.log('warn', 'Vite dev host found its pre-bundled dependencies changed on disk; re-optimizing', {
175
+ code: 'INERTIA_VITE_DEPS_STALE',
176
+ trigger,
177
+ drift: state.drift,
178
+ folder: this.folder,
179
+ files: state.files,
180
+ missing: state.missing.length,
181
+ sample: state.missing.slice(0, SAMPLE),
182
+ });
183
+ const begin = performance.now();
184
+ // Vite reads no metadata from a folder that is not there: the restarted optimizer starts
185
+ // afresh (the folder is rewritten, the browser gets new `?v=` hashes). restart(true) would
186
+ // do the same but keep forcing a re-optimization on every later restart of this Vite.
187
+ await rm(this.folder, { recursive: true, force: true });
188
+ await this.#server.restart();
189
+ this.#logger.log('debug', 'Vite dev host re-optimized its dependencies', {
190
+ trigger,
191
+ ms: Math.round(performance.now() - begin),
192
+ repairs: this.#repairs,
193
+ });
194
+ return true;
195
+ }
196
+ /**
197
+ * Records this host in the cache folder and warns when another live host of `dokebi dev`
198
+ * recorded itself there: two optimizers on one folder can break each other's files.
199
+ */
200
+ async claim(pid = process.pid) {
201
+ const marker = join(this.#server.config.cacheDir, HOST_MARKER);
202
+ let other;
203
+ try {
204
+ const recorded = JSON.parse(await readFile(marker, 'utf8')).pid;
205
+ if (typeof recorded === 'number' && recorded !== pid && alive(recorded))
206
+ other = recorded;
207
+ }
208
+ catch (error) {
209
+ const code = error.code;
210
+ if (code !== 'ENOENT' && !(error instanceof SyntaxError))
211
+ throw error;
212
+ if (this.#logger.isEnabled('debug'))
213
+ this.#logger.log('debug', 'Vite dev host found no live host marker', {
214
+ marker,
215
+ reason: code ?? 'unreadable',
216
+ });
217
+ }
218
+ if (other !== undefined)
219
+ this.#logger.log('warn', 'Another dokebi dev Vite host uses this project; stop one, or their pre-bundled dependencies can break each other', { code: 'INERTIA_VITE_HOST_SHARED', pid: other, folder: this.#server.config.cacheDir });
220
+ await mkdir(dirname(marker), { recursive: true });
221
+ await writeFile(marker, `${JSON.stringify({ pid })}\n`);
222
+ this.#logger.log('debug', 'Vite dev host recorded itself in the cache folder', {
223
+ marker,
224
+ shared: other !== undefined,
225
+ });
226
+ }
227
+ /** Removes the marker when it is still this host's. */
228
+ async release(pid = process.pid) {
229
+ const marker = join(this.#server.config.cacheDir, HOST_MARKER);
230
+ let recorded;
231
+ try {
232
+ recorded = JSON.parse(await readFile(marker, 'utf8')).pid;
233
+ }
234
+ catch (error) {
235
+ const code = error.code;
236
+ if (code !== 'ENOENT' && !(error instanceof SyntaxError))
237
+ throw error;
238
+ // Gone or not a marker this host wrote: nothing of this host's to remove.
239
+ if (this.#logger.isEnabled('debug'))
240
+ this.#logger.log('debug', 'Vite dev host has no marker to remove', {
241
+ marker,
242
+ reason: code ?? 'unreadable',
243
+ });
244
+ return;
245
+ }
246
+ if (recorded === pid)
247
+ await rm(marker, { force: true });
248
+ }
249
+ }
@@ -1,4 +1,5 @@
1
- import { type IncomingMessage, type ServerResponse } from 'node:http';
1
+ import { type ClientRequest, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
2
3
  import type { Logger } from '@dokebi/core';
3
4
  export declare const viteDevRequested: () => boolean;
4
5
  /** The part of a Node response the dev route finishes. */
@@ -14,6 +15,15 @@ export interface DevResponse {
14
15
  export declare function viteFallthrough(response: DevResponse, error: unknown, report: (error: unknown) => void): void;
15
16
  /** Header that carries the host key; the host answers no HTTP request without it. */
16
17
  export declare const VITE_HOST_KEY_HEADER = "x-dokebi-vite-key";
18
+ /** Whether an upgrade request is Vite's client (its HMR or ping socket), by its subprotocol. */
19
+ export declare function isViteUpgrade(request: Pick<IncomingMessage, 'headers'>): boolean;
20
+ /** Default of `devDepsRepairIntervalMs` (D-0128). */
21
+ export declare const DEFAULT_DEPS_REPAIR_INTERVAL_MS = 10000;
22
+ /** Settings of the host from dokebi.yaml (`options.dokebi-template-inertia`). */
23
+ export interface ViteHostSettings {
24
+ /** Least time between two repairs of the pre-bundled dependencies in ms (D-0128). */
25
+ readonly depsRepairIntervalMs?: number;
26
+ }
17
27
  /** The one Vite of a development session and the loopback port it answers on. */
18
28
  export interface ViteHost {
19
29
  /** Where clients reach the host: `http://127.0.0.1:<port>`. */
@@ -31,8 +41,19 @@ export interface ViteHost {
31
41
  export interface ViteDev {
32
42
  /** Answers a dev route request from the host (status, headers and body as Vite sent them). */
33
43
  forward(request: IncomingMessage, response: ServerResponse, report: (error: unknown) => void): void;
34
- /** Loads `entry` (relative to the project root) through Vite and calls its `render(page)`. */
35
- render(entry: string, page: unknown): Promise<unknown>;
44
+ /**
45
+ * Loads `entry` (relative to the project root) through Vite and calls its `render(page)`. With
46
+ * `timeoutMs` (the scope's `ssr.timeoutMs`) the request to the host is cut at that deadline
47
+ * (`SSR_TIMEOUT`), so a render that never finishes does not keep a connection to the host.
48
+ */
49
+ render(entry: string, page: unknown, timeoutMs?: number): Promise<unknown>;
50
+ /**
51
+ * Joins an HMR WebSocket upgrade that reached the app's port (`<base>` with subprotocol
52
+ * `vite-hmr`) to the host: the browser's handshake goes on as it came, Vite checks its token
53
+ * and answers, then bytes flow both ways until either side closes. So the browser needs only
54
+ * the page's own origin (a tunnel, a proxy), never the host's loopback port.
55
+ */
56
+ upgrade(request: IncomingMessage, socket: Duplex, head: Buffer, report: (error: unknown) => void): void;
36
57
  /** Releases this process's connections (and its own host, when it started one). */
37
58
  close(): Promise<void>;
38
59
  }
@@ -47,8 +68,25 @@ export interface ViteDev {
47
68
  * - WebSocket upgrades of Vite's client (HMR): Vite answers them and keeps its token check
48
69
  * (cross-site WebSocket hijacking). @dokebi/websocket answers every upgrade on the app's port,
49
70
  * so HMR cannot share that one.
71
+ *
72
+ * Watcher events for files whose state did not change since Vite read them (FSEvents delivering
73
+ * writes from before the start, late) are dropped before Vite sees them (`vite-watch.ts`).
74
+ *
75
+ * The pre-bundled dependencies (node_modules/.vite/deps) are compared with what Vite serves at
76
+ * the start, whenever an app process connects (every app restart) and after a 504; when the
77
+ * folder changed under Vite, the host re-optimizes (`vite-deps.ts`, D-0128).
78
+ */
79
+ export declare function startViteHost(logger: Logger, root?: string, settings?: ViteHostSettings): Promise<ViteHost>;
80
+ /**
81
+ * Destroys a request to the host without leaving a listener on a pooled connection. Node 24.15
82
+ * (`_http_client` onSocket, onSocketNT) adds the connection's 'error' listener when the agent
83
+ * hands it to a request and, when that request was destroyed while it waited in the agent's
84
+ * queue, puts the connection back in the pool with the listener still on it: one more per such
85
+ * request for as long as the keep-alive connection lives (audit r6: MaxListenersExceededWarning
86
+ * after renders cut at their deadline). A request without its connection yet is destroyed once
87
+ * it has one, which closes that connection instead.
50
88
  */
51
- export declare function startViteHost(logger: Logger, root?: string): Promise<ViteHost>;
89
+ export declare function destroyRequest(outgoing: ClientRequest, error?: Error): void;
52
90
  /**
53
91
  * Connects this process to a Vite host and checks that it answers with the key. Requests share
54
92
  * keep-alive connections; `close` ends them (the host keeps running).
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();