@dokebi/template-inertia 0.2.0 → 0.4.1

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
@@ -1,15 +1,28 @@
1
1
  // Development mode (D-0045): with DOKEBI_VITE_DEV=1 (set by `dokebi dev`) pages load their code
2
- // from the project's Vite in middleware mode instead of a build, so a .vue edit reaches the
3
- // browser by hot module replacement. Vite is the project's dev dependency, found from the
4
- // working directory; this package does not depend on it.
2
+ // from the project's Vite instead of a build, so a .vue edit reaches the browser by hot module
3
+ // replacement. Vite is the project's dev dependency, found from the working directory; this
4
+ // package does not depend on it.
5
+ //
6
+ // One Vite per `dokebi dev` session (D-0045 addendum 3): the Vite host (`startViteHost`, run by
7
+ // `dokebi dev` through `vite-host.ts`) starts Vite in middleware mode behind a loopback HTTP server
8
+ // of its own. Every app process (every cluster worker) is a client (`connectViteDev`): its
9
+ // `<base>*` routes forward to the host, and server rendering asks the host. The browser's HMR
10
+ // socket goes straight to the host's port, so all workers advertise one port and one token, and
11
+ // dependencies are pre-bundled once. Without a host (`DOKEBI_VITE_DEV_URL` unset) the process
12
+ // starts a host of its own, as one process did before.
13
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
14
+ import { Agent, createServer as createHttpServer, request as httpRequest, } from 'node:http';
5
15
  import { createRequire } from 'node:module';
6
- import { createServer } from 'node:net';
16
+ import { createServer as createNetServer, } from 'node:net';
7
17
  import { dirname, join } from 'node:path';
8
18
  import { pathToFileURL } from 'node:url';
19
+ import { diagnostics } from '@dokebi/core/libraries';
20
+ import { InertiaError } from './errors.js';
21
+ const { describeError } = diagnostics;
9
22
  export const viteDevRequested = () => process.env.DOKEBI_VITE_DEV === '1';
10
23
  /**
11
- * The `next` of Vite's middlewares on a dev route: no error means Vite has nothing for the URL
12
- * (404); an error is reported (logged by the caller) and answered 500, never dropped (D-0047).
24
+ * The `next` of Vite's middlewares: no error means Vite has nothing for the URL (404); an error is
25
+ * reported (logged by the caller) and answered 500, never dropped (D-0047).
13
26
  */
