@pygmalionjs/pygmalion 0.2.38 → 0.2.40

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,653 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ mergeRoutePreviewArtifactV3,
6
+ selectRoutePreviewArtifactFrames,
7
+ validateRoutePreviewArtifactBundle,
8
+ } from './route-preview-artifact-v3.mjs';
9
+
10
+ export const PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION = 1;
11
+
12
+ const MANIFEST_ENTRY_LIMIT = 131_072;
13
+ export const PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION = Object.freeze({
14
+ revisionsPerNamespace: 16,
15
+ variantsPerFrame: 32,
16
+ });
17
+ const LOCK_TIMEOUT_MS = 30_000;
18
+ const LOCK_STALE_MS = 120_000;
19
+ const LOCK_POLL_MS = 40;
20
+ const OBJECT_HASH = /^[a-f0-9]{64}$/u;
21
+ const OBJECT_CACHE_MAX_BYTES = 64 * 1024 * 1024;
22
+
23
+ function sha256(value) {
24
+ return createHash('sha256').update(value).digest('hex');
25
+ }
26
+
27
+ function emptyBundle(namespace, sourceRevision) {
28
+ return {
29
+ version: 3,
30
+ namespace,
31
+ ...(sourceRevision ? { sourceRevision } : {}),
32
+ assets: { head: {}, stylesheets: {}, screenshots: {} },
33
+ frames: {},
34
+ };
35
+ }
36
+
37
+ function validManifestEntry(entry, kind) {
38
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
39
+ if (!OBJECT_HASH.test(entry.object) || !Number.isSafeInteger(entry.generation)) {
40
+ return false;
41
+ }
42
+ if (kind === 'revision') {
43
+ return typeof entry.sourceRevision === 'string' && entry.sourceRevision.length > 0;
44
+ }
45
+ return (
46
+ typeof entry.id === 'string' &&
47
+ entry.id.length > 0 &&
48
+ (entry.fingerprint == null || typeof entry.fingerprint === 'string') &&
49
+ (entry.sourceRevision == null || typeof entry.sourceRevision === 'string')
50
+ );
51
+ }
52
+
53
+ function validateManifest(value, namespace) {
54
+ if (
55
+ !value ||
56
+ typeof value !== 'object' ||
57
+ Array.isArray(value) ||
58
+ value.version !== PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION ||
59
+ value.namespace !== namespace ||
60
+ !Number.isSafeInteger(value.generation) ||
61
+ value.generation < 0 ||
62
+ !Array.isArray(value.revisions) ||
63
+ !Array.isArray(value.frames) ||
64
+ value.revisions.length + value.frames.length > MANIFEST_ENTRY_LIMIT ||
65
+ !value.revisions.every((entry) => validManifestEntry(entry, 'revision')) ||
66
+ !value.frames.every((entry) => validManifestEntry(entry, 'frame'))
67
+ ) {
68
+ throw new Error('Preview artifact store manifest is invalid.');
69
+ }
70
+ return value;
71
+ }
72
+
73
+ async function readJson(file) {
74
+ return JSON.parse(await fs.readFile(file, 'utf8'));
75
+ }
76
+
77
+ async function writeJsonAtomic(file, value) {
78
+ await fs.mkdir(path.dirname(file), { recursive: true });
79
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
80
+ try {
81
+ await fs.writeFile(temporary, `${JSON.stringify(value)}\n`, 'utf8');
82
+ await fs.rename(temporary, file);
83
+ } catch (error) {
84
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ function processIsAlive(pid) {
90
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch (error) {
95
+ return error?.code === 'ESRCH' ? false : true;
96
+ }
97
+ }
98
+
99
+ async function withFilesystemLock(lockPath, task, options = {}) {
100
+ const timeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
101
+ const staleMs = options.lockStaleMs ?? LOCK_STALE_MS;
102
+ const deadline = Date.now() + timeoutMs;
103
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
104
+ while (true) {
105
+ try {
106
+ await fs.mkdir(lockPath);
107
+ break;
108
+ } catch (error) {
109
+ if (error?.code !== 'EEXIST') throw error;
110
+ const stale = await fs
111
+ .stat(lockPath)
112
+ .then((stat) => Date.now() - stat.mtimeMs > staleMs)
113
+ .catch(() => false);
114
+ if (stale) {
115
+ const ownerState = await readJson(path.join(lockPath, 'owner.json'))
116
+ .then((owner) => processIsAlive(owner?.pid))
117
+ .catch(() => null);
118
+ // A delayed but live writer must never lose its lock: it may resume and
119
+ // publish a manifest read before another writer. Recover only when the
120
+ // owning process is confirmed dead, or an old lock never got an owner.
121
+ if (ownerState === false || ownerState === null) {
122
+ await fs.rm(lockPath, { recursive: true, force: true });
123
+ continue;
124
+ }
125
+ }
126
+ if (Date.now() >= deadline) {
127
+ throw new Error('Timed out waiting for the preview artifact store lock.');
128
+ }
129
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
130
+ }
131
+ }
132
+
133
+ const owner = path.join(lockPath, 'owner.json');
134
+ const ownerToken = randomUUID();
135
+ try {
136
+ await fs.writeFile(
137
+ owner,
138
+ `${JSON.stringify({
139
+ token: ownerToken,
140
+ pid: process.pid,
141
+ createdAt: new Date().toISOString(),
142
+ })}\n`,
143
+ 'utf8',
144
+ );
145
+ } catch (error) {
146
+ await fs.rm(lockPath, { recursive: true, force: true });
147
+ throw error;
148
+ }
149
+ const heartbeat = setInterval(() => {
150
+ const now = new Date();
151
+ void fs.utimes(lockPath, now, now).catch(() => undefined);
152
+ }, Math.min(2_000, Math.max(100, Math.floor(staleMs / 4))));
153
+ heartbeat.unref();
154
+ try {
155
+ await options.onLockAcquired?.();
156
+ return await task();
157
+ } finally {
158
+ clearInterval(heartbeat);
159
+ const stillOwned = await readJson(owner)
160
+ .then((value) => value?.token === ownerToken)
161
+ .catch(() => false);
162
+ if (stillOwned) {
163
+ await fs.rm(lockPath, { recursive: true, force: true });
164
+ }
165
+ }
166
+ }
167
+
168
+ function upsertRevision(entries, next) {
169
+ return [
170
+ ...entries.filter((entry) => entry.sourceRevision !== next.sourceRevision),
171
+ next,
172
+ ]
173
+ .sort((a, b) => b.generation - a.generation)
174
+ .slice(
175
+ 0,
176
+ PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.revisionsPerNamespace,
177
+ );
178
+ }
179
+
180
+ function upsertFrame(entries, next) {
181
+ const updated = [
182
+ ...entries.filter(
183
+ (entry) =>
184
+ entry.id !== next.id ||
185
+ entry.fingerprint !== next.fingerprint ||
186
+ entry.sourceRevision !== next.sourceRevision,
187
+ ),
188
+ next,
189
+ ];
190
+ const keptForFrame = new Set(
191
+ updated
192
+ .filter((entry) => entry.id === next.id)
193
+ .sort((a, b) => b.generation - a.generation)
194
+ .slice(0, PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.variantsPerFrame),
195
+ );
196
+ return updated.filter(
197
+ (entry) => entry.id !== next.id || keptForFrame.has(entry),
198
+ );
199
+ }
200
+
201
+ /** The persistent sharded store derived from the compatible artifactFile path. */
202
+ export function resolvePreviewArtifactStoreDirectory(artifactFile) {
203
+ return `${path.resolve(artifactFile)}.store`;
204
+ }
205
+
206
+ export function createPreviewArtifactStore({
207
+ artifactFile,
208
+ storeDirectory = resolvePreviewArtifactStoreDirectory(artifactFile),
209
+ lockTimeoutMs,
210
+ lockStaleMs,
211
+ onLockAcquired,
212
+ } = {}) {
213
+ if (typeof artifactFile !== 'string' || !artifactFile.trim()) {
214
+ throw new TypeError('Preview artifact store requires an artifact file.');
215
+ }
216
+ const legacyFile = path.resolve(artifactFile);
217
+ const root = path.resolve(storeDirectory);
218
+ let legacyCache = null;
219
+ const objectCache = new Map();
220
+ let objectCacheBytes = 0;
221
+
222
+ const rememberObject = (hash, artifact, bytes) => {
223
+ const previous = objectCache.get(hash);
224
+ if (previous) objectCacheBytes -= previous.bytes;
225
+ objectCache.delete(hash);
226
+ objectCache.set(hash, { artifact, bytes });
227
+ objectCacheBytes += bytes;
228
+ while (objectCacheBytes > OBJECT_CACHE_MAX_BYTES && objectCache.size > 1) {
229
+ const oldestHash = objectCache.keys().next().value;
230
+ const oldest = objectCache.get(oldestHash);
231
+ objectCache.delete(oldestHash);
232
+ objectCacheBytes -= oldest?.bytes ?? 0;
233
+ }
234
+ };
235
+
236
+ const forgetObject = (hash) => {
237
+ const cached = objectCache.get(hash);
238
+ if (!cached) return;
239
+ objectCache.delete(hash);
240
+ objectCacheBytes -= cached.bytes;
241
+ };
242
+
243
+ const legacyFileSignature = async () => {
244
+ const stat = await fs.stat(legacyFile);
245
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
246
+ };
247
+
248
+ const namespaceHash = (namespace) => sha256(namespace);
249
+ const manifestPath = (namespace) =>
250
+ path.join(root, 'namespaces', `${namespaceHash(namespace)}.json`);
251
+ const lockPath = (namespace) =>
252
+ path.join(root, 'locks', `${namespaceHash(namespace)}.lock`);
253
+ const objectPath = (hash) =>
254
+ path.join(root, 'objects', hash.slice(0, 2), `${hash}.json`);
255
+
256
+ const readManifest = async (namespace) => {
257
+ try {
258
+ return validateManifest(await readJson(manifestPath(namespace)), namespace);
259
+ } catch (error) {
260
+ if (error?.code === 'ENOENT') return null;
261
+ throw error;
262
+ }
263
+ };
264
+
265
+ const writeObject = async (artifact) => {
266
+ const validation = validateRoutePreviewArtifactBundle(artifact);
267
+ if (!validation.valid) {
268
+ throw new Error('Preview artifact store refused an invalid artifact.');
269
+ }
270
+ const serialized = JSON.stringify(artifact);
271
+ const hash = sha256(serialized);
272
+ const target = objectPath(hash);
273
+ rememberObject(hash, artifact, Buffer.byteLength(serialized));
274
+ try {
275
+ await fs.access(target);
276
+ return hash;
277
+ } catch {
278
+ await writeJsonAtomic(target, artifact);
279
+ return hash;
280
+ }
281
+ };
282
+
283
+ const readObject = async (hash) => {
284
+ if (!OBJECT_HASH.test(hash)) return null;
285
+ const cached = objectCache.get(hash);
286
+ if (cached) {
287
+ objectCache.delete(hash);
288
+ objectCache.set(hash, cached);
289
+ return cached.artifact;
290
+ }
291
+ try {
292
+ const raw = await fs.readFile(objectPath(hash), 'utf8');
293
+ const artifact = JSON.parse(raw);
294
+ const serialized = JSON.stringify(artifact);
295
+ if (sha256(serialized) !== hash) return null;
296
+ if (!validateRoutePreviewArtifactBundle(artifact).valid) return null;
297
+ rememberObject(hash, artifact, Buffer.byteLength(serialized));
298
+ return artifact;
299
+ } catch {
300
+ return null;
301
+ }
302
+ };
303
+
304
+ const materializeManifest = async (manifest, sourceRevision) => {
305
+ const latestById = new Map();
306
+ for (const entry of manifest.frames) {
307
+ const current = latestById.get(entry.id);
308
+ if (!current || current.generation < entry.generation) {
309
+ latestById.set(entry.id, entry);
310
+ }
311
+ }
312
+ let aggregate = null;
313
+ for (const entry of [...latestById.values()].sort((a, b) =>
314
+ a.id.localeCompare(b.id),
315
+ )) {
316
+ const artifact = await readObject(entry.object);
317
+ if (!artifact || artifact.version !== 3 || artifact.namespace !== manifest.namespace) {
318
+ continue;
319
+ }
320
+ aggregate = mergeRoutePreviewArtifactV3(aggregate, artifact);
321
+ }
322
+ if (!aggregate) return null;
323
+ const { sourceRevision: _mixedRevision, ...withoutRevision } = aggregate;
324
+ return {
325
+ ...withoutRevision,
326
+ ...(sourceRevision ? { sourceRevision } : {}),
327
+ };
328
+ };
329
+
330
+ const publishArtifact = async (
331
+ artifact,
332
+ {
333
+ recordRevision = true,
334
+ sourceRevision = artifact?.sourceRevision,
335
+ materializeLegacy = true,
336
+ } = {},
337
+ ) => {
338
+ const validation = validateRoutePreviewArtifactBundle(artifact);
339
+ if (!validation.valid || typeof artifact.namespace !== 'string') {
340
+ throw new Error('Preview artifact store refused an invalid artifact.');
341
+ }
342
+ if (
343
+ recordRevision &&
344
+ sourceRevision &&
345
+ artifact.sourceRevision !== sourceRevision
346
+ ) {
347
+ throw new Error('Preview artifact store refused a mismatched revision.');
348
+ }
349
+ const namespace = artifact.namespace;
350
+ return withFilesystemLock(
351
+ lockPath(namespace),
352
+ async () => {
353
+ // Cross-process correctness depends on this read happening after the
354
+ // lock is held. Every writer merges its entries into the newest manifest.
355
+ const stored = await readManifest(namespace);
356
+ const generation = (stored?.generation ?? 0) + 1;
357
+ let manifest = stored ?? {
358
+ version: PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION,
359
+ namespace,
360
+ generation: 0,
361
+ revisions: [],
362
+ frames: [],
363
+ };
364
+ const previousObjectReferences = new Set(
365
+ [
366
+ ...(stored?.revisions ?? []),
367
+ ...(stored?.frames ?? []),
368
+ ].map((entry) => entry.object),
369
+ );
370
+ if (recordRevision && sourceRevision) {
371
+ const wholeObject = await writeObject(artifact);
372
+ const nextRevision = {
373
+ sourceRevision,
374
+ object: wholeObject,
375
+ generation,
376
+ };
377
+ const alreadyRecorded = manifest.revisions.some(
378
+ (entry) =>
379
+ entry.sourceRevision === nextRevision.sourceRevision &&
380
+ entry.object === nextRevision.object,
381
+ );
382
+ if (!alreadyRecorded) {
383
+ manifest = {
384
+ ...manifest,
385
+ revisions: upsertRevision(manifest.revisions, nextRevision),
386
+ };
387
+ }
388
+ }
389
+ if (artifact.version === 3) {
390
+ let frames = manifest.frames;
391
+ for (const [id, frame] of Object.entries(artifact.frames)) {
392
+ const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
393
+ const object = await writeObject(selected.bundle);
394
+ const nextFrame = {
395
+ id,
396
+ fingerprint:
397
+ typeof frame.fingerprint === 'string' ? frame.fingerprint : null,
398
+ sourceRevision: sourceRevision ?? null,
399
+ object,
400
+ generation,
401
+ };
402
+ const alreadyRecorded = frames.some(
403
+ (entry) =>
404
+ entry.id === nextFrame.id &&
405
+ entry.fingerprint === nextFrame.fingerprint &&
406
+ entry.sourceRevision === nextFrame.sourceRevision &&
407
+ entry.object === nextFrame.object,
408
+ );
409
+ if (!alreadyRecorded) frames = upsertFrame(frames, nextFrame);
410
+ }
411
+ manifest = { ...manifest, frames };
412
+ }
413
+ manifest = { ...manifest, generation };
414
+ if (
415
+ manifest.revisions.length + manifest.frames.length >
416
+ MANIFEST_ENTRY_LIMIT
417
+ ) {
418
+ throw new Error('Preview artifact store manifest is too large.');
419
+ }
420
+ await writeJsonAtomic(manifestPath(namespace), manifest);
421
+
422
+ // Objects contain the namespace, so an identical hash cannot be owned
423
+ // by another namespace. Under this namespace lock it is safe to remove
424
+ // entries retention just made unreachable, after the manifest commit.
425
+ const retainedObjectReferences = new Set(
426
+ [...manifest.revisions, ...manifest.frames].map(
427
+ (entry) => entry.object,
428
+ ),
429
+ );
430
+ await Promise.all(
431
+ [...previousObjectReferences]
432
+ .filter((hash) => !retainedObjectReferences.has(hash))
433
+ .map(async (hash) => {
434
+ await fs.rm(objectPath(hash), { force: true });
435
+ forgetObject(hash);
436
+ }),
437
+ );
438
+
439
+ if (materializeLegacy) {
440
+ // Never stamp a cross-revision frame aggregate with the current
441
+ // revision. The compatibility file remains an honest exact snapshot;
442
+ // endpoint frame selection is served from the manifest above.
443
+ let materialized = recordRevision ? artifact : null;
444
+ if (artifact.version === 3 && !recordRevision) {
445
+ const exactRevision =
446
+ manifest.revisions.find(
447
+ (entry) => entry.sourceRevision === sourceRevision,
448
+ ) ??
449
+ [...manifest.revisions].sort(
450
+ (a, b) => b.generation - a.generation,
451
+ )[0];
452
+ materialized = exactRevision
453
+ ? await readObject(exactRevision.object)
454
+ : null;
455
+ }
456
+ // A partial frame capture is never written as a whole revision. If no
457
+ // exact revision exists yet, leave the compatibility file untouched.
458
+ if (materialized) {
459
+ await writeJsonAtomic(legacyFile, materialized);
460
+ legacyCache = {
461
+ signature: await legacyFileSignature(),
462
+ hash: sha256(JSON.stringify(materialized)),
463
+ status: 'valid',
464
+ artifact: materialized,
465
+ };
466
+ }
467
+ }
468
+ return artifact;
469
+ },
470
+ { lockTimeoutMs, lockStaleMs, onLockAcquired },
471
+ );
472
+ };
473
+
474
+ const importLegacyArtifact = async () => {
475
+ let signature;
476
+ try {
477
+ signature = await legacyFileSignature();
478
+ } catch (error) {
479
+ if (error?.code === 'ENOENT') {
480
+ legacyCache = {
481
+ signature: 'missing',
482
+ status: 'missing',
483
+ artifact: null,
484
+ };
485
+ return legacyCache;
486
+ }
487
+ throw error;
488
+ }
489
+ if (legacyCache?.signature === signature) return legacyCache;
490
+ let raw;
491
+ try {
492
+ raw = await fs.readFile(legacyFile, 'utf8');
493
+ } catch (error) {
494
+ if (error?.code === 'ENOENT') return { status: 'missing', artifact: null };
495
+ throw error;
496
+ }
497
+ let artifact;
498
+ try {
499
+ artifact = JSON.parse(raw);
500
+ } catch {
501
+ legacyCache = { signature, status: 'invalid', artifact: null };
502
+ return legacyCache;
503
+ }
504
+ const hash = sha256(JSON.stringify(artifact));
505
+ if (!validateRoutePreviewArtifactBundle(artifact).valid) {
506
+ legacyCache = {
507
+ signature,
508
+ hash,
509
+ status: 'invalid',
510
+ artifact: null,
511
+ };
512
+ return legacyCache;
513
+ }
514
+ // The compatibility contract treats this file as one exact snapshot. Frame
515
+ // consumers still decide exactness from their requested fingerprints; the
516
+ // idempotent entries below ensure restarting Vite cannot promote this seed
517
+ // above a newer partial capture merely by importing it again.
518
+ await publishArtifact(artifact, { materializeLegacy: false });
519
+ legacyCache = { signature, hash, status: 'valid', artifact };
520
+ return legacyCache;
521
+ };
522
+
523
+ const readExactArtifact = async (namespace, sourceRevision) => {
524
+ const manifest = await readManifest(namespace);
525
+ const entry = manifest?.revisions.find(
526
+ (candidate) => candidate.sourceRevision === sourceRevision,
527
+ );
528
+ if (!entry) return null;
529
+ const artifact = await readObject(entry.object);
530
+ return artifact?.namespace === namespace &&
531
+ artifact.sourceRevision === sourceRevision
532
+ ? artifact
533
+ : null;
534
+ };
535
+
536
+ const readLatestArtifact = async (namespace) => {
537
+ const manifest = await readManifest(namespace);
538
+ if (!manifest) return null;
539
+ const revision = [...manifest.revisions].sort(
540
+ (a, b) => b.generation - a.generation,
541
+ )[0];
542
+ if (revision) {
543
+ const artifact = await readObject(revision.object);
544
+ if (artifact?.namespace === namespace) return artifact;
545
+ }
546
+ return materializeManifest(manifest);
547
+ };
548
+
549
+ const indexFrameCandidates = (manifest, wanted) => {
550
+ const wantedIds = new Set(wanted.map((request) => request.id));
551
+ const candidatesById = new Map();
552
+ for (const entry of manifest?.frames ?? []) {
553
+ if (!wantedIds.has(entry.id)) continue;
554
+ const candidates = candidatesById.get(entry.id) ?? [];
555
+ candidates.push(entry);
556
+ candidatesById.set(entry.id, candidates);
557
+ }
558
+ for (const candidates of candidatesById.values()) {
559
+ candidates.sort((left, right) => right.generation - left.generation);
560
+ }
561
+ return candidatesById;
562
+ };
563
+
564
+ /** Resolves freshness from the compact manifest without loading frame payloads. */
565
+ const resolveFrameSelection = async (namespace, sourceRevision, wanted) => {
566
+ const manifest = await readManifest(namespace);
567
+ const candidatesById = indexFrameCandidates(manifest, wanted);
568
+ const exact = [];
569
+ const missing = [];
570
+ const stale = [];
571
+ for (const request of wanted) {
572
+ const candidates = candidatesById.get(request.id) ?? [];
573
+ if (!candidates.length) {
574
+ missing.push(request.id);
575
+ continue;
576
+ }
577
+ const current = candidates.some(
578
+ (entry) =>
579
+ entry.sourceRevision === sourceRevision &&
580
+ (request.fingerprint == null ||
581
+ entry.fingerprint === request.fingerprint),
582
+ );
583
+ (current ? exact : stale).push(request.id);
584
+ }
585
+ return { exact, missing, stale };
586
+ };
587
+
588
+ const readFrameSelection = async (namespace, sourceRevision, wanted) => {
589
+ const manifest = await readManifest(namespace);
590
+ let aggregate = null;
591
+ if (manifest) {
592
+ // Index once per read. The earlier path filtered the complete manifest for
593
+ // every wanted frame, which made a storyboard probe O(wanted × entries).
594
+ const candidatesById = indexFrameCandidates(manifest, wanted);
595
+ for (const request of wanted) {
596
+ const candidates = candidatesById.get(request.id) ?? [];
597
+ const exact = candidates.filter((entry) =>
598
+ entry.sourceRevision === sourceRevision &&
599
+ (request.fingerprint == null ||
600
+ entry.fingerprint === request.fingerprint),
601
+ );
602
+ const exactSet = new Set(exact);
603
+ const ordered = [
604
+ ...exact,
605
+ ...candidates.filter((entry) => !exactSet.has(entry)),
606
+ ];
607
+ for (const entry of ordered) {
608
+ const artifact = await readObject(entry.object);
609
+ if (
610
+ artifact?.version === 3 &&
611
+ artifact.namespace === namespace &&
612
+ artifact.frames[request.id]
613
+ ) {
614
+ const frame = artifact.frames[request.id];
615
+ const withProvenance = {
616
+ ...artifact,
617
+ frames: {
618
+ ...artifact.frames,
619
+ [request.id]: {
620
+ ...frame,
621
+ ...(entry.sourceRevision == null
622
+ ? {}
623
+ : { sourceRevision: entry.sourceRevision }),
624
+ },
625
+ },
626
+ };
627
+ aggregate = mergeRoutePreviewArtifactV3(
628
+ aggregate,
629
+ withProvenance,
630
+ );
631
+ break;
632
+ }
633
+ }
634
+ }
635
+ }
636
+ return selectRoutePreviewArtifactFrames(
637
+ aggregate ?? emptyBundle(namespace, sourceRevision),
638
+ wanted,
639
+ { includeStale: true, sourceRevision },
640
+ );
641
+ };
642
+
643
+ return {
644
+ artifactFile: legacyFile,
645
+ storeDirectory: root,
646
+ importLegacyArtifact,
647
+ publishArtifact,
648
+ readExactArtifact,
649
+ readFrameSelection,
650
+ readLatestArtifact,
651
+ resolveFrameSelection,
652
+ };
653
+ }
@@ -0,0 +1,26 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ /** Returns a stable, isolated Vite optimizeDeps cache for one preview instance. */
5
+ export function resolvePygmalionPreviewViteCacheDir({
6
+ appRoot,
7
+ instance,
8
+ modulesDirectory = 'node_modules',
9
+ }) {
10
+ if (typeof appRoot !== 'string' || !appRoot.trim()) {
11
+ throw new TypeError('Preview Vite cache requires an app root.');
12
+ }
13
+ if (typeof instance !== 'string' || !instance.trim()) {
14
+ throw new TypeError('Preview Vite cache requires an instance identity.');
15
+ }
16
+ const resolvedRoot = path.resolve(appRoot);
17
+ const digest = createHash('sha256')
18
+ .update(`${resolvedRoot}\u0000${instance}`)
19
+ .digest('hex')
20
+ .slice(0, 20);
21
+ return path.join(
22
+ path.resolve(resolvedRoot, modulesDirectory),
23
+ '.vite-pygmalion',
24
+ digest,
25
+ );
26
+ }