@vgai/sdk 0.5.13 → 0.5.15
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/package.json +2 -2
- package/src/cinematic/theatre-operations.ts +2 -2
- package/src/mcp/mcp-server.ts +1 -1
- package/src/project/discovery-operations.ts +5 -5
- package/src/project/manifest-operations.ts +3 -4
- package/src/project/session-journal.ts +82 -7
- package/src/project/shared.ts +6 -0
- package/src/render/render-cinematic.ts +79 -23
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@vgai/sdk",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.15",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
-
"@vgai/engine": "0.5.
|
|
33
|
+
"@vgai/engine": "0.5.15",
|
|
34
34
|
"playwright": "^1.58.2",
|
|
35
35
|
"zod": "^4.3.6"
|
|
36
36
|
}
|
|
@@ -31,7 +31,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
31
31
|
import { isAbsolute, join } from 'node:path';
|
|
32
32
|
import { z } from 'zod';
|
|
33
33
|
import { ToolError } from '../errors.js';
|
|
34
|
-
import { listProjectFiles } from '../project/shared.js';
|
|
34
|
+
import { isTheatreProjectPath, listProjectFiles } from '../project/shared.js';
|
|
35
35
|
import { defineTool, type ToolErrorDefinition, type ToolRegistry } from '../registry.js';
|
|
36
36
|
import type { ToolContext } from '../types.js';
|
|
37
37
|
|
|
@@ -153,7 +153,7 @@ export const cinematicProjectList = defineTool({
|
|
|
153
153
|
permission: { risk: 'read', summary: 'Walks a file tree; no writes.' },
|
|
154
154
|
async impl(input, ctx) {
|
|
155
155
|
const root = resolveRoot(input.root, ctx);
|
|
156
|
-
const theatreProjectFiles = listProjectFiles(root,
|
|
156
|
+
const theatreProjectFiles = listProjectFiles(root, isTheatreProjectPath);
|
|
157
157
|
return { root, theatreProjectFiles };
|
|
158
158
|
},
|
|
159
159
|
});
|
package/src/mcp/mcp-server.ts
CHANGED
|
@@ -159,7 +159,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): VgaiMcpSe
|
|
|
159
159
|
request.params.name === 'discover-project'
|
|
160
160
|
? 'Call project.discover, project.status, and project.manifest.read. Summarize each adapter root and then call editor.session.list. Do not write files.'
|
|
161
161
|
: request.params.name === 'bring-existing-game'
|
|
162
|
-
? 'Call project.inspect before writing anything. Use its detected technologies, entry candidates, and blockers to identify Three.js/R3F, PixiJS, or React by renderer;
|
|
162
|
+
? 'Call project.inspect before writing anything. Use its detected technologies, entry candidates, and blockers to identify Three.js/R3F, PixiJS, or React by renderer; name any capability the adapter has not yet reached and obtain consent before metadata or source writes.'
|
|
163
163
|
: `Implement only this requested outcome: ${task}. Discover the project first, preserve unrelated work, use dryRun for file mutations when available, then verify through the exact matching editor with Play, status/logs, and a legible capture.`;
|
|
164
164
|
return {
|
|
165
165
|
description: requested.description,
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - Stories (CSF) are ordinary `.tsx`/`.ts` modules (§5.2/C3) — VGAI stores
|
|
10
10
|
* no duplicate story registry, so there is nothing to mutate here; listing
|
|
11
11
|
* `*.stories.tsx`/`*.stories.ts` is the whole B2 surface.
|
|
12
|
-
* - Theatre project state (`*.theatre-project.json`)
|
|
12
|
+
* - Theatre project state (`theatre-project.json` or `*.theatre-project.json`)
|
|
13
|
+
* is a real, versioned,
|
|
13
14
|
* proprietary Theatre.js format this repo does not own (§3.2/§5.3 — "the
|
|
14
15
|
* committed Theatre project state is authoritative authored data"). B2
|
|
15
16
|
* validates only the OUTER shape (`sheetsById`/`definitionVersion`/
|
|
@@ -29,6 +30,7 @@ import { z } from 'zod';
|
|
|
29
30
|
import { ToolError } from '../errors.js';
|
|
30
31
|
import { defineTool, type ToolRegistry } from '../registry.js';
|
|
31
32
|
import {
|
|
33
|
+
isTheatreProjectPath,
|
|
32
34
|
listProjectFiles,
|
|
33
35
|
NO_PROJECT_ROOT_ERROR,
|
|
34
36
|
PATH_OUTSIDE_PROJECT_ERROR,
|
|
@@ -71,7 +73,7 @@ export const projectStoryDiscover = defineTool({
|
|
|
71
73
|
export const projectTheatreDiscover = defineTool({
|
|
72
74
|
name: 'project.theatre.discover',
|
|
73
75
|
summary:
|
|
74
|
-
'List every committed Theatre project-state file (*.theatre-project.json) in the project.',
|
|
76
|
+
'List every committed Theatre project-state file (theatre-project.json or *.theatre-project.json) in the project.',
|
|
75
77
|
description:
|
|
76
78
|
'List-only (§3.2/§5.3: the committed Theatre project state is authoritative authored data VGAI does not own the internal format of).',
|
|
77
79
|
input: z.object({}),
|
|
@@ -86,9 +88,7 @@ export const projectTheatreDiscover = defineTool({
|
|
|
86
88
|
permission: { risk: 'read', summary: 'Walks the project file tree; no writes.' },
|
|
87
89
|
async impl(_input, ctx) {
|
|
88
90
|
const projectRoot = requireProjectRoot(ctx);
|
|
89
|
-
const theatreProjectFiles = listProjectFiles(projectRoot,
|
|
90
|
-
p.endsWith('.theatre-project.json'),
|
|
91
|
-
);
|
|
91
|
+
const theatreProjectFiles = listProjectFiles(projectRoot, isTheatreProjectPath);
|
|
92
92
|
return { theatreProjectFiles };
|
|
93
93
|
},
|
|
94
94
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
checkNotStale,
|
|
23
23
|
DryRunField,
|
|
24
24
|
FILE_NOT_FOUND_ERROR,
|
|
25
|
+
isTheatreProjectPath,
|
|
25
26
|
listProjectFiles,
|
|
26
27
|
mutationResultSchema,
|
|
27
28
|
NO_PROJECT_ROOT_ERROR,
|
|
@@ -65,7 +66,7 @@ const ProjectDiscoverResult = z
|
|
|
65
66
|
.describe('Every *.stories.tsx / *.stories.ts found under the project.'),
|
|
66
67
|
theatreProjectFiles: z
|
|
67
68
|
.array(z.string())
|
|
68
|
-
.describe('Every *.theatre-project.json found under the project.'),
|
|
69
|
+
.describe('Every theatre-project.json / *.theatre-project.json found under the project.'),
|
|
69
70
|
})
|
|
70
71
|
.describe('A one-call survey of the project at ctx.projectRoot.');
|
|
71
72
|
|
|
@@ -115,9 +116,7 @@ export const projectDiscover = defineTool({
|
|
|
115
116
|
projectRoot,
|
|
116
117
|
(p) => p.endsWith('.stories.tsx') || p.endsWith('.stories.ts'),
|
|
117
118
|
),
|
|
118
|
-
theatreProjectFiles: listProjectFiles(projectRoot,
|
|
119
|
-
p.endsWith('.theatre-project.json'),
|
|
120
|
-
),
|
|
119
|
+
theatreProjectFiles: listProjectFiles(projectRoot, isTheatreProjectPath),
|
|
121
120
|
};
|
|
122
121
|
},
|
|
123
122
|
});
|
|
@@ -235,16 +235,78 @@ export type SessionJournalEvent =
|
|
|
235
235
|
/** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
|
|
236
236
|
| { readonly kind: 'tab-duplicated'; readonly tabId8: string }
|
|
237
237
|
/**
|
|
238
|
-
* The tab is present
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
238
|
+
* The tab is present but its PAGE never came up this page-load. Presence
|
|
239
|
+
* proves the tab exists, not that the document works — a main thread that
|
|
240
|
+
* died after the inline bootstrap keeps beating and holding its control
|
|
241
|
+
* connection, and can run nothing. Such a tab is passed over for blessing
|
|
242
|
+
* and named in the refusal.
|
|
243
|
+
*
|
|
244
|
+
* `reason` says which stage it is stuck at: `'no-channel'` (the page never
|
|
245
|
+
* opened a control connection at all) or `'no-command-listener'` (it did,
|
|
246
|
+
* and the module graph behind it never produced a listener).
|
|
243
247
|
*/
|
|
244
248
|
| {
|
|
245
249
|
readonly kind: 'tab-unresponsive';
|
|
246
250
|
readonly tabId8: string;
|
|
247
|
-
readonly
|
|
251
|
+
readonly reason: 'no-channel' | 'no-command-listener';
|
|
252
|
+
readonly unresponsiveForMs: number;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* An uncaught error or unhandled rejection in a tab's PAGE, captured by
|
|
256
|
+
* `index.html`'s inline bootstrap — before the module graph, so it is
|
|
257
|
+
* recorded even when the boot that would have reported it is the thing that
|
|
258
|
+
* died. This is the line that says WHY a `tab-unresponsive` tab is
|
|
259
|
+
* unresponsive.
|
|
260
|
+
*/
|
|
261
|
+
| {
|
|
262
|
+
readonly kind: 'page-error';
|
|
263
|
+
readonly tabId8: string;
|
|
264
|
+
readonly message: string;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* One batch of occurrences of ONE console error/warning condition, as the
|
|
268
|
+
* server's unresolved-console ledger recorded it
|
|
269
|
+
* (`packages/editor/server/console-ledger.ts`).
|
|
270
|
+
*
|
|
271
|
+
* This is the DURABLE half of the loudness convention, and the reason it is
|
|
272
|
+
* a row per observation rather than a row per distinct message: `count` is
|
|
273
|
+
* the running total after this batch, so the file answers "how many times
|
|
274
|
+
* did this actually fire, and when did it stop" long after the ledger (and
|
|
275
|
+
* the tab, and the server) are gone. A repeat is never filtered out here —
|
|
276
|
+
* deduping repeats to silence is precisely the failure that made a session's
|
|
277
|
+
* eleven errors read as one line and then nothing.
|
|
278
|
+
*/
|
|
279
|
+
| {
|
|
280
|
+
readonly kind: 'console-entry';
|
|
281
|
+
readonly id: string;
|
|
282
|
+
readonly severity: 'error' | 'warn';
|
|
283
|
+
readonly source: string | null;
|
|
284
|
+
/** Occurrences in THIS batch. */
|
|
285
|
+
readonly added: number;
|
|
286
|
+
/** Running total for this condition, across every page-load. */
|
|
287
|
+
readonly count: number;
|
|
288
|
+
readonly message: string;
|
|
289
|
+
}
|
|
290
|
+
/** A condition cleared by the honest re-test: the page reloaded and it did
|
|
291
|
+
* not recur. `count` is what it reached before it stopped. */
|
|
292
|
+
| {
|
|
293
|
+
readonly kind: 'console-retired';
|
|
294
|
+
readonly id: string;
|
|
295
|
+
readonly severity: 'error' | 'warn';
|
|
296
|
+
readonly count: number;
|
|
297
|
+
readonly message: string;
|
|
298
|
+
}
|
|
299
|
+
/** A condition waved through BY NAME (`vgai console ack`). The audit row is
|
|
300
|
+
* the whole point: an acknowledgment records who and why, and never erases
|
|
301
|
+
* what was acknowledged. */
|
|
302
|
+
| {
|
|
303
|
+
readonly kind: 'console-ack';
|
|
304
|
+
readonly id: string;
|
|
305
|
+
readonly severity: 'error' | 'warn';
|
|
306
|
+
readonly count: number;
|
|
307
|
+
readonly by: string;
|
|
308
|
+
readonly reason: string;
|
|
309
|
+
readonly message: string;
|
|
248
310
|
};
|
|
249
311
|
|
|
250
312
|
/** A parsed journal line: the event plus when it was appended. */
|
|
@@ -449,9 +511,22 @@ export function formatJournalLine(line: SessionJournalLine): string {
|
|
|
449
511
|
case 'tab-duplicated':
|
|
450
512
|
return `journal: ${at} tab-duplicated ${line.tabId8}`;
|
|
451
513
|
case 'tab-unresponsive':
|
|
452
|
-
return `journal: ${at} tab-unresponsive ${line.tabId8}
|
|
514
|
+
return `journal: ${at} tab-unresponsive ${line.tabId8} ${line.reason} for ${Math.round(line.unresponsiveForMs / 1000)}s`;
|
|
515
|
+
case 'page-error':
|
|
516
|
+
return `journal: ${at} page-error ${line.tabId8} ${line.message}`;
|
|
453
517
|
case 'tab-death-profile':
|
|
454
518
|
return `journal: ${at} tab-death-profile ${line.tabId8} code ${line.code} — ${tabDeathProfileBody(line)}`;
|
|
519
|
+
// The CONSOLE arm. `count` is the running total for that condition, so a
|
|
520
|
+
// reader scanning the column sees a repeat climbing rather than the same
|
|
521
|
+
// line over and over with nothing to distinguish the fiftieth from the
|
|
522
|
+
// first. Every line leads with the ack id, because that is what a reader
|
|
523
|
+
// types next.
|
|
524
|
+
case 'console-entry':
|
|
525
|
+
return `journal: ${at} console-${line.severity} ${line.id} +${line.added} (total ${line.count}) ${line.source === null ? '' : `[${line.source}] `}${line.message.split('\n')[0]}`;
|
|
526
|
+
case 'console-retired':
|
|
527
|
+
return `journal: ${at} console-retired ${line.id} after ${line.count} — did not recur after reload`;
|
|
528
|
+
case 'console-ack':
|
|
529
|
+
return `journal: ${at} console-ack ${line.id} (×${line.count}) by ${line.by}: ${line.reason}`;
|
|
455
530
|
}
|
|
456
531
|
}
|
|
457
532
|
|
package/src/project/shared.ts
CHANGED
|
@@ -44,6 +44,12 @@ import { ToolError } from '../errors.js';
|
|
|
44
44
|
import type { ToolErrorDefinition } from '../registry.js';
|
|
45
45
|
import type { ToolContext } from '../types.js';
|
|
46
46
|
|
|
47
|
+
/** Both Theatre project-state names already established in shipped projects. */
|
|
48
|
+
export function isTheatreProjectPath(path: string): boolean {
|
|
49
|
+
const filename = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
|
|
50
|
+
return filename === 'theatre-project.json' || filename.endsWith('.theatre-project.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
// ---------------------------------------------------------------------------
|
|
48
54
|
// Project-root resolution
|
|
49
55
|
// ---------------------------------------------------------------------------
|
|
@@ -245,7 +245,7 @@ function keepFramesOverride(signal: AbortSignal | undefined): boolean | undefine
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
const DEFAULT_SEED = 0x5eed_c0de;
|
|
248
|
-
/** Only actually used when `entry` is an http(s) URL (no
|
|
248
|
+
/** Only actually used when `entry` is an http(s) URL (no preview server spawn — see {@link resolveEffectivePort}) or as this pure function's own documented default before that override applies. */
|
|
249
249
|
const DEFAULT_PORT = 5799;
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -282,7 +282,7 @@ export function findFreeRenderPort(): Promise<number> {
|
|
|
282
282
|
|
|
283
283
|
/**
|
|
284
284
|
* I7 fold-in (#9b): when the caller didn't pin an explicit `port`, resolve a
|
|
285
|
-
* genuinely free one right before spawning the
|
|
285
|
+
* genuinely free one right before spawning the preview server, overriding the
|
|
286
286
|
* resolved request's `DEFAULT_PORT` placeholder. Checked against the
|
|
287
287
|
* ORIGINAL (unresolved) `request.port`, not `resolved.port` — the latter
|
|
288
288
|
* already has `DEFAULT_PORT` filled in by {@link resolveRenderCinematicRequest}
|
|
@@ -471,7 +471,7 @@ export function resolveRenderCinematicRequest(
|
|
|
471
471
|
keepFrames: req.keepFrames ?? false,
|
|
472
472
|
// I7 fold-in #9b: this DEFAULT_PORT fallback is only actually used when
|
|
473
473
|
// `resolveEffectivePort` (below) doesn't override it — i.e. `entry` is
|
|
474
|
-
// an already-serving http(s) URL (no
|
|
474
|
+
// an already-serving http(s) URL (no preview server is ever spawned, so no
|
|
475
475
|
// port collision is possible). Every Vite-config `entry` gets a REAL
|
|
476
476
|
// free port at launch time instead — see `resolveEffectivePort`.
|
|
477
477
|
port: req.port ?? DEFAULT_PORT,
|
|
@@ -607,23 +607,23 @@ async function waitForHttpOk(url: string, timeoutMs: number, signal?: AbortSigna
|
|
|
607
607
|
const deadline = Date.now() + timeoutMs;
|
|
608
608
|
let lastErr: unknown;
|
|
609
609
|
while (Date.now() < deadline) {
|
|
610
|
-
// I7 fold-in (#9a): don't keep polling a
|
|
610
|
+
// I7 fold-in (#9a): don't keep polling a preview server that's booting for a
|
|
611
611
|
// render that was already cancelled — return promptly so the caller's
|
|
612
612
|
// own catch path (`launchEntryServer`) can group-kill the half-started
|
|
613
613
|
// vite process right away instead of waiting out the full 30s timeout.
|
|
614
614
|
if (signal?.aborted) {
|
|
615
|
-
throw new Error('Render cancelled while waiting for the
|
|
615
|
+
throw new Error('Render cancelled while waiting for the preview server to become ready.');
|
|
616
616
|
}
|
|
617
617
|
try {
|
|
618
618
|
const res = await fetch(url);
|
|
619
|
-
if (res.ok || res.status === 404) return; //
|
|
619
|
+
if (res.ok || res.status === 404) return; // a preview response is enough; 404 still proves it's up
|
|
620
620
|
} catch (err) {
|
|
621
621
|
lastErr = err;
|
|
622
622
|
}
|
|
623
623
|
await new Promise((r) => setTimeout(r, 200));
|
|
624
624
|
}
|
|
625
625
|
throw new Error(
|
|
626
|
-
`
|
|
626
|
+
`Preview server at ${url} did not become ready within ${timeoutMs}ms` +
|
|
627
627
|
(lastErr instanceof Error ? ` (last error: ${lastErr.message})` : ''),
|
|
628
628
|
);
|
|
629
629
|
}
|
|
@@ -633,16 +633,56 @@ export interface LaunchedServer {
|
|
|
633
633
|
stop(): Promise<void>;
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
-
/**
|
|
636
|
+
/** Build one Vite config as an exported artifact. A dev-served standalone game
|
|
637
|
+
* is intentionally trapped by `unexported-game-trap.ts`; render tooling must
|
|
638
|
+
* exercise either the editor or the export, never invent a bypass query. */
|
|
639
|
+
async function buildExportedEntry(
|
|
640
|
+
configPath: string,
|
|
641
|
+
cwd: string,
|
|
642
|
+
outDir: string,
|
|
643
|
+
signal?: AbortSignal,
|
|
644
|
+
): Promise<string> {
|
|
645
|
+
const child = spawn('npx', ['vite', 'build', '--config', configPath, '--outDir', outDir], {
|
|
646
|
+
cwd,
|
|
647
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
648
|
+
detached: true,
|
|
649
|
+
});
|
|
650
|
+
let output = '';
|
|
651
|
+
child.stdout?.on('data', (data) => {
|
|
652
|
+
output += String(data);
|
|
653
|
+
});
|
|
654
|
+
child.stderr?.on('data', (data) => {
|
|
655
|
+
output += String(data);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const abort = () => void stopServerProcess(child);
|
|
659
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
660
|
+
try {
|
|
661
|
+
const code = await new Promise<number>((resolve, reject) => {
|
|
662
|
+
child.once('error', reject);
|
|
663
|
+
child.once('close', (exitCode) => resolve(exitCode ?? 1));
|
|
664
|
+
});
|
|
665
|
+
if (signal?.aborted) throw new Error('Render cancelled while building the exported game.');
|
|
666
|
+
if (code !== 0) {
|
|
667
|
+
throw new Error(`Vite production build failed (exit ${code}).\nOutput:\n${output}`);
|
|
668
|
+
}
|
|
669
|
+
return output;
|
|
670
|
+
} finally {
|
|
671
|
+
signal?.removeEventListener('abort', abort);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Resolve `entry` into a running exported page: reuse a URL as-is, or build
|
|
676
|
+
* the Vite config and spawn `vite preview`. */
|
|
637
677
|
export async function launchEntryServer(
|
|
638
678
|
entry: string,
|
|
639
679
|
port: number,
|
|
640
|
-
|
|
680
|
+
_engineRoot: string,
|
|
641
681
|
onProgress: ((e: RenderCinematicProgress) => void) | undefined,
|
|
642
682
|
signal?: AbortSignal,
|
|
643
683
|
options?: {
|
|
644
684
|
/**
|
|
645
|
-
* Extra args appended to the `npx vite` spawn (G3 fold-in). Motivating
|
|
685
|
+
* Extra args appended to the `npx vite preview` spawn (G3 fold-in). Motivating
|
|
646
686
|
* case: the render-mode e2e fixtures all pin `server.host: '127.0.0.1'`
|
|
647
687
|
* in their own vite configs, but a real project's config (an
|
|
648
688
|
* `examples/<id>/vite.config.ts`) typically leaves `host` at Vite's
|
|
@@ -668,6 +708,15 @@ export async function launchEntryServer(
|
|
|
668
708
|
}
|
|
669
709
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
670
710
|
onProgress?.({ phase: 'server-launch', url: baseUrl });
|
|
711
|
+
const projectDir = dirname(configPath);
|
|
712
|
+
const exportDir = await mkdtemp(join(tmpdir(), 'vgai-render-export-'));
|
|
713
|
+
let buildOutput: string;
|
|
714
|
+
try {
|
|
715
|
+
buildOutput = await buildExportedEntry(configPath, projectDir, exportDir, signal);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
671
720
|
|
|
672
721
|
// `detached: true` makes this child the leader of its OWN process group —
|
|
673
722
|
// required for `stopServerProcess`'s `process.kill(-pid, sig)` group-kill
|
|
@@ -682,14 +731,17 @@ export async function launchEntryServer(
|
|
|
682
731
|
'npx',
|
|
683
732
|
[
|
|
684
733
|
'vite',
|
|
734
|
+
'preview',
|
|
685
735
|
'--config',
|
|
686
736
|
configPath,
|
|
737
|
+
'--outDir',
|
|
738
|
+
exportDir,
|
|
687
739
|
'--port',
|
|
688
740
|
String(port),
|
|
689
741
|
'--strictPort',
|
|
690
742
|
...(options?.extraViteArgs ?? []),
|
|
691
743
|
],
|
|
692
|
-
{ cwd:
|
|
744
|
+
{ cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
|
|
693
745
|
);
|
|
694
746
|
let serverOutput = '';
|
|
695
747
|
child.stdout?.on('data', (d) => {
|
|
@@ -708,10 +760,14 @@ export async function launchEntryServer(
|
|
|
708
760
|
await waitForHttpOk(`${baseUrl}/`, 30_000, signal);
|
|
709
761
|
} catch (err) {
|
|
710
762
|
await stopServerProcess(child);
|
|
711
|
-
|
|
763
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
764
|
+
throw new Error(
|
|
765
|
+
`${(err as Error).message}\nVite build output:\n${buildOutput}\nVite preview output:\n${serverOutput}`,
|
|
766
|
+
);
|
|
712
767
|
}
|
|
713
768
|
if (exited) {
|
|
714
|
-
|
|
769
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
770
|
+
throw new Error(`Vite preview exited before becoming ready.\nOutput:\n${serverOutput}`);
|
|
715
771
|
}
|
|
716
772
|
|
|
717
773
|
onProgress?.({ phase: 'server-ready', url: baseUrl });
|
|
@@ -719,8 +775,8 @@ export async function launchEntryServer(
|
|
|
719
775
|
return {
|
|
720
776
|
baseUrl,
|
|
721
777
|
async stop() {
|
|
722
|
-
if (exited)
|
|
723
|
-
await
|
|
778
|
+
if (!exited) await stopServerProcess(child);
|
|
779
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
724
780
|
},
|
|
725
781
|
};
|
|
726
782
|
}
|
|
@@ -729,7 +785,7 @@ export async function launchEntryServer(
|
|
|
729
785
|
* Terminate `proc` AND its whole process group (see the `detached: true`
|
|
730
786
|
* comment at its call site above) — a plain `proc.kill()` leaves the real
|
|
731
787
|
* `vite` node process (an `npx` grandchild) running, which both leaks a
|
|
732
|
-
* port-bound
|
|
788
|
+
* port-bound preview server across renders and keeps THIS process's event loop
|
|
733
789
|
* alive via the still-open stdout/stderr pipes, hanging `renderCinematic`'s
|
|
734
790
|
* caller indefinitely after the render itself has already finished. Mirrors
|
|
735
791
|
* `packages/editor/e2e/helpers/server.ts`'s `stopProcess` (POSIX process-
|
|
@@ -757,7 +813,7 @@ function stopServerProcess(proc: ChildProcess): Promise<void> {
|
|
|
757
813
|
return;
|
|
758
814
|
}
|
|
759
815
|
const timer = setTimeout(() => {
|
|
760
|
-
signalGroup('SIGKILL'); // belt and braces — don't hang the render on a stuck
|
|
816
|
+
signalGroup('SIGKILL'); // belt and braces — don't hang the render on a stuck preview server
|
|
761
817
|
resolvePromise();
|
|
762
818
|
}, 5_000);
|
|
763
819
|
proc.on('exit', () => {
|
|
@@ -1450,12 +1506,12 @@ async function openRenderPage(
|
|
|
1450
1506
|
export interface RenderCinematicPageSession {
|
|
1451
1507
|
readonly page: Page;
|
|
1452
1508
|
readonly resolved: ResolvedRenderCinematicRequest;
|
|
1453
|
-
/** Closes the page/context, the browser, and stops the spawned
|
|
1509
|
+
/** Closes the page/context, the browser, and stops the spawned preview server (if any). */
|
|
1454
1510
|
close(): Promise<void>;
|
|
1455
1511
|
}
|
|
1456
1512
|
|
|
1457
1513
|
/**
|
|
1458
|
-
* Open a live render-mode page — preflight,
|
|
1514
|
+
* Open a live render-mode page — preflight, production build/preview launch, headless
|
|
1459
1515
|
* Chromium launch, DOM/CSS-animation freeze, navigate, and wait for
|
|
1460
1516
|
* `__vgaiRender.ready()` — WITHOUT running {@link renderCinematic}'s own
|
|
1461
1517
|
* frame-capture/encode loop. This exposes the raw Playwright `Page` for a
|
|
@@ -1723,13 +1779,13 @@ export async function renderCinematic(
|
|
|
1723
1779
|
// I7 fold-in (#9a — cancellation race): this is the EARLIEST possible
|
|
1724
1780
|
// cancellation point — `preparePipelineOutputs` (immediately above)
|
|
1725
1781
|
// already created `framesDir` on disk, so throwing here without cleanup
|
|
1726
|
-
// leaked it (reproduced by a real abort landing before the
|
|
1782
|
+
// leaked it (reproduced by a real abort landing before the preview server
|
|
1727
1783
|
// even starts, not merely a hypothetical). `cleanupAfterFailure` is a
|
|
1728
1784
|
// no-op on everything else at this point (no `outAbs` output could
|
|
1729
1785
|
// possibly exist yet), so this is just the framesDir removal, done
|
|
1730
1786
|
// explicitly rather than pulled into a broader try/catch this early.
|
|
1731
1787
|
const err = new RenderCinematicCancelledError(
|
|
1732
|
-
'Render cancelled before the
|
|
1788
|
+
'Render cancelled before the preview server launched.',
|
|
1733
1789
|
keepFramesOverride(signal),
|
|
1734
1790
|
);
|
|
1735
1791
|
await cleanupAfterFailure(err, resolved, outAbs, framesDir, signal);
|
|
@@ -1742,7 +1798,7 @@ export async function renderCinematic(
|
|
|
1742
1798
|
// cancellation-driven failure — `waitForHttpOk`'s own `signal?.aborted`
|
|
1743
1799
|
// check inside `launchEntryServer` throws a plain `Error` (wrapped again
|
|
1744
1800
|
// by `launchEntryServer`'s own catch, losing any `RenderCinematicCancelledError`-
|
|
1745
|
-
// ness entirely) if `signal` aborts while still waiting for the
|
|
1801
|
+
// ness entirely) if `signal` aborts while still waiting for the preview server
|
|
1746
1802
|
// to come up. Without this try/catch, THAT throw skipped
|
|
1747
1803
|
// `cleanupAfterFailure` completely (it's outside the main try block) and
|
|
1748
1804
|
// leaked the temp frames dir `preparePipelineOutputs` already created
|
|
@@ -1765,7 +1821,7 @@ export async function renderCinematic(
|
|
|
1765
1821
|
let browser: Browser | undefined;
|
|
1766
1822
|
const warnings: string[] = [];
|
|
1767
1823
|
// I7 fold-in (#9a — cancellation/orphan): tear down the browser AND the
|
|
1768
|
-
//
|
|
1824
|
+
// Vite preview server's WHOLE PROCESS GROUP the INSTANT `signal` aborts,
|
|
1769
1825
|
// rather than waiting for the next cooperative per-frame check
|
|
1770
1826
|
// (`captureFrames`'s `signal?.aborted` guard) to be reached. This is what
|
|
1771
1827
|
// makes a slow/hung in-flight `page.evaluate`/`page.screenshot` call, or a
|