@pygmalionjs/pygmalion 0.2.44 → 0.4.0
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-lib/FrozenRoutePreview-B6xc8Ami.js +8059 -0
- package/dist-lib/pygmalion.js +15183 -1329
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/preview-capture-worker.mjs +529 -0
- package/node/storyboard-capture-runtime.mjs +188 -3
- package/node/vite.mjs +42 -2
- package/package.json +4 -2
- package/vite.d.ts +37 -0
- package/dist-lib/App-CCaXx_KA.js +0 -20441
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { validateRoutePreviewArtifactBundle } from './route-preview-artifact-v3.mjs';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_IDLE_MS = 300_000;
|
|
6
|
+
const DEFAULT_JOB_TIMEOUT_MS = 1_200_000;
|
|
7
|
+
const DEFAULT_MAX_RESTARTS = 3;
|
|
8
|
+
const DEFAULT_RESTART_WINDOW_MS = 600_000;
|
|
9
|
+
const DEFAULT_PING_TIMEOUT_MS = 10_000;
|
|
10
|
+
const MAX_RESTART_BACKOFF_MS = 8_000;
|
|
11
|
+
const STOP_KILL_GRACE_MS = 5_000;
|
|
12
|
+
const STDERR_TAIL_LIMIT = 8_000;
|
|
13
|
+
|
|
14
|
+
function assertPositiveNumber(value, name, fallback) {
|
|
15
|
+
if (value == null) return fallback;
|
|
16
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
17
|
+
throw new TypeError(`Preview capture worker ${name} must be a positive number.`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertNonNegativeInteger(value, name, fallback) {
|
|
23
|
+
if (value == null) return fallback;
|
|
24
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
`Preview capture worker ${name} must be a non-negative integer.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeArgs(args) {
|
|
33
|
+
if (args == null) return [];
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(args) ||
|
|
36
|
+
args.some((entry) => typeof entry !== 'string')
|
|
37
|
+
) {
|
|
38
|
+
throw new TypeError(
|
|
39
|
+
'Preview capture worker args must be an array of strings.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return [...args];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeRequest(request) {
|
|
46
|
+
if (!request || typeof request !== 'object') {
|
|
47
|
+
throw new TypeError('Preview capture request must be an object.');
|
|
48
|
+
}
|
|
49
|
+
const { namespace, sourceRevision, frames, captureBaseUrl } = request;
|
|
50
|
+
if (typeof namespace !== 'string' || !namespace.trim()) {
|
|
51
|
+
throw new TypeError('Preview capture request requires a namespace.');
|
|
52
|
+
}
|
|
53
|
+
if (typeof sourceRevision !== 'string' || !sourceRevision.trim()) {
|
|
54
|
+
throw new TypeError('Preview capture request requires a source revision.');
|
|
55
|
+
}
|
|
56
|
+
if (frames != null && !Array.isArray(frames)) {
|
|
57
|
+
throw new TypeError('Preview capture request frames must be an array.');
|
|
58
|
+
}
|
|
59
|
+
if (captureBaseUrl != null && typeof captureBaseUrl !== 'string') {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
'Preview capture request captureBaseUrl must be a string.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
namespace,
|
|
66
|
+
sourceRevision,
|
|
67
|
+
...(frames == null ? {} : { frames: frames.map((frame) => ({ ...frame })) }),
|
|
68
|
+
...(captureBaseUrl == null ? {} : { captureBaseUrl }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function attachLineParser(stream, onMessage) {
|
|
73
|
+
let buffer = '';
|
|
74
|
+
stream.setEncoding('utf8');
|
|
75
|
+
stream.on('data', (chunk) => {
|
|
76
|
+
buffer += chunk;
|
|
77
|
+
let newline = buffer.indexOf('\n');
|
|
78
|
+
while (newline >= 0) {
|
|
79
|
+
const line = buffer.slice(0, newline).trim();
|
|
80
|
+
buffer = buffer.slice(newline + 1);
|
|
81
|
+
newline = buffer.indexOf('\n');
|
|
82
|
+
if (!line) continue;
|
|
83
|
+
let message;
|
|
84
|
+
try {
|
|
85
|
+
message = JSON.parse(line);
|
|
86
|
+
} catch {
|
|
87
|
+
continue; // Not a protocol line; the worker owns its own noise.
|
|
88
|
+
}
|
|
89
|
+
if (message && typeof message === 'object') onMessage(message);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function attachStderrTail(stream, worker) {
|
|
95
|
+
stream.setEncoding('utf8');
|
|
96
|
+
stream.on('data', (chunk) => {
|
|
97
|
+
worker.stderrTail = `${worker.stderrTail}${chunk}`.slice(-STDERR_TAIL_LIMIT);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function consumeArtifactFile(response) {
|
|
102
|
+
const file = response?.artifactFile;
|
|
103
|
+
if (typeof file !== 'string' || !file.trim()) {
|
|
104
|
+
throw new Error('Preview capture worker returned no artifact file.');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const bundle = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
108
|
+
const validation = validateRoutePreviewArtifactBundle(bundle);
|
|
109
|
+
if (!validation.valid) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Preview capture worker artifact is invalid: ${(
|
|
112
|
+
validation.errors ?? ['unknown validation error']
|
|
113
|
+
).join(', ')}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return bundle;
|
|
117
|
+
} finally {
|
|
118
|
+
// The hand-off file is single-use: the bundle now lives in memory and the
|
|
119
|
+
// plugin persists it through the artifact store, so the temp file would
|
|
120
|
+
// only accumulate.
|
|
121
|
+
await fs.rm(file, { force: true }).catch(() => undefined);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function workerFailure(worker, fallbackMessage) {
|
|
126
|
+
const tail = worker.stderrTail.trim();
|
|
127
|
+
return new Error(tail ? `${fallbackMessage}\n${tail}` : fallbackMessage);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Long-lived capture worker channel: one child process that keeps its expensive
|
|
132
|
+
* boot (runtime preflight, case bundling, browser launch) warm across artifact
|
|
133
|
+
* requests, speaking JSON lines over stdio.
|
|
134
|
+
*
|
|
135
|
+
* Protocol: the channel writes `{ id, type: 'capture', payload }` — payload is
|
|
136
|
+
* `{ namespace, sourceRevision, frames?, captureBaseUrl? }` — and the worker
|
|
137
|
+
* answers `{ id, ok: true, artifactFile }` with the bundle in a temp file the
|
|
138
|
+
* channel reads, validates, and deletes, or `{ id, ok: false, error }`.
|
|
139
|
+
* `{ id, type: 'ping' }` must be answered immediately even while booting; the
|
|
140
|
+
* channel uses it to detect a wedged worker before reusing it.
|
|
141
|
+
*
|
|
142
|
+
* Failure policy: a crash rejects the in-flight job and the worker restarts on
|
|
143
|
+
* the next job with capped backoff. After `maxRestarts` crashes inside
|
|
144
|
+
* `restartWindowMs` the channel degrades permanently to spawning the script
|
|
145
|
+
* per request with `--once` — slower, but never dark.
|
|
146
|
+
*/
|
|
147
|
+
export function createPreviewCaptureWorkerChannel({
|
|
148
|
+
script,
|
|
149
|
+
args = [],
|
|
150
|
+
env,
|
|
151
|
+
cwd,
|
|
152
|
+
idleMs = DEFAULT_IDLE_MS,
|
|
153
|
+
jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS,
|
|
154
|
+
maxRestarts = DEFAULT_MAX_RESTARTS,
|
|
155
|
+
restartWindowMs = DEFAULT_RESTART_WINDOW_MS,
|
|
156
|
+
pingTimeoutMs = DEFAULT_PING_TIMEOUT_MS,
|
|
157
|
+
} = {}) {
|
|
158
|
+
if (typeof script !== 'string' || !script.trim()) {
|
|
159
|
+
throw new TypeError(
|
|
160
|
+
'Preview capture worker script must be a non-empty string.',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const workerArgs = normalizeArgs(args);
|
|
164
|
+
if (env != null && (typeof env !== 'object' || Array.isArray(env))) {
|
|
165
|
+
throw new TypeError('Preview capture worker env must be an object.');
|
|
166
|
+
}
|
|
167
|
+
if (cwd != null && (typeof cwd !== 'string' || !cwd.trim())) {
|
|
168
|
+
throw new TypeError('Preview capture worker cwd must be a non-empty string.');
|
|
169
|
+
}
|
|
170
|
+
const resolvedIdleMs = assertPositiveNumber(idleMs, 'idleMs', DEFAULT_IDLE_MS);
|
|
171
|
+
const resolvedJobTimeoutMs = assertPositiveNumber(
|
|
172
|
+
jobTimeoutMs,
|
|
173
|
+
'jobTimeoutMs',
|
|
174
|
+
DEFAULT_JOB_TIMEOUT_MS,
|
|
175
|
+
);
|
|
176
|
+
const resolvedMaxRestarts = assertNonNegativeInteger(
|
|
177
|
+
maxRestarts,
|
|
178
|
+
'maxRestarts',
|
|
179
|
+
DEFAULT_MAX_RESTARTS,
|
|
180
|
+
);
|
|
181
|
+
const resolvedRestartWindowMs = assertPositiveNumber(
|
|
182
|
+
restartWindowMs,
|
|
183
|
+
'restartWindowMs',
|
|
184
|
+
DEFAULT_RESTART_WINDOW_MS,
|
|
185
|
+
);
|
|
186
|
+
const resolvedPingTimeoutMs = assertPositiveNumber(
|
|
187
|
+
pingTimeoutMs,
|
|
188
|
+
'pingTimeoutMs',
|
|
189
|
+
DEFAULT_PING_TIMEOUT_MS,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const spawnEnv = env ? { ...process.env, ...env } : undefined;
|
|
193
|
+
let worker = null;
|
|
194
|
+
let inFlight = false;
|
|
195
|
+
let degraded = false;
|
|
196
|
+
let disposed = false;
|
|
197
|
+
let idleTimer = null;
|
|
198
|
+
let requestCounter = 0;
|
|
199
|
+
const crashTimestamps = [];
|
|
200
|
+
|
|
201
|
+
function nextRequestId() {
|
|
202
|
+
requestCounter += 1;
|
|
203
|
+
return `capture-${process.pid}-${requestCounter}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function recordCrash(now = Date.now()) {
|
|
207
|
+
while (
|
|
208
|
+
crashTimestamps.length > 0 &&
|
|
209
|
+
now - crashTimestamps[0] > resolvedRestartWindowMs
|
|
210
|
+
) {
|
|
211
|
+
crashTimestamps.shift();
|
|
212
|
+
}
|
|
213
|
+
crashTimestamps.push(now);
|
|
214
|
+
if (crashTimestamps.length > resolvedMaxRestarts) degraded = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function restartBackoffMs() {
|
|
218
|
+
const recent = crashTimestamps.length;
|
|
219
|
+
if (recent === 0) return 0;
|
|
220
|
+
return Math.min(500 * 2 ** (recent - 1), MAX_RESTART_BACKOFF_MS);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearIdleTimer() {
|
|
224
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
225
|
+
idleTimer = null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function scheduleIdleShutdown() {
|
|
229
|
+
clearIdleTimer();
|
|
230
|
+
if (!worker || disposed) return;
|
|
231
|
+
idleTimer = setTimeout(() => {
|
|
232
|
+
// Idle means no job is in flight, so the worker drains instantly.
|
|
233
|
+
void stopWorker();
|
|
234
|
+
}, resolvedIdleMs);
|
|
235
|
+
idleTimer.unref?.();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function settlePending(current, settle) {
|
|
239
|
+
const pending = current.pending;
|
|
240
|
+
if (!pending) return;
|
|
241
|
+
current.pending = null;
|
|
242
|
+
clearTimeout(pending.timer);
|
|
243
|
+
settle(pending);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function spawnWorker() {
|
|
247
|
+
const proc = spawn(process.execPath, [script, ...workerArgs], {
|
|
248
|
+
...(cwd ? { cwd } : {}),
|
|
249
|
+
...(spawnEnv ? { env: spawnEnv } : {}),
|
|
250
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
251
|
+
});
|
|
252
|
+
const current = {
|
|
253
|
+
proc,
|
|
254
|
+
pending: null,
|
|
255
|
+
stopping: false,
|
|
256
|
+
stderrTail: '',
|
|
257
|
+
exited: new Promise((resolve) => {
|
|
258
|
+
proc.once('exit', resolve);
|
|
259
|
+
proc.once('error', resolve);
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
attachLineParser(proc.stdout, (message) => {
|
|
263
|
+
const pending = current.pending;
|
|
264
|
+
if (!pending || message.id !== pending.id) return;
|
|
265
|
+
if (pending.kind === 'ping') {
|
|
266
|
+
settlePending(current, ({ resolve }) => resolve(message));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (message.ok === true) {
|
|
270
|
+
settlePending(current, ({ resolve }) => resolve(message));
|
|
271
|
+
} else {
|
|
272
|
+
// A refused job is the worker doing its job — the process stays warm.
|
|
273
|
+
settlePending(current, ({ reject }) =>
|
|
274
|
+
reject(
|
|
275
|
+
new Error(
|
|
276
|
+
typeof message.error === 'string' && message.error
|
|
277
|
+
? message.error
|
|
278
|
+
: 'Preview capture worker reported a failure.',
|
|
279
|
+
),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
attachStderrTail(proc.stderr, current);
|
|
285
|
+
const onGone = () => {
|
|
286
|
+
if (worker === current) worker = null;
|
|
287
|
+
if (current.stopping) return;
|
|
288
|
+
current.stopping = true;
|
|
289
|
+
recordCrash();
|
|
290
|
+
settlePending(current, ({ reject }) =>
|
|
291
|
+
reject(workerFailure(current, 'Preview capture worker exited unexpectedly.')),
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
proc.once('exit', onGone);
|
|
295
|
+
proc.once('error', onGone);
|
|
296
|
+
return current;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function stopWorker() {
|
|
300
|
+
const current = worker;
|
|
301
|
+
if (!current) return;
|
|
302
|
+
worker = null;
|
|
303
|
+
current.stopping = true;
|
|
304
|
+
settlePending(current, ({ reject }) =>
|
|
305
|
+
reject(new Error('Preview capture worker channel is shutting down.')),
|
|
306
|
+
);
|
|
307
|
+
try {
|
|
308
|
+
current.proc.stdin.end();
|
|
309
|
+
} catch {
|
|
310
|
+
// Already closed; the signal below still applies.
|
|
311
|
+
}
|
|
312
|
+
current.proc.kill('SIGTERM');
|
|
313
|
+
const killTimer = setTimeout(() => {
|
|
314
|
+
current.proc.kill('SIGKILL');
|
|
315
|
+
}, STOP_KILL_GRACE_MS);
|
|
316
|
+
killTimer.unref?.();
|
|
317
|
+
await current.exited;
|
|
318
|
+
clearTimeout(killTimer);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function killWorker(current) {
|
|
322
|
+
if (worker === current) worker = null;
|
|
323
|
+
current.stopping = true;
|
|
324
|
+
current.proc.kill('SIGKILL');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function dispatch(current, message, { kind, timeoutMs, onTimeout }) {
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
current.pending = null;
|
|
331
|
+
onTimeout();
|
|
332
|
+
reject(
|
|
333
|
+
workerFailure(
|
|
334
|
+
current,
|
|
335
|
+
kind === 'ping'
|
|
336
|
+
? 'Preview capture worker did not answer a health check.'
|
|
337
|
+
: `Preview capture job timed out after ${timeoutMs}ms.`,
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}, timeoutMs);
|
|
341
|
+
timer.unref?.();
|
|
342
|
+
current.pending = { id: message.id, kind, resolve, reject, timer };
|
|
343
|
+
try {
|
|
344
|
+
current.proc.stdin.write(`${JSON.stringify(message)}\n`);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
settlePending(current, () => undefined);
|
|
347
|
+
reject(
|
|
348
|
+
workerFailure(
|
|
349
|
+
current,
|
|
350
|
+
`Preview capture worker is not accepting requests: ${
|
|
351
|
+
error?.message ?? error
|
|
352
|
+
}`,
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function pingWorker(current) {
|
|
360
|
+
await dispatch(
|
|
361
|
+
current,
|
|
362
|
+
{ id: nextRequestId(), type: 'ping' },
|
|
363
|
+
{
|
|
364
|
+
kind: 'ping',
|
|
365
|
+
timeoutMs: resolvedPingTimeoutMs,
|
|
366
|
+
onTimeout: () => {
|
|
367
|
+
// A worker that cannot answer a ping cannot run a job either.
|
|
368
|
+
recordCrash();
|
|
369
|
+
killWorker(current);
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Returns a live worker, or null when the channel degraded on the way. */
|
|
376
|
+
async function ensureWorker() {
|
|
377
|
+
if (worker) {
|
|
378
|
+
const current = worker;
|
|
379
|
+
try {
|
|
380
|
+
await pingWorker(current);
|
|
381
|
+
return current;
|
|
382
|
+
} catch {
|
|
383
|
+
// The wedged worker was killed and the crash recorded. When that
|
|
384
|
+
// crash exhausted the restart budget, the caller takes the degraded
|
|
385
|
+
// path; otherwise fall through to a fresh spawn below.
|
|
386
|
+
if (degraded) return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const backoff = restartBackoffMs();
|
|
390
|
+
if (backoff > 0) {
|
|
391
|
+
await new Promise((resolve) => {
|
|
392
|
+
const timer = setTimeout(resolve, backoff);
|
|
393
|
+
timer.unref?.();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (disposed) {
|
|
397
|
+
throw new Error('Preview capture worker channel is disposed.');
|
|
398
|
+
}
|
|
399
|
+
worker = spawnWorker();
|
|
400
|
+
return worker;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function runWorkerJob(current, payload) {
|
|
404
|
+
return dispatch(
|
|
405
|
+
current,
|
|
406
|
+
{ id: nextRequestId(), type: 'capture', payload },
|
|
407
|
+
{
|
|
408
|
+
kind: 'capture',
|
|
409
|
+
timeoutMs: resolvedJobTimeoutMs,
|
|
410
|
+
onTimeout: () => {
|
|
411
|
+
recordCrash();
|
|
412
|
+
killWorker(current);
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Degraded path: one spawn per request with `--once`, the same protocol over
|
|
420
|
+
* the child's own stdio. Slow again, but on-demand capture keeps answering.
|
|
421
|
+
*/
|
|
422
|
+
function runOnceJob(payload) {
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
const proc = spawn(
|
|
425
|
+
process.execPath,
|
|
426
|
+
[script, ...workerArgs, '--once'],
|
|
427
|
+
{
|
|
428
|
+
...(cwd ? { cwd } : {}),
|
|
429
|
+
...(spawnEnv ? { env: spawnEnv } : {}),
|
|
430
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
const once = { stderrTail: '' };
|
|
434
|
+
let settled = false;
|
|
435
|
+
const settle = (fn) => {
|
|
436
|
+
if (settled) return;
|
|
437
|
+
settled = true;
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
fn();
|
|
440
|
+
};
|
|
441
|
+
const timer = setTimeout(() => {
|
|
442
|
+
settle(() => {
|
|
443
|
+
proc.kill('SIGKILL');
|
|
444
|
+
reject(
|
|
445
|
+
new Error(`Preview capture job timed out after ${resolvedJobTimeoutMs}ms.`),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
}, resolvedJobTimeoutMs);
|
|
449
|
+
timer.unref?.();
|
|
450
|
+
const id = nextRequestId();
|
|
451
|
+
attachLineParser(proc.stdout, (message) => {
|
|
452
|
+
if (message.id !== id) return;
|
|
453
|
+
if (message.ok === true) {
|
|
454
|
+
settle(() => resolve(message));
|
|
455
|
+
} else {
|
|
456
|
+
settle(() =>
|
|
457
|
+
reject(
|
|
458
|
+
new Error(
|
|
459
|
+
typeof message.error === 'string' && message.error
|
|
460
|
+
? message.error
|
|
461
|
+
: 'Preview capture worker reported a failure.',
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
proc.stdin.end();
|
|
467
|
+
});
|
|
468
|
+
attachStderrTail(proc.stderr, once);
|
|
469
|
+
const onGone = () => {
|
|
470
|
+
settle(() =>
|
|
471
|
+
reject(
|
|
472
|
+
workerFailure(once, 'Preview capture worker exited without a response.'),
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
};
|
|
476
|
+
proc.once('exit', onGone);
|
|
477
|
+
proc.once('error', onGone);
|
|
478
|
+
try {
|
|
479
|
+
proc.stdin.write(
|
|
480
|
+
`${JSON.stringify({ id, type: 'capture', payload })}\n`,
|
|
481
|
+
);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
settle(() =>
|
|
484
|
+
reject(
|
|
485
|
+
new Error(
|
|
486
|
+
`Preview capture worker is not accepting requests: ${
|
|
487
|
+
error?.message ?? error
|
|
488
|
+
}`,
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function generateArtifact(request) {
|
|
497
|
+
if (disposed) {
|
|
498
|
+
throw new Error('Preview capture worker channel is disposed.');
|
|
499
|
+
}
|
|
500
|
+
// The artifact plugin serializes per identity; overlap here means a caller
|
|
501
|
+
// bypassed that queue, and interleaving two captures on one warm browser
|
|
502
|
+
// would let them poison each other.
|
|
503
|
+
if (inFlight) {
|
|
504
|
+
throw new Error('Preview capture worker accepts one job at a time.');
|
|
505
|
+
}
|
|
506
|
+
const payload = normalizeRequest(request);
|
|
507
|
+
inFlight = true;
|
|
508
|
+
clearIdleTimer();
|
|
509
|
+
try {
|
|
510
|
+
const current = degraded ? null : await ensureWorker();
|
|
511
|
+
const response = current
|
|
512
|
+
? await runWorkerJob(current, payload)
|
|
513
|
+
: await runOnceJob(payload);
|
|
514
|
+
return await consumeArtifactFile(response);
|
|
515
|
+
} finally {
|
|
516
|
+
inFlight = false;
|
|
517
|
+
scheduleIdleShutdown();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function dispose() {
|
|
522
|
+
if (disposed) return;
|
|
523
|
+
disposed = true;
|
|
524
|
+
clearIdleTimer();
|
|
525
|
+
await stopWorker();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return { generateArtifact, dispose };
|
|
529
|
+
}
|