@crouton-kit/capture 1.3.3 → 1.3.5
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/bin/capture +2293 -1477
- package/package.json +7 -3
- package/src/capture.ts +10 -383
- package/src/cdp/a11y.ts +104 -0
- package/src/cdp/args.ts +101 -0
- package/src/cdp/client.ts +97 -0
- package/src/cdp/commands/exec.ts +102 -0
- package/src/cdp/commands/har.ts +128 -0
- package/src/cdp/commands/lib.ts +356 -0
- package/src/cdp/commands/tabs.ts +189 -0
- package/src/cdp/commands/traffic.ts +121 -0
- package/src/cdp/commands/ui.ts +172 -0
- package/src/cdp/connection.ts +108 -0
- package/src/cdp/console-recorder.ts +80 -0
- package/src/cdp/detect.ts +150 -0
- package/src/cdp/dispatch.ts +32 -0
- package/src/cdp/execute.ts +117 -0
- package/src/cdp/har-output.ts +47 -0
- package/src/cdp/har-recorder.ts +147 -0
- package/src/cdp/record.ts +221 -0
- package/src/cdp/screenshot.ts +100 -0
- package/src/cdp/tab-lock.ts +132 -0
- package/src/cdp/targets.ts +112 -0
- package/src/cdp/types.ts +36 -0
- package/src/cdp/usage.ts +69 -0
- package/src/cdp.ts +17 -2337
- package/src/session/commands.ts +385 -0
- package/src/vault/bundle.ts +205 -0
- package/src/vault/docs.ts +224 -0
- package/src/vault/paths.ts +87 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import {
|
|
6
|
+
createHarRecording,
|
|
7
|
+
readHarRecording,
|
|
8
|
+
deleteHarRecording,
|
|
9
|
+
} from '../har-manager.js';
|
|
10
|
+
import {
|
|
11
|
+
getActiveSession,
|
|
12
|
+
setActiveSession,
|
|
13
|
+
clearActiveSession,
|
|
14
|
+
} from '../session-context.js';
|
|
15
|
+
|
|
16
|
+
const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
|
|
17
|
+
|
|
18
|
+
interface LogPid {
|
|
19
|
+
pid: number;
|
|
20
|
+
name: string;
|
|
21
|
+
sourcePath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Session {
|
|
25
|
+
id: string;
|
|
26
|
+
dir: string;
|
|
27
|
+
harId: string | null;
|
|
28
|
+
startedAt: string;
|
|
29
|
+
url: string | null;
|
|
30
|
+
targetId: string | null;
|
|
31
|
+
stepCount: number;
|
|
32
|
+
logPids: LogPid[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BundleManifest {
|
|
36
|
+
id: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
stoppedAt: string;
|
|
39
|
+
duration: number;
|
|
40
|
+
url: string | null;
|
|
41
|
+
screenshots: Array<{ name: string; path: string }>;
|
|
42
|
+
har: { id: string; path: string; entryCount: number } | null;
|
|
43
|
+
a11y: Array<{ name: string; path: string }>;
|
|
44
|
+
logs: Array<{ name: string; path: string; lines: number }>;
|
|
45
|
+
other: Array<{ name: string; path: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sessionDir(id: string): string {
|
|
49
|
+
return path.join(CAPTURE_ROOT, id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionMetaPath(id: string): string {
|
|
53
|
+
return path.join(sessionDir(id), '.session.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readSession(id: string): Session {
|
|
57
|
+
const metaPath = sessionMetaPath(id);
|
|
58
|
+
if (!fs.existsSync(metaPath)) {
|
|
59
|
+
throw new Error(`No capture session found: ${id}`);
|
|
60
|
+
}
|
|
61
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Session;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function generateId(): string {
|
|
65
|
+
return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Session Commands
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
async function start(args: string[]): Promise<void> {
|
|
73
|
+
let url: string | null = null;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
if (args[i] === '--url' && args[i + 1]) {
|
|
77
|
+
url = args[++i];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const id = generateId();
|
|
82
|
+
const dir = sessionDir(id);
|
|
83
|
+
fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
|
|
84
|
+
fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Start HAR recording directly
|
|
87
|
+
let harId: string | null = null;
|
|
88
|
+
try {
|
|
89
|
+
const harResult = createHarRecording();
|
|
90
|
+
harId = harResult.id;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Open tab if URL provided
|
|
96
|
+
let targetId: string | null = null;
|
|
97
|
+
if (url) {
|
|
98
|
+
try {
|
|
99
|
+
const { detectCdpPort, navigateAndWait } = await import('../cdp.js');
|
|
100
|
+
const port = await detectCdpPort();
|
|
101
|
+
const tab = await navigateAndWait(port, url);
|
|
102
|
+
targetId = tab.id;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`Warning: could not open tab: ${err instanceof Error ? err.message : err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const session: Session = {
|
|
109
|
+
id,
|
|
110
|
+
dir,
|
|
111
|
+
harId,
|
|
112
|
+
startedAt: new Date().toISOString(),
|
|
113
|
+
url,
|
|
114
|
+
targetId,
|
|
115
|
+
stepCount: 0,
|
|
116
|
+
logPids: [],
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
|
|
119
|
+
|
|
120
|
+
// Set as active session for auto-defaults
|
|
121
|
+
setActiveSession({
|
|
122
|
+
sessionId: id,
|
|
123
|
+
dir,
|
|
124
|
+
harId,
|
|
125
|
+
targetId,
|
|
126
|
+
stepCount: 0,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Output for agent consumption
|
|
130
|
+
const result = {
|
|
131
|
+
sessionId: id,
|
|
132
|
+
bundleDir: dir,
|
|
133
|
+
harId,
|
|
134
|
+
targetId,
|
|
135
|
+
shotsDir: path.join(dir, 'shots'),
|
|
136
|
+
a11yDir: path.join(dir, 'a11y'),
|
|
137
|
+
};
|
|
138
|
+
console.log(JSON.stringify(result, null, 2));
|
|
139
|
+
|
|
140
|
+
// Agent-friendly next steps on stderr
|
|
141
|
+
console.error(`\nCapture session started: ${id}`);
|
|
142
|
+
if (targetId) {
|
|
143
|
+
console.error(`Tab opened — session context active. No need to pass --target or --har.`);
|
|
144
|
+
}
|
|
145
|
+
console.error(`\nWhen done: capture session stop ${id}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function logCommand(args: string[]): void {
|
|
149
|
+
const sourcePath = args[0];
|
|
150
|
+
if (!sourcePath) {
|
|
151
|
+
console.error('Usage: capture log <path> [--name label] [--session <id>]');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const resolved = path.resolve(sourcePath);
|
|
156
|
+
if (!fs.existsSync(resolved)) {
|
|
157
|
+
throw new Error(`Log file not found: ${resolved}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let name: string | null = null;
|
|
161
|
+
let sessionId: string | null = null;
|
|
162
|
+
for (let i = 1; i < args.length; i++) {
|
|
163
|
+
if (args[i] === '--name' && args[i + 1]) name = args[++i];
|
|
164
|
+
if (args[i] === '--session' && args[i + 1]) sessionId = args[++i];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!sessionId) {
|
|
168
|
+
const active = getActiveSession();
|
|
169
|
+
if (!active) {
|
|
170
|
+
throw new Error('No active capture session. Start one or pass --session <id>.');
|
|
171
|
+
}
|
|
172
|
+
sessionId = active.sessionId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const session = readSession(sessionId);
|
|
176
|
+
name = name ?? path.basename(resolved, path.extname(resolved));
|
|
177
|
+
|
|
178
|
+
const logsDir = path.join(session.dir, 'logs');
|
|
179
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
180
|
+
|
|
181
|
+
const destPath = path.join(logsDir, `${name}.log`);
|
|
182
|
+
const outFd = fs.openSync(destPath, 'a');
|
|
183
|
+
|
|
184
|
+
const child = spawn(
|
|
185
|
+
'sh',
|
|
186
|
+
['-c', `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
|
|
187
|
+
{ detached: true, stdio: ['ignore', outFd, 'ignore'] },
|
|
188
|
+
);
|
|
189
|
+
child.unref();
|
|
190
|
+
fs.closeSync(outFd);
|
|
191
|
+
|
|
192
|
+
const pid = child.pid!;
|
|
193
|
+
session.logPids.push({ pid, name, sourcePath: resolved });
|
|
194
|
+
fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
|
|
195
|
+
|
|
196
|
+
console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function stop(args: string[]): Promise<void> {
|
|
200
|
+
const id = args[0];
|
|
201
|
+
if (!id) {
|
|
202
|
+
console.error('Usage: capture session stop <session-id>');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const session = readSession(id);
|
|
207
|
+
const stoppedAt = new Date().toISOString();
|
|
208
|
+
const startMs = new Date(session.startedAt).getTime();
|
|
209
|
+
const duration = Date.now() - startMs;
|
|
210
|
+
|
|
211
|
+
// Kill log tailers
|
|
212
|
+
for (const lp of session.logPids ?? []) {
|
|
213
|
+
try { process.kill(-lp.pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
214
|
+
}
|
|
215
|
+
if (session.logPids?.length) {
|
|
216
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Collect screenshots
|
|
220
|
+
const shotsDir = path.join(session.dir, 'shots');
|
|
221
|
+
const screenshots = fs.existsSync(shotsDir)
|
|
222
|
+
? fs.readdirSync(shotsDir)
|
|
223
|
+
.filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
|
|
224
|
+
.map((f) => ({ name: f, path: path.join(shotsDir, f) }))
|
|
225
|
+
: [];
|
|
226
|
+
|
|
227
|
+
// Collect a11y snapshots
|
|
228
|
+
const a11yDir = path.join(session.dir, 'a11y');
|
|
229
|
+
const a11y = fs.existsSync(a11yDir)
|
|
230
|
+
? fs.readdirSync(a11yDir)
|
|
231
|
+
.filter((f) => f.endsWith('.json') || f.endsWith('.txt'))
|
|
232
|
+
.map((f) => ({ name: f, path: path.join(a11yDir, f) }))
|
|
233
|
+
: [];
|
|
234
|
+
|
|
235
|
+
// Collect HAR directly from har-manager
|
|
236
|
+
let har: BundleManifest['har'] = null;
|
|
237
|
+
if (session.harId) {
|
|
238
|
+
try {
|
|
239
|
+
const harData = readHarRecording(session.harId);
|
|
240
|
+
if (harData) {
|
|
241
|
+
const harPath = path.join(session.dir, 'har.json');
|
|
242
|
+
fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
|
|
243
|
+
har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
|
|
244
|
+
// Clean up the HAR recording
|
|
245
|
+
try { deleteHarRecording(session.harId); } catch { /* best effort */ }
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error(`Warning: could not read HAR: ${err instanceof Error ? err.message : err}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Collect log files
|
|
253
|
+
const logsDir = path.join(session.dir, 'logs');
|
|
254
|
+
const logs = fs.existsSync(logsDir)
|
|
255
|
+
? fs.readdirSync(logsDir)
|
|
256
|
+
.filter((f) => f.endsWith('.log'))
|
|
257
|
+
.map((f) => {
|
|
258
|
+
const filePath = path.join(logsDir, f);
|
|
259
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
260
|
+
const lines = content ? content.split('\n').filter(Boolean).length : 0;
|
|
261
|
+
return { name: f, path: filePath, lines };
|
|
262
|
+
})
|
|
263
|
+
: [];
|
|
264
|
+
|
|
265
|
+
// Collect anything else dropped in the session dir
|
|
266
|
+
const knownDirs = new Set(['shots', 'a11y', 'logs']);
|
|
267
|
+
const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json']);
|
|
268
|
+
const other = fs.readdirSync(session.dir)
|
|
269
|
+
.filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
|
|
270
|
+
.map((f) => ({ name: f, path: path.join(session.dir, f) }));
|
|
271
|
+
|
|
272
|
+
// Clear active session context
|
|
273
|
+
clearActiveSession();
|
|
274
|
+
|
|
275
|
+
const manifest: BundleManifest = {
|
|
276
|
+
id: session.id,
|
|
277
|
+
startedAt: session.startedAt,
|
|
278
|
+
stoppedAt,
|
|
279
|
+
duration,
|
|
280
|
+
url: session.url,
|
|
281
|
+
screenshots,
|
|
282
|
+
har,
|
|
283
|
+
a11y,
|
|
284
|
+
logs,
|
|
285
|
+
other,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
289
|
+
fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
|
|
290
|
+
|
|
291
|
+
console.log(JSON.stringify({
|
|
292
|
+
bundlePath,
|
|
293
|
+
summary: {
|
|
294
|
+
duration,
|
|
295
|
+
screenshots: screenshots.length,
|
|
296
|
+
harEntries: har?.entryCount ?? 0,
|
|
297
|
+
a11ySnapshots: a11y.length,
|
|
298
|
+
logFiles: logs.length,
|
|
299
|
+
otherFiles: other.length,
|
|
300
|
+
},
|
|
301
|
+
}, null, 2));
|
|
302
|
+
|
|
303
|
+
console.error(`\nBundle written: ${bundlePath}`);
|
|
304
|
+
console.error(`Read it: capture session view ${id}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function list(): void {
|
|
308
|
+
if (!fs.existsSync(CAPTURE_ROOT)) {
|
|
309
|
+
console.log('[]');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const sessions = fs.readdirSync(CAPTURE_ROOT)
|
|
314
|
+
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
315
|
+
.map((d) => {
|
|
316
|
+
const session = readSession(d);
|
|
317
|
+
const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
318
|
+
return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function view(args: string[]): void {
|
|
325
|
+
const id = args[0];
|
|
326
|
+
if (!id) {
|
|
327
|
+
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const session = readSession(id);
|
|
332
|
+
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
333
|
+
|
|
334
|
+
if (!fs.existsSync(bundlePath)) {
|
|
335
|
+
console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const manifest = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as BundleManifest;
|
|
340
|
+
|
|
341
|
+
const filter = args.find((_, i) => args[i - 1] === '--filter');
|
|
342
|
+
if (filter) {
|
|
343
|
+
const section = manifest[filter as keyof BundleManifest];
|
|
344
|
+
console.log(JSON.stringify(section, null, 2));
|
|
345
|
+
} else {
|
|
346
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function sessionMain(args: string[]): Promise<void> {
|
|
351
|
+
const [subcommand, ...rest] = args;
|
|
352
|
+
|
|
353
|
+
switch (subcommand) {
|
|
354
|
+
case 'start': return start(rest);
|
|
355
|
+
case 'stop': return stop(rest);
|
|
356
|
+
case 'list': return list();
|
|
357
|
+
case 'view': return view(rest);
|
|
358
|
+
default:
|
|
359
|
+
console.log(`capture session — manage capture sessions
|
|
360
|
+
|
|
361
|
+
Sub-commands:
|
|
362
|
+
start [--url <url>] Start a session (opens tab, records HAR, sets active context)
|
|
363
|
+
stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
|
|
364
|
+
list List active and stopped sessions
|
|
365
|
+
view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
|
|
366
|
+
|
|
367
|
+
Why sessions: once started, every subsequent capture command auto-fills
|
|
368
|
+
--target (the tab) and --har (the recording). No manual flag threading.
|
|
369
|
+
|
|
370
|
+
Typical flow:
|
|
371
|
+
1. capture session start --url http://localhost:3000
|
|
372
|
+
2. Interact — no --target / --har needed:
|
|
373
|
+
capture a11y --interactive
|
|
374
|
+
capture click "Sign in"
|
|
375
|
+
capture type "hi@me.com" --into "Email"
|
|
376
|
+
capture screenshot
|
|
377
|
+
capture navigate https://app.example.com/dashboard
|
|
378
|
+
capture har read --filter-url /api
|
|
379
|
+
3. capture session stop <session-id>
|
|
380
|
+
4. capture session view <session-id>
|
|
381
|
+
|
|
382
|
+
Related: capture log <path> [--name label] Tail a log into the active session
|
|
383
|
+
See also: capture --help Full command list`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault/bundle — the import-driven exec engine.
|
|
3
|
+
*
|
|
4
|
+
* Ports the bundling mechanism of northlight's
|
|
5
|
+
* `apps/core/src/tool-registry/lib/bundle-agent-code.ts`, DROPPING the telemetry
|
|
6
|
+
* envelope (no `__vallumStartCall`, no `__snapshot`, no `borgEnabled`). When
|
|
7
|
+
* `capture exec` is handed code containing leading static imports, this turns it
|
|
8
|
+
* into a single self-contained IIFE: the user's `libs/<name>` imports are
|
|
9
|
+
* resolved to the forked vault source, esbuild bundles the whole tree on the
|
|
10
|
+
* fly, and the result is a string the exec path evals over CDP.
|
|
11
|
+
*
|
|
12
|
+
* No top-level `esbuild` import — it is loaded lazily via `loadEsbuild()` so the
|
|
13
|
+
* published package (no esbuild) never crashes at startup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import type { Plugin, BuildResult, Message } from "esbuild";
|
|
19
|
+
import {
|
|
20
|
+
isValidLibName,
|
|
21
|
+
loadEsbuild,
|
|
22
|
+
unknownLibMsg,
|
|
23
|
+
vaultLibsDir,
|
|
24
|
+
vaultRepoRoot,
|
|
25
|
+
} from "./paths.js";
|
|
26
|
+
|
|
27
|
+
// Sentinel the post-bundle return-insert anchors on. esbuild with minify:false
|
|
28
|
+
// never renames top-level identifiers, and no first-party lib references
|
|
29
|
+
// __CAPTURE_*, so this literal survives bundling intact and uniquely (the same
|
|
30
|
+
// invariant core relies on for __VALLUM_USER_PROMISE).
|
|
31
|
+
const SENTINEL = "__CAPTURE_RESULT";
|
|
32
|
+
|
|
33
|
+
// Matches a leading run of static import statements (plus any leading comments)
|
|
34
|
+
// per statement: leading ws/comments, then `import` through its single
|
|
35
|
+
// module-specifier string and optional `;`. A normal import has exactly one
|
|
36
|
+
// string literal (the specifier), so this is unambiguous. Ported from
|
|
37
|
+
// bundle-agent-code.ts, with one addition: the `(?!\s*\()` lookahead so a
|
|
38
|
+
// leading dynamic `import(...)` CALL is NOT misclassified as a static import
|
|
39
|
+
// statement (it would otherwise be hoisted and the body run detached, silently
|
|
40
|
+
// returning undefined). Dynamic `import(...)` anywhere in the body is untouched.
|
|
41
|
+
const IMPORT_STMT_RE =
|
|
42
|
+
/^\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*import\b(?!\s*\()[^'"`]*?(['"])(?:[^'"`\\]|\\.)*\1[^\n;]*;?/;
|
|
43
|
+
|
|
44
|
+
function splitLeadingImports(code: string): { imports: string; body: string } {
|
|
45
|
+
const imports: string[] = [];
|
|
46
|
+
let body = code;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const m = IMPORT_STMT_RE.exec(body);
|
|
49
|
+
if (!m) break;
|
|
50
|
+
imports.push(m[0]);
|
|
51
|
+
body = body.slice(m[0].length);
|
|
52
|
+
}
|
|
53
|
+
return { imports: imports.join(""), body };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True iff `code` contains ≥1 leading static `import` statement. This is the
|
|
58
|
+
* trigger exec.ts uses to preserve the no-import fast path: plain exec has no
|
|
59
|
+
* top-level import, so esbuild never runs for it.
|
|
60
|
+
*/
|
|
61
|
+
export function hasImports(code: string): boolean {
|
|
62
|
+
return splitLeadingImports(code).imports.length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Matches a `libs/<name>` module specifier inside a string literal.
|
|
66
|
+
const LIB_SPECIFIER_RE = /(['"])libs\/((?:[^'"\\]|\\.)*)\1/g;
|
|
67
|
+
|
|
68
|
+
// Validate every `libs/<name>` specifier up front. esbuild with treeShaking:true
|
|
69
|
+
// drops an UNUSED import before its resolver ever runs, so a bogus-lib import
|
|
70
|
+
// whose binding is unreferenced would otherwise bundle silently; this guarantees
|
|
71
|
+
// the `Unknown lib` error deterministically, and before esbuild is even loaded
|
|
72
|
+
// (fail fast, before any CDP). Mirrors vaultResolver's existence check exactly.
|
|
73
|
+
function preflightLibs(imports: string): void {
|
|
74
|
+
const libs = vaultLibsDir();
|
|
75
|
+
LIB_SPECIFIER_RE.lastIndex = 0;
|
|
76
|
+
let m: RegExpExecArray | null;
|
|
77
|
+
while ((m = LIB_SPECIFIER_RE.exec(imports)) !== null) {
|
|
78
|
+
const name = m[2];
|
|
79
|
+
if (!isValidLibName(name) || !fs.existsSync(path.join(libs, name, "index.ts"))) {
|
|
80
|
+
throw new Error(unknownLibMsg(name));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hoist the imports to module top level (so esbuild resolves & tree-shakes them)
|
|
86
|
+
// and wrap the body in a result-capturing async IIFE bound to the sentinel. The
|
|
87
|
+
// trailing `.then()` is a preserved no-op call → DCE-safe anchor for the
|
|
88
|
+
// post-bundle return insert (a bare `__CAPTURE_RESULT;` statement can be dropped
|
|
89
|
+
// by tree-shaking; a call survives).
|
|
90
|
+
function buildEntry(code: string): string {
|
|
91
|
+
const { imports, body } = splitLeadingImports(code);
|
|
92
|
+
const expr = body.trim().replace(/;+\s*$/, "");
|
|
93
|
+
return [
|
|
94
|
+
imports,
|
|
95
|
+
`const ${SENTINEL} = (async () => { ${expr} })();`,
|
|
96
|
+
`${SENTINEL}.then();`,
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Maps the two bare specifier shapes (`@vallum/_runtime`, `libs/<name>`) to
|
|
101
|
+
// absolute source paths in the DEFAULT `file` namespace — no custom namespace,
|
|
102
|
+
// no onLoad. esbuild then natively resolves each file's own relative imports
|
|
103
|
+
// (`./context`, `./messages/send`) and npm imports (`zod`) off disk, pulling the
|
|
104
|
+
// whole nested source tree in for free.
|
|
105
|
+
function vaultResolver(): Plugin {
|
|
106
|
+
const libs = vaultLibsDir();
|
|
107
|
+
const runtime = path.join(libs, "_runtime", "index.ts");
|
|
108
|
+
return {
|
|
109
|
+
name: "vault",
|
|
110
|
+
setup(build) {
|
|
111
|
+
build.onResolve({ filter: /^@vallum\/_runtime$/ }, () => ({
|
|
112
|
+
path: runtime,
|
|
113
|
+
}));
|
|
114
|
+
build.onResolve({ filter: /^libs\// }, (args) => {
|
|
115
|
+
const name = args.path.slice("libs/".length);
|
|
116
|
+
if (!isValidLibName(name)) {
|
|
117
|
+
return { errors: [{ text: unknownLibMsg(name) }] };
|
|
118
|
+
}
|
|
119
|
+
const entry = path.join(libs, name, "index.ts");
|
|
120
|
+
return fs.existsSync(entry)
|
|
121
|
+
? { path: entry }
|
|
122
|
+
: { errors: [{ text: unknownLibMsg(name) }] };
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runEsbuild(entry: string): Promise<BuildResult<{ write: false }>> {
|
|
129
|
+
const esbuild = await loadEsbuild();
|
|
130
|
+
const opts = {
|
|
131
|
+
stdin: {
|
|
132
|
+
contents: entry,
|
|
133
|
+
resolveDir: vaultRepoRoot(),
|
|
134
|
+
loader: "ts" as const,
|
|
135
|
+
sourcefile: "exec.ts",
|
|
136
|
+
},
|
|
137
|
+
plugins: [vaultResolver()],
|
|
138
|
+
bundle: true,
|
|
139
|
+
write: false as const,
|
|
140
|
+
format: "iife" as const,
|
|
141
|
+
target: "es2020",
|
|
142
|
+
platform: "browser" as const,
|
|
143
|
+
treeShaking: true,
|
|
144
|
+
minify: false,
|
|
145
|
+
logLevel: "silent" as const,
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
return await esbuild.build(opts);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// esbuild's background process can crash, making all future calls fail with
|
|
151
|
+
// "The service is no longer running". stop() forces a fresh process; retry once.
|
|
152
|
+
if (
|
|
153
|
+
error instanceof Error &&
|
|
154
|
+
error.message.includes("service is no longer running")
|
|
155
|
+
) {
|
|
156
|
+
esbuild.stop();
|
|
157
|
+
return await esbuild.build(opts);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The only post-bundle edit: esbuild's iife wrapper `(() => { … })()` has no
|
|
164
|
+
// return, so the script completion value is undefined. Insert `return ` before
|
|
165
|
+
// the last `__CAPTURE_RESULT.then(` so the IIFE returns the user's promise.
|
|
166
|
+
function applyReturnInsert(bundled: string): string {
|
|
167
|
+
const anchor = `${SENTINEL}.then(`;
|
|
168
|
+
const i = bundled.lastIndexOf(anchor);
|
|
169
|
+
return i === -1 ? bundled : bundled.slice(0, i) + "return " + bundled.slice(i);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatBuildErrors(errors: Message[]): string {
|
|
173
|
+
return errors
|
|
174
|
+
.map((e) => {
|
|
175
|
+
const l = e.location;
|
|
176
|
+
const loc = l ? ` (${l.file}:${l.line}:${l.column})` : "";
|
|
177
|
+
return `${e.text}${loc}`;
|
|
178
|
+
})
|
|
179
|
+
.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Bundle import-bearing exec code into a complete IIFE string that returns the
|
|
184
|
+
* user's promise. Throws an Error whose message formats esbuild's `errors[]`
|
|
185
|
+
* (text + file:line:col) on a build/resolve failure — exec.ts prints it and
|
|
186
|
+
* exits before opening a CDP connection.
|
|
187
|
+
*/
|
|
188
|
+
export async function bundleExec(code: string): Promise<string> {
|
|
189
|
+
preflightLibs(splitLeadingImports(code).imports);
|
|
190
|
+
const entry = buildEntry(code);
|
|
191
|
+
let result: BuildResult<{ write: false }>;
|
|
192
|
+
try {
|
|
193
|
+
result = await runEsbuild(entry);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const errs = (error as { errors?: Message[] })?.errors;
|
|
196
|
+
if (Array.isArray(errs) && errs.length > 0) {
|
|
197
|
+
throw new Error(formatBuildErrors(errs));
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
202
|
+
throw new Error("esbuild produced no output");
|
|
203
|
+
}
|
|
204
|
+
return applyReturnInsert(result.outputFiles[0].text);
|
|
205
|
+
}
|