@pygmalionjs/pygmalion 0.2.44 → 0.5.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-efexOSqR.js +9416 -0
- package/dist-lib/pygmalion.js +17853 -1374
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/component-branches.mjs +108 -0
- package/node/dev-view.vite.mjs +45 -1
- package/node/preview-artifact-plugin.mjs +20 -1
- package/node/preview-artifact-store.mjs +483 -29
- package/node/preview-capture-worker.mjs +529 -0
- package/node/route-dependency-digest.mjs +151 -0
- package/node/route-preview-artifact-v3.mjs +10 -5
- package/node/source-graph.mjs +258 -0
- package/node/storyboard-capture-runtime.mjs +213 -5
- package/node/storyboard-environment.mjs +97 -10
- package/node/vite.mjs +53 -2
- package/package.json +5 -2
- package/types.d.ts +495 -0
- package/vite.d.ts +68 -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
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
1
5
|
function normalizedRoutePath(value) {
|
|
2
6
|
if (typeof value !== 'string' || !value.trim()) return null;
|
|
3
7
|
try {
|
|
@@ -60,3 +64,150 @@ export function resolvePreviewRouteDependencyDigest(route, entries) {
|
|
|
60
64
|
);
|
|
61
65
|
return candidates[0]?.digest;
|
|
62
66
|
}
|
|
67
|
+
|
|
68
|
+
// Observed dependencies — the source files a captured frame actually rendered.
|
|
69
|
+
//
|
|
70
|
+
// Freshness used to be decided by the source revision, so every commit retired
|
|
71
|
+
// every frame even when a frame's own content could not have changed. A route
|
|
72
|
+
// digest is not a usable replacement in a single-page application: nearly every
|
|
73
|
+
// screen lives on one or two routes, so a route's dependency closure is the
|
|
74
|
+
// whole application and any edit invalidates everything.
|
|
75
|
+
//
|
|
76
|
+
// A frozen snapshot stamps the file each element came from, so the set of files
|
|
77
|
+
// a screen really rendered is recoverable from the capture itself. That set is
|
|
78
|
+
// finer than a route and cannot drift from the truth, because it is an
|
|
79
|
+
// observation rather than a declaration.
|
|
80
|
+
|
|
81
|
+
/** Attributes the DOM importer stamps a source file onto. */
|
|
82
|
+
const SOURCE_ATTRIBUTES = ['data-pygmalion-source', 'data-pygmalion-own-source'];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Minimum stamped elements before an observed set is trusted.
|
|
86
|
+
*
|
|
87
|
+
* A frame whose stamps are missing (a host that never wired sourceComponents,
|
|
88
|
+
* or a capture that failed early) would otherwise read as "depends on nothing"
|
|
89
|
+
* and never be invalidated again. Too few stamps therefore means unknown, and
|
|
90
|
+
* unknown must fall back to the coarse key rather than claim precision.
|
|
91
|
+
*/
|
|
92
|
+
export const OBSERVED_DEPENDENCY_MIN_STAMPS = 8;
|
|
93
|
+
|
|
94
|
+
/** Files whose content decides a screen without ever being stamped. */
|
|
95
|
+
const DEFAULT_ALWAYS_INCLUDED = Object.freeze([]);
|
|
96
|
+
|
|
97
|
+
function normalizedSourcePath(value) {
|
|
98
|
+
if (typeof value !== 'string') return null;
|
|
99
|
+
// A stamp is "<file>|<hash>|<tag>"; only the file participates in identity.
|
|
100
|
+
const file = value.split('|', 1)[0].trim();
|
|
101
|
+
if (!file || file.startsWith('/') || file.includes('..')) return null;
|
|
102
|
+
return file.split(path.sep).join('/');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Source files stamped into a serialized snapshot.
|
|
107
|
+
*
|
|
108
|
+
* Parsing the stored HTML rather than collecting in the page keeps the answer
|
|
109
|
+
* true to what was actually stored: a serializer that prunes or rewrites nodes
|
|
110
|
+
* cannot make the recorded set disagree with the artifact it describes. It also
|
|
111
|
+
* means one function serves every producer, since all of them publish HTML.
|
|
112
|
+
*/
|
|
113
|
+
export function extractObservedSourceFiles(snapshot) {
|
|
114
|
+
// A stored snapshot is `{ document, head, body }`, but callers also hold the
|
|
115
|
+
// raw body string; accept both so one function serves every producer.
|
|
116
|
+
const html =
|
|
117
|
+
typeof snapshot === 'string'
|
|
118
|
+
? snapshot
|
|
119
|
+
: snapshot && typeof snapshot === 'object'
|
|
120
|
+
? [snapshot.document, snapshot.head, snapshot.body]
|
|
121
|
+
.filter((part) => typeof part === 'string')
|
|
122
|
+
.join('\n')
|
|
123
|
+
: '';
|
|
124
|
+
if (!html) {
|
|
125
|
+
return { files: [], stamps: 0 };
|
|
126
|
+
}
|
|
127
|
+
const files = new Set();
|
|
128
|
+
let stamps = 0;
|
|
129
|
+
for (const attribute of SOURCE_ATTRIBUTES) {
|
|
130
|
+
const pattern = new RegExp(`${attribute}="([^"]*)"`, 'g');
|
|
131
|
+
for (const match of html.matchAll(pattern)) {
|
|
132
|
+
stamps += 1;
|
|
133
|
+
const file = normalizedSourcePath(match[1]);
|
|
134
|
+
if (file) files.add(file);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { files: [...files].sort(), stamps };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fileContentHash(root, file) {
|
|
141
|
+
try {
|
|
142
|
+
const resolved = path.resolve(root, file);
|
|
143
|
+
if (!resolved.startsWith(path.resolve(root) + path.sep)) return null;
|
|
144
|
+
const source = await fs.readFile(resolved);
|
|
145
|
+
return createHash('sha256').update(source).digest('hex').slice(0, 32);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Records the observed set as `[path, contentHash]` pairs.
|
|
153
|
+
*
|
|
154
|
+
* Pairs rather than one digest: freshness is then a comparison against the
|
|
155
|
+
* files as they are now, so recording the set never changes the key the entry
|
|
156
|
+
* was written under. Storing a digest in the key instead would retire the very
|
|
157
|
+
* entry that just produced it.
|
|
158
|
+
*/
|
|
159
|
+
export async function recordObservedDependencies({
|
|
160
|
+
snapshot,
|
|
161
|
+
sourceRoot,
|
|
162
|
+
alwaysInclude = DEFAULT_ALWAYS_INCLUDED,
|
|
163
|
+
minStamps = OBSERVED_DEPENDENCY_MIN_STAMPS,
|
|
164
|
+
}) {
|
|
165
|
+
if (typeof sourceRoot !== 'string' || !sourceRoot) return null;
|
|
166
|
+
const { files, stamps } = extractObservedSourceFiles(snapshot);
|
|
167
|
+
if (stamps < minStamps || files.length === 0) return null;
|
|
168
|
+
const wanted = [
|
|
169
|
+
...new Set([
|
|
170
|
+
...files,
|
|
171
|
+
...alwaysInclude
|
|
172
|
+
.map((file) => normalizedSourcePath(file))
|
|
173
|
+
.filter((file) => file != null),
|
|
174
|
+
]),
|
|
175
|
+
].sort();
|
|
176
|
+
const recorded = [];
|
|
177
|
+
for (const file of wanted) {
|
|
178
|
+
const hash = await fileContentHash(sourceRoot, file);
|
|
179
|
+
// A stamped file we cannot read leaves the set unverifiable, and an
|
|
180
|
+
// unverifiable set must not be treated as fresh later.
|
|
181
|
+
if (hash == null) return null;
|
|
182
|
+
recorded.push([file, hash]);
|
|
183
|
+
}
|
|
184
|
+
return recorded;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** True when every recorded file still hashes to what it did at capture time. */
|
|
188
|
+
export async function observedDependenciesUnchanged({ recorded, sourceRoot }) {
|
|
189
|
+
if (!Array.isArray(recorded) || recorded.length === 0) return false;
|
|
190
|
+
if (typeof sourceRoot !== 'string' || !sourceRoot) return false;
|
|
191
|
+
for (const pair of recorded) {
|
|
192
|
+
if (!Array.isArray(pair) || pair.length !== 2) return false;
|
|
193
|
+
const [file, hash] = pair;
|
|
194
|
+
if (typeof file !== 'string' || typeof hash !== 'string') return false;
|
|
195
|
+
const current = await fileContentHash(sourceRoot, file);
|
|
196
|
+
if (current !== hash) return false;
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Normalizes a persisted set, dropping anything malformed. */
|
|
202
|
+
export function normalizeObservedDependencies(value) {
|
|
203
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
204
|
+
const pairs = [];
|
|
205
|
+
for (const pair of value) {
|
|
206
|
+
if (!Array.isArray(pair) || pair.length !== 2) return null;
|
|
207
|
+
const file = normalizedSourcePath(pair[0]);
|
|
208
|
+
const hash = typeof pair[1] === 'string' ? pair[1].trim() : '';
|
|
209
|
+
if (!file || !hash) return null;
|
|
210
|
+
pairs.push([file, hash]);
|
|
211
|
+
}
|
|
212
|
+
return pairs.sort((left, right) => left[0].localeCompare(right[0]));
|
|
213
|
+
}
|
|
@@ -652,11 +652,16 @@ export function selectRoutePreviewArtifactFrames(bundle, wanted, options = {}) {
|
|
|
652
652
|
}
|
|
653
653
|
const fingerprint = isPlainRecord(request) ? request.fingerprint : undefined;
|
|
654
654
|
const frameSourceRevision = frame.sourceRevision ?? bundle.sourceRevision;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
655
|
+
// A fingerprint is content identity, so it decides on its own and the
|
|
656
|
+
// revision stays provenance. Requiring both retired every frame on every
|
|
657
|
+
// commit, even when the fingerprint proved the frame could not differ.
|
|
658
|
+
// Without a fingerprint the revision is the only identity available.
|
|
659
|
+
const staleForRequest =
|
|
660
|
+
fingerprint != null
|
|
661
|
+
? frame.fingerprint !== fingerprint
|
|
662
|
+
: options.sourceRevision != null &&
|
|
663
|
+
frameSourceRevision !== options.sourceRevision;
|
|
664
|
+
if (staleForRequest) {
|
|
660
665
|
stale.push(id);
|
|
661
666
|
if (options.includeStale === true) frames[id] = frame;
|
|
662
667
|
continue;
|