@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/AI.md +138 -25
- package/README.md +23 -7
- package/dist/boot-fallback.d.ts +61 -0
- package/dist/boot-fallback.js +131 -0
- package/dist/built-files.d.ts +9 -0
- package/dist/built-files.js +44 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -1
- package/dist/i18n-store.d.ts +64 -0
- package/dist/i18n-store.js +368 -0
- package/dist/i18n.d.ts +216 -0
- package/dist/i18n.js +901 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +458 -61
- package/dist/options.d.ts +28 -0
- package/dist/options.js +97 -1
- package/dist/prune.d.ts +30 -0
- package/dist/prune.js +159 -0
- package/dist/responder.d.ts +14 -4
- package/dist/responder.js +166 -51
- package/dist/vite-deps.d.ts +76 -0
- package/dist/vite-deps.js +249 -0
- package/dist/vite-dev.d.ts +42 -4
- package/dist/vite-dev.js +162 -16
- package/dist/vite-files.d.ts +129 -0
- package/dist/vite-files.js +633 -0
- package/dist/vite-host.js +67 -10
- package/dist/vite-plugin.d.ts +69 -0
- package/dist/vite-plugin.js +173 -0
- package/dist/vite-watch.d.ts +44 -0
- package/dist/vite-watch.js +176 -0
- package/options.schema.json +127 -1
- package/package.json +38 -11
|
@@ -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
|
+
}
|
package/dist/vite-dev.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
279
|
-
//
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|