@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.
@@ -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
+ }