14
27
  export function viteFallthrough(response, error, report) {
15
28
  if (error !== undefined && error !== null) {
@@ -21,37 +34,457 @@ export function viteFallthrough(response, error, report) {
21
34
  response.statusCode = 404;
22
35
  response.end();
23
36
  }
24
- function freePort() {
37
+ /** Header that carries the host key; the host answers no HTTP request without it. */
38
+ export const VITE_HOST_KEY_HEADER = 'x-dokebi-vite-key';
39
+ /** Host-only paths (outside every Vite base): a ping and server rendering. */
40
+ const HOST_PATH = '/__dokebi/vite-host';
41
+ const SSR_PATH = `${HOST_PATH}/ssr`;
42
+ /** Largest server-render request (the page JSON) the host reads. */
43
+ const SSR_BODY_MAX_BYTES = 32 * 1024 * 1024;
44
+ /** Loopback addresses of the host (a browser resolves `*.localhost` to either); never all. */
45
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
46
+ /** Tries for a port that is free on both loopback addresses. */
47
+ const PORT_ATTEMPTS = 8;
48
+ /** WebSocket subprotocols of Vite's client; other upgrades on the host port are closed. */
49
+ const VITE_PROTOCOLS = new Set(['vite-hmr', 'vite-ping']);
50
+ /** Hop-by-hop headers (RFC 9110 §7.6.1) that a forwarder does not pass on. */
51
+ const HOP_BY_HOP = [
52
+ 'connection',
53
+ 'keep-alive',
54
+ 'proxy-connection',
55
+ 'proxy-authenticate',
56
+ 'proxy-authorization',
57
+ 'te',
58
+ 'trailer',
59
+ 'transfer-encoding',
60
+ 'upgrade',
61
+ ];
62
+ function listenExclusive(host, port) {
25
63
  return new Promise((resolve, reject) => {
26
- const server = createServer();
27
- server.once('error', reject);
28
- server.listen(0, '127.0.0.1', () => {
29
- const address = server.address();
30
- server.close(() => typeof address === 'object' && address ? resolve(address.port) : reject(new Error('port')));
64
+ const listener = createNetServer();
65
+ listener.once('error', reject);
66
+ // exclusive: the process binds the port itself. Under node:cluster a shared listen goes
67
+ // through the primary, which would spread HMR sockets over workers (D-0045 addendum 2).
68
+ listener.listen({ host, port, exclusive: true }, () => {
69
+ listener.off('error', reject);
70
+ resolve(listener);
31
71
  });
32
72
  });
33
73
  }
34
- /**
35
- * Starts the project's Vite (its vite.config.*) in middleware mode. The HMR socket gets a free
36
- * port of its own: @dokebi/websocket answers every upgrade on the app's HTTP server and closes
37
- * the ones that are not its routes, so HMR cannot share that server.
38
- */
39
- export async function startViteDev(root = process.cwd()) {
40
- let entry;
74
+ function closeListener(listener) {
75
+ return new Promise((resolve, reject) => listener.close((error) => (error ? reject(error) : resolve())));
76
+ }
77
+ async function openLoopback(logger, handler) {
78
+ const listeners = [];
79
+ let port = 0;
80
+ for (let attempt = 1; listeners.length === 0; attempt++) {
81
+ const first = await listenExclusive(LOOPBACK_HOSTS[0], 0);
82
+ port = first.address().port;
83
+ try {
84
+ listeners.push(first, await listenExclusive(LOOPBACK_HOSTS[1], port));
85
+ }
86
+ catch (error) {
87
+ const code = error.code;
88
+ if (code === 'EADDRNOTAVAIL' || code === 'EAFNOSUPPORT') {
89
+ // No IPv6 loopback here: names that resolve to loopback reach 127.0.0.1.
90
+ logger.log('debug', 'Vite dev host listens on IPv4 loopback only', { port, code });
91
+ listeners.push(first);
92
+ }
93
+ else {
94
+ await closeListener(first);
95
+ if (code !== 'EADDRINUSE' || attempt >= PORT_ATTEMPTS)
96
+ throw error;
97
+ logger.log('debug', 'Vite dev host port is taken on ::1, trying another', {
98
+ port,
99
+ attempt,
100
+ });
101
+ }
102
+ }
103
+ }
104
+ const hosts = listeners.map((listener) => listener.address().address);
105
+ const server = createHttpServer(handler);
106
+ // Vite answers its own subprotocols on this server; another upgrade would stay open.
107
+ server.on('upgrade', (request, socket) => {
108
+ const protocol = request.headers['sec-websocket-protocol'];
109
+ if (protocol !== undefined && VITE_PROTOCOLS.has(protocol))
110
+ return;
111
+ if (logger.isEnabled('debug'))
112
+ logger.log('debug', 'Vite dev host closed an upgrade that is not Vite', {
113
+ protocol: String(protocol ?? '').slice(0, 64),
114
+ });
115
+ socket.destroy();
116
+ });
117
+ const sockets = new Set();
118
+ for (const listener of listeners) {
119
+ listener.on('connection', (socket) => {
120
+ sockets.add(socket);
121
+ socket.once('close', () => sockets.delete(socket));
122
+ server.emit('connection', socket);
123
+ });
124
+ listener.on('error', (error) => logger.log('error', 'Vite dev host listener failed', {
125
+ code: 'INERTIA_VITE_HOST_FAILED',
126
+ port,
127
+ error: describeError(error),
128
+ }));
129
+ }
130
+ logger.log('debug', 'Vite dev host listening', { port, hosts });
131
+ let closing;
132
+ return {
133
+ server,
134
+ port,
135
+ hosts,
136
+ close: () => (closing ??= (async () => {
137
+ const open = sockets.size;
138
+ for (const socket of sockets)
139
+ socket.destroy();
140
+ await Promise.all(listeners.map(closeListener));
141
+ logger.log('debug', 'Vite dev host closed', { port, connections: open });
142
+ })()),
143
+ };
144
+ }
145
+ function keyMatches(expected, presented) {
146
+ if (typeof presented !== 'string')
147
+ return false;
148
+ const given = Buffer.from(presented);
149
+ return given.length === expected.length && timingSafeEqual(given, expected);
150
+ }
151
+ function sendJson(response, status, value) {
152
+ const body = JSON.stringify(value);
153
+ response.writeHead(status, {
154
+ 'content-type': 'application/json; charset=utf-8',
155
+ 'content-length': Buffer.byteLength(body),
156
+ 'cache-control': 'no-store',
157
+ });
158
+ response.end(body);
159
+ }
160
+ /** An error as JSON for the client process (it rebuilds and reports it, D-0047). */
161
+ function errorJson(error) {
162
+ if (!(error instanceof Error))
163
+ return { name: 'Error', message: String(error) };
164
+ const code = error.code;
165
+ return {
166
+ name: error.name,
167
+ message: error.message,
168
+ ...(error.stack ? { stack: error.stack } : {}),
169
+ ...(typeof code === 'string' ? { code } : {}),
170
+ };
171
+ }
172
+ function readBody(request, limit) {
173
+ return new Promise((resolve, reject) => {
174
+ const chunks = [];
175
+ let size = 0;
176
+ request.on('data', (chunk) => {
177
+ size += chunk.length;
178
+ if (size > limit) {
179
+ reject(new RangeError(`Server-render request is larger than ${limit} bytes`));
180
+ request.destroy();
181
+ return;
182
+ }
183
+ chunks.push(chunk);
184
+ });
185
+ request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
186
+ request.once('error', reject);
187
+ });
188
+ }
189
+ function findVite(root) {
41
190
  try {
42
191
  const require = createRequire(join(root, 'package.json'));
43
- entry = join(dirname(require.resolve('vite/package.json')), 'dist/node/index.js');
192
+ return join(dirname(require.resolve('vite/package.json')), 'dist/node/index.js');
44
193
  }
45
194
  catch (cause) {
46
- throw new Error('DOKEBI_VITE_DEV=1 needs Vite in the project (npm install --save-dev vite)', {
47
- cause,
48
- });
195
+ throw new InertiaError('VITE_DEV_MISSING', 'DOKEBI_VITE_DEV=1 needs Vite in the project (npm install --save-dev vite)', { cause });
49
196
  }
197
+ }
198
+ /**
199
+ * Starts the project's Vite (its vite.config.*) in middleware mode behind a loopback HTTP server
200
+ * of its own (127.0.0.1 and ::1 on one free port, `exclusive` so a cluster worker binds it
201
+ * itself). On that port:
202
+ * - HTTP requests with the key: Vite's middlewares (modules, pre-bundled dependencies,
203
+ * `@vite/client`), a ping (`/__dokebi/vite-host`) and server rendering
204
+ * (`POST /__dokebi/vite-host/ssr`). Without the key: 403 (a page cannot read sources through
205
+ * DNS rebinding; app processes check page hosts before forwarding).
206
+ * - WebSocket upgrades of Vite's client (HMR): Vite answers them and keeps its token check
207
+ * (cross-site WebSocket hijacking). @dokebi/websocket answers every upgrade on the app's port,
208
+ * so HMR cannot share that one.
209
+ */
210
+ export async function startViteHost(logger, root = process.cwd()) {
211
+ const begin = performance.now();
212
+ const entry = findVite(root);
50
213
  const vite = (await import(pathToFileURL(entry).href));
51
- return vite.createServer({
214
+ const key = randomBytes(24).toString('base64url');
215
+ const expected = Buffer.from(key);
216
+ let started;
217
+ const report = (operation) => (error) => logger.log('error', 'Vite dev host request failed', {
218
+ code: 'INERTIA_VITE_HOST_FAILED',
219
+ operation,
220
+ error: describeError(error),
221
+ });
222
+ const ssr = async (request, response, server) => {
223
+ try {
224
+ const { entry: source, page } = JSON.parse(await readBody(request, SSR_BODY_MAX_BYTES));
225
+ if (typeof source !== 'string' || !source || source.includes('..') || source.startsWith('/'))
226
+ throw new TypeError('Server-render entry must be a path relative to the project root');
227
+ const module = await server.ssrLoadModule(`/${source}`);
228
+ if (typeof module.render !== 'function')
229
+ throw new InertiaError('SSR_BUNDLE_INVALID', `Inertia SSR entry ${source} does not export render(page)`);
230
+ sendJson(response, 200, { result: await module.render(page) });
231
+ }
232
+ catch (error) {
233
+ // Returned to the app process, which counts and logs it (`Inertia SSR render failed`).
234
+ if (logger.isEnabled('debug'))
235
+ logger.log('debug', 'Vite dev host returned a server-render failure', {
236
+ error: describeError(error),
237
+ });
238
+ if (response.headersSent)
239
+ response.destroy(error);
240
+ else
241
+ sendJson(response, 500, { error: errorJson(error) });
242
+ }
243
+ };
244
+ const endpoint = await openLoopback(logger, (request, response) => {
245
+ if (!keyMatches(expected, request.headers[VITE_HOST_KEY_HEADER])) {
246
+ if (logger.isEnabled('debug'))
247
+ logger.log('debug', 'Vite dev host refused a request without its key', {
248
+ method: request.method,
249
+ path: String(request.url ?? '').slice(0, 200),
250
+ });
251
+ response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
252
+ response.end('The Vite dev host answers the app processes of dokebi dev only.');
253
+ return;
254
+ }
255
+ const server = started;
256
+ if (!server) {
257
+ response.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
258
+ response.end('Vite is starting.');
259
+ return;
260
+ }
261
+ 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')
265
+ void ssr(request, response, server);
266
+ else
267
+ server.middlewares(request, response, (error) => viteFallthrough(response, error, report('vite')));
268
+ });
269
+ try {
270
+ started = await vite.createServer({
271
+ root,
272
+ appType: 'custom',
273
+ server: {
274
+ middlewareMode: true,
275
+ // Hosts are checked by the dev routes of the plugin (see index.ts) and HTTP needs the key,
276
+ // so Vite's host check is off. The HMR socket keeps Vite's token check.
277
+ 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 },
281
+ },
282
+ });
283
+ }
284
+ catch (error) {
285
+ // The start failure goes to the caller; the port opened for it must not outlive it.
286
+ await endpoint.close();
287
+ throw error;
288
+ }
289
+ const server = started;
290
+ logger.log('debug', 'Vite dev host started', {
291
+ port: endpoint.port,
292
+ hosts: endpoint.hosts,
52
293
  root,
53
- appType: 'custom',
54
- // Hosts are checked by the dev routes of the plugin (see index.ts), not by Vite.
55
- server: { middlewareMode: true, allowedHosts: true, hmr: { port: await freePort() } },
294
+ ms: Math.round(performance.now() - begin),
295
+ });
296
+ let closing;
297
+ return {
298
+ url: `http://127.0.0.1:${endpoint.port}`,
299
+ key,
300
+ port: endpoint.port,
301
+ hosts: endpoint.hosts,
302
+ close: () => (closing ??= (async () => {
303
+ try {
304
+ await server.close();
305
+ }
306
+ finally {
307
+ await endpoint.close();
308
+ }
309
+ })()),
310
+ };
311
+ }
312
+ /** Rebuilds an error the host returned, keeping its name, message, stack and known code. */
313
+ function remoteError(info) {
314
+ const message = typeof info.message === 'string' ? info.message : 'Vite dev host failed';
315
+ if (info.code === 'SSR_BUNDLE_INVALID')
316
+ return new InertiaError('SSR_BUNDLE_INVALID', message);
317
+ const error = new Error(message);
318
+ if (typeof info.name === 'string')
319
+ error.name = info.name;
320
+ if (typeof info.stack === 'string')
321
+ error.stack = info.stack;
322
+ if (typeof info.code === 'string')
323
+ Object.assign(error, { code: info.code });
324
+ return error;
325
+ }
326
+ function hostUrl(url) {
327
+ let parsed;
328
+ try {
329
+ parsed = new URL(url);
330
+ }
331
+ catch (cause) {
332
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', 'DOKEBI_VITE_DEV_URL is not a URL', { cause });
333
+ }
334
+ // The key goes along with every request: only to this machine, never over a network.
335
+ if (parsed.protocol !== 'http:' ||
336
+ !['127.0.0.1', '[::1]', 'localhost'].includes(parsed.hostname) ||
337
+ !parsed.port ||
338
+ parsed.pathname !== '/')
339
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', 'DOKEBI_VITE_DEV_URL must be http://127.0.0.1:<port> (a loopback address and port)');
340
+ return parsed;
341
+ }
342
+ /**
343
+ * Connects this process to a Vite host and checks that it answers with the key. Requests share
344
+ * keep-alive connections; `close` ends them (the host keeps running).
345
+ */
346
+ export async function connectViteDev(logger, url, key) {
347
+ const target = hostUrl(url);
348
+ const host = target.hostname.replace(/^\[(.*)\]$/, '$1');
349
+ const port = Number(target.port);
350
+ const agent = new Agent({ keepAlive: true, maxSockets: 64 });
351
+ const call = (method, path, body) => new Promise((resolve, reject) => {
352
+ const outgoing = httpRequest({
353
+ host,
354
+ port,
355
+ method,
356
+ path,
357
+ agent,
358
+ headers: {
359
+ [VITE_HOST_KEY_HEADER]: key,
360
+ ...(body === undefined
361
+ ? {}
362
+ : {
363
+ 'content-type': 'application/json',
364
+ 'content-length': Buffer.byteLength(body),
365
+ }),
366
+ },
367
+ }, (answer) => {
368
+ const chunks = [];
369
+ answer.on('data', (chunk) => chunks.push(chunk));
370
+ answer.once('end', () => resolve({
371
+ status: answer.statusCode ?? 0,
372
+ text: Buffer.concat(chunks).toString('utf8'),
373
+ }));
374
+ answer.once('error', reject);
375
+ });
376
+ outgoing.once('error', reject);
377
+ outgoing.end(body);
56
378
  });
379
+ try {
380
+ const { status } = await call('GET', HOST_PATH);
381
+ if (status !== 200)
382
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', `The Vite dev host at ${target.origin} answered ${status} (wrong DOKEBI_VITE_DEV_KEY?)`);
383
+ }
384
+ catch (cause) {
385
+ agent.destroy();
386
+ if (cause instanceof InertiaError)
387
+ throw cause;
388
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', `The Vite dev host at ${target.origin} cannot be reached`, { cause });
389
+ }
390
+ logger.log('debug', 'Vite dev host connected', { url: target.origin });
391
+ return {
392
+ forward(request, response, report) {
393
+ const headers = { ...request.headers };
394
+ for (const name of HOP_BY_HOP)
395
+ delete headers[name];
396
+ headers[VITE_HOST_KEY_HEADER] = key;
397
+ let aborted = false;
398
+ const outgoing = httpRequest({ host, port, method: request.method, path: request.url, headers, agent }, (answer) => {
399
+ const out = { ...answer.headers };
400
+ for (const name of HOP_BY_HOP)
401
+ delete out[name];
402
+ response.writeHead(answer.statusCode ?? 502, out);
403
+ answer.pipe(response);
404
+ answer.once('error', (error) => {
405
+ if (!aborted)
406
+ report(error);
407
+ response.destroy();
408
+ });
409
+ });
410
+ outgoing.once('error', (error) => {
411
+ if (aborted) {
412
+ // The browser went away first; the request to the host was cut on purpose.
413
+ if (logger.isEnabled('debug'))
414
+ logger.log('debug', 'Vite dev request ended by the browser', {
415
+ path: String(request.url ?? '').slice(0, 200),
416
+ });
417
+ return;
418
+ }
419
+ report(error);
420
+ if (response.headersSent)
421
+ response.destroy();
422
+ else {
423
+ response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
424
+ response.end('The Vite dev host did not answer; see the dokebi dev output.');
425
+ }
426
+ });
427
+ response.once('close', () => {
428
+ if (response.writableFinished)
429
+ return;
430
+ aborted = true;
431
+ outgoing.destroy();
432
+ });
433
+ outgoing.end();
434
+ },
435
+ async render(entry, page) {
436
+ const { status, text } = await call('POST', SSR_PATH, JSON.stringify({ entry, page }));
437
+ let answer;
438
+ try {
439
+ answer = JSON.parse(text);
440
+ }
441
+ catch (cause) {
442
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', `The Vite dev host answered a server render with ${status} and no JSON`, { cause });
443
+ }
444
+ if (status === 200 && 'result' in answer)
445
+ return answer.result;
446
+ throw remoteError(answer.error ?? { message: `Vite dev host answered ${status}` });
447
+ },
448
+ close: async () => {
449
+ agent.destroy();
450
+ logger.log('debug', 'Vite dev host disconnected', { url: target.origin });
451
+ },
452
+ };
453
+ }
454
+ /**
455
+ * The Vite of this process: the host of `dokebi dev` when `DOKEBI_VITE_DEV_URL` and
456
+ * `DOKEBI_VITE_DEV_KEY` name one, otherwise a host started here (then each process, each cluster
457
+ * worker, has its own Vite: run cluster apps through `dokebi dev`).
458
+ */
459
+ export async function openViteDev(logger, environment = process.env, root = process.cwd()) {
460
+ const url = environment.DOKEBI_VITE_DEV_URL;
461
+ if (url) {
462
+ const key = environment.DOKEBI_VITE_DEV_KEY;
463
+ if (!key)
464
+ throw new InertiaError('VITE_DEV_UNAVAILABLE', 'DOKEBI_VITE_DEV_URL is set without DOKEBI_VITE_DEV_KEY');
465
+ logger.log('debug', 'Vite dev uses the host of dokebi dev', { url });
466
+ return connectViteDev(logger, url, key);
467
+ }
468
+ logger.log('debug', 'Vite dev starts a host in this process', { pid: process.pid });
469
+ const host = await startViteHost(logger, root);
470
+ let client;
471
+ try {
472
+ client = await connectViteDev(logger, host.url, host.key);
473
+ }
474
+ catch (error) {
475
+ await host.close();
476
+ throw error;
477
+ }
478
+ return {
479
+ forward: client.forward,
480
+ render: client.render,
481
+ close: async () => {
482
+ try {
483
+ await client.close();
484
+ }
485
+ finally {
486
+ await host.close();
487
+ }
488
+ },
489
+ };
57
490
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ // The Vite dev host process (D-0045 addendum 3): `dokebi dev` runs this once per session, in the
2
+ // project folder, before the app. It starts the project's Vite (`startViteHost`) and reports
3
+ // `{ type: 'dokebi-vite-host', state: 'ready', url, key }` over the IPC channel; dev passes them to
4
+ // the app as DOKEBI_VITE_DEV_URL and DOKEBI_VITE_DEV_KEY. The host outlives app restarts.
5
+ // A start failure is reported as `state: 'failed'` and the process exits 1.
6
+ import { ConsoleLogger } from '@dokebi/core';
7
+ import { diagnostics } from '@dokebi/core/libraries';
8
+ import { startViteHost } from './vite-dev.js';
9
+ const { describeError } = diagnostics;
10
+ const LEVELS = new Set(['debug', 'info', 'warn', 'error', 'silent']);
11
+ const asked = process.env.DOKEBI_LOG_LEVEL?.trim().toLowerCase() ?? '';
12
+ const logger = new ConsoleLogger({ level: LEVELS.has(asked) ? asked : 'info' }, { plugin: 'dokebi-template-inertia', process: 'vite-host' });
13
+ const send = (message) => process.send?.(message);
14
+ let host;
15
+ try {
16
+ host = await startViteHost(logger, process.cwd());
17
+ }
18
+ catch (error) {
19
+ logger.log('error', 'Vite dev host failed to start', {
20
+ code: 'INERTIA_VITE_HOST_FAILED',
21
+ error: describeError(error),
22
+ });
23
+ send({
24
+ type: 'dokebi-vite-host',
25
+ state: 'failed',
26
+ message: error instanceof Error ? error.message : String(error),
27
+ });
28
+ process.exitCode = 1;
29
+ }
30
+ if (host) {
31
+ const started = host;
32
+ let stopping = false;
33
+ const stop = (signal) => {
34
+ if (stopping)
35
+ return;
36
+ stopping = true;
37
+ // Rejections become an error log and exit code 1 here (D-0047).
38
+ void started.close().then(() => logger.log('debug', 'Vite dev host stopped', { signal }), (error) => {
39
+ process.exitCode = 1;
40
+ logger.log('error', 'Vite dev host failed to stop', {
41
+ code: 'INERTIA_VITE_HOST_FAILED',
42
+ signal,
43
+ error: describeError(error),
44
+ });
45
+ });
46
+ };
47
+ process.on('SIGTERM', () => stop('SIGTERM'));
48
+ // Under dokebi dev (an IPC channel) a terminal Ctrl+C reaches the whole process group: the app
49
+ // drains first and dev stops the host after it (SIGTERM). Alone, Ctrl+C stops the host.
50
+ process.on('SIGINT', () => {
51
+ if (process.connected)
52
+ logger.log('debug', 'Vite dev host waits for dokebi dev to stop it');
53
+ else
54
+ stop('SIGINT');
55
+ });
56
+ logger.log('info', 'Vite dev host ready', {
57
+ code: 'INERTIA_VITE_HOST',
58
+ url: started.url,
59
+ hosts: started.hosts,
60
+ });
61
+ send({ type: 'dokebi-vite-host', state: 'ready', url: started.url, key: started.key });
62
+ }