@pygmalionjs/pygmalion 0.2.38 → 0.2.39

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.
@@ -1,4 +1,4 @@
1
- import { F as s, N as a, e as r, s as i, a as t } from "./App-DTeXtV9b.js";
1
+ import { F as s, N as a, e as r, s as i, a as t } from "./App-CJz8NrxS.js";
2
2
  export {
3
3
  s as FrozenRoutePreviewView,
4
4
  a as NodeModel,
@@ -11,6 +11,7 @@ import { normalizeViteMode } from './dev-mirror.mjs';
11
11
  import { writeStyleEdit, writeTextEdit } from './inspect-plugin.mjs';
12
12
  import { createUnifiedDiff } from './source-diff.mjs';
13
13
  import { writeSourceOperations } from './source-operations.mjs';
14
+ import { resolvePygmalionPreviewViteCacheDir } from './preview-vite-cache.mjs';
14
15
 
15
16
  const execFileAsync = promisify(execFile);
16
17
 
@@ -526,6 +527,11 @@ export function pygmalionDesignSessionPlugin(options) {
526
527
  PYGMALION_APP_ROOT: sessionAppRoot,
527
528
  PYGMALION_VITE_CONFIG: viteConfig,
528
529
  PYGMALION_PREVIEW_BASE: `${sessionPreviewPrefix}/`,
530
+ PYGMALION_VITE_CACHE_DIR: resolvePygmalionPreviewViteCacheDir({
531
+ appRoot: sessionAppRoot,
532
+ instance: `session:${session.id}:${session.headSha}`,
533
+ modulesDirectory: dependencies.modulesDirectory,
534
+ }),
529
535
  PYGMALION_DEV_FRONTEND_ROOT: sessionAppRoot,
530
536
  PYGMALION_DEV_BASE: `${sessionPreviewPrefix}/`,
531
537
  },
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
9
  import { execFile } from 'node:child_process';
10
+ import { resolvePygmalionPreviewViteCacheDir } from './preview-vite-cache.mjs';
10
11
 
11
12
  const execFileAsync = promisify(execFile);
12
13
 
@@ -864,7 +865,7 @@ export function pygmalionDevMirrorPlugin(options) {
864
865
  await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
865
866
  };
866
867
 
867
- const startPreview = async (restart) => {
868
+ const startPreview = async (restart, sourceIdentity = ref) => {
868
869
  if (restart) await stopPreview();
869
870
  if (previewChild && previewChild.exitCode == null && previewPort != null) return;
870
871
 
@@ -900,6 +901,11 @@ export function pygmalionDevMirrorPlugin(options) {
900
901
  PYGMALION_APP_ROOT: mirrorAppRoot,
901
902
  PYGMALION_VITE_CONFIG: viteConfig,
902
903
  PYGMALION_PREVIEW_BASE: `${prefix}/`,
904
+ PYGMALION_VITE_CACHE_DIR: resolvePygmalionPreviewViteCacheDir({
905
+ appRoot: mirrorAppRoot,
906
+ instance: `mirror:${sourceIdentity}:${prefix}`,
907
+ modulesDirectory: dependencies.modulesDirectory,
908
+ }),
903
909
  // Legacy names keep older preview configs working.
904
910
  PYGMALION_DEV_FRONTEND_ROOT: mirrorAppRoot,
905
911
  PYGMALION_DEV_BASE: `${prefix}/`,
@@ -963,7 +969,7 @@ export function pygmalionDevMirrorPlugin(options) {
963
969
  await claimDevMirror(repoRoot, mirrorRoot, ownerToken);
964
970
  const dependenciesChanged = await syncDependencies();
965
971
  await generateInventory(commit);
966
- await startPreview(dependenciesChanged);
972
+ await startPreview(dependenciesChanged, commit);
967
973
 
968
974
  revision += 1;
969
975
  status = {
@@ -14,6 +14,9 @@ const previewBase =
14
14
  process.env.PYGMALION_PREVIEW_BASE ||
15
15
  process.env.PYGMALION_DEV_BASE ||
16
16
  '/__pygmalion-dev/';
17
+ const previewCacheDir = process.env.PYGMALION_VITE_CACHE_DIR
18
+ ? path.resolve(process.env.PYGMALION_VITE_CACHE_DIR)
19
+ : undefined;
17
20
  export const PREVIEW_RUNTIME_DEDUPE = ['react', 'react-dom'];
18
21
 
19
22
  function previewRuntimePlugin() {
@@ -49,6 +52,7 @@ export default defineConfig(async (env) => {
49
52
  return mergeConfig(loaded.config, {
50
53
  root: appRoot,
51
54
  base: previewBase,
55
+ ...(previewCacheDir ? { cacheDir: previewCacheDir } : {}),
52
56
  resolve: {
53
57
  // A host config can resolve editor and application dependencies through
54
58
  // different package roots. A preview must still run one React dispatcher;
Binary file
@@ -0,0 +1,530 @@
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
+
22
+ function sha256(value) {
23
+ return createHash('sha256').update(value).digest('hex');
24
+ }
25
+
26
+ function emptyBundle(namespace, sourceRevision) {
27
+ return {
28
+ version: 3,
29
+ namespace,
30
+ ...(sourceRevision ? { sourceRevision } : {}),
31
+ assets: { head: {}, stylesheets: {}, screenshots: {} },
32
+ frames: {},
33
+ };
34
+ }
35
+
36
+ function validManifestEntry(entry, kind) {
37
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
38
+ if (!OBJECT_HASH.test(entry.object) || !Number.isSafeInteger(entry.generation)) {
39
+ return false;
40
+ }
41
+ if (kind === 'revision') {
42
+ return typeof entry.sourceRevision === 'string' && entry.sourceRevision.length > 0;
43
+ }
44
+ return (
45
+ typeof entry.id === 'string' &&
46
+ entry.id.length > 0 &&
47
+ (entry.fingerprint == null || typeof entry.fingerprint === 'string') &&
48
+ (entry.sourceRevision == null || typeof entry.sourceRevision === 'string')
49
+ );
50
+ }
51
+
52
+ function validateManifest(value, namespace) {
53
+ if (
54
+ !value ||
55
+ typeof value !== 'object' ||
56
+ Array.isArray(value) ||
57
+ value.version !== PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION ||
58
+ value.namespace !== namespace ||
59
+ !Number.isSafeInteger(value.generation) ||
60
+ value.generation < 0 ||
61
+ !Array.isArray(value.revisions) ||
62
+ !Array.isArray(value.frames) ||
63
+ value.revisions.length + value.frames.length > MANIFEST_ENTRY_LIMIT ||
64
+ !value.revisions.every((entry) => validManifestEntry(entry, 'revision')) ||
65
+ !value.frames.every((entry) => validManifestEntry(entry, 'frame'))
66
+ ) {
67
+ throw new Error('Preview artifact store manifest is invalid.');
68
+ }
69
+ return value;
70
+ }
71
+
72
+ async function readJson(file) {
73
+ return JSON.parse(await fs.readFile(file, 'utf8'));
74
+ }
75
+
76
+ async function writeJsonAtomic(file, value) {
77
+ await fs.mkdir(path.dirname(file), { recursive: true });
78
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
79
+ try {
80
+ await fs.writeFile(temporary, `${JSON.stringify(value)}\n`, 'utf8');
81
+ await fs.rename(temporary, file);
82
+ } catch (error) {
83
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ function processIsAlive(pid) {
89
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch (error) {
94
+ return error?.code === 'ESRCH' ? false : true;
95
+ }
96
+ }
97
+
98
+ async function withFilesystemLock(lockPath, task, options = {}) {
99
+ const timeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
100
+ const staleMs = options.lockStaleMs ?? LOCK_STALE_MS;
101
+ const deadline = Date.now() + timeoutMs;
102
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
103
+ while (true) {
104
+ try {
105
+ await fs.mkdir(lockPath);
106
+ break;
107
+ } catch (error) {
108
+ if (error?.code !== 'EEXIST') throw error;
109
+ const stale = await fs
110
+ .stat(lockPath)
111
+ .then((stat) => Date.now() - stat.mtimeMs > staleMs)
112
+ .catch(() => false);
113
+ if (stale) {
114
+ const ownerState = await readJson(path.join(lockPath, 'owner.json'))
115
+ .then((owner) => processIsAlive(owner?.pid))
116
+ .catch(() => null);
117
+ // A delayed but live writer must never lose its lock: it may resume and
118
+ // publish a manifest read before another writer. Recover only when the
119
+ // owning process is confirmed dead, or an old lock never got an owner.
120
+ if (ownerState === false || ownerState === null) {
121
+ await fs.rm(lockPath, { recursive: true, force: true });
122
+ continue;
123
+ }
124
+ }
125
+ if (Date.now() >= deadline) {
126
+ throw new Error('Timed out waiting for the preview artifact store lock.');
127
+ }
128
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
129
+ }
130
+ }
131
+
132
+ const owner = path.join(lockPath, 'owner.json');
133
+ const ownerToken = randomUUID();
134
+ try {
135
+ await fs.writeFile(
136
+ owner,
137
+ `${JSON.stringify({
138
+ token: ownerToken,
139
+ pid: process.pid,
140
+ createdAt: new Date().toISOString(),
141
+ })}\n`,
142
+ 'utf8',
143
+ );
144
+ } catch (error) {
145
+ await fs.rm(lockPath, { recursive: true, force: true });
146
+ throw error;
147
+ }
148
+ const heartbeat = setInterval(() => {
149
+ const now = new Date();
150
+ void fs.utimes(lockPath, now, now).catch(() => undefined);
151
+ }, Math.min(2_000, Math.max(100, Math.floor(staleMs / 4))));
152
+ heartbeat.unref();
153
+ try {
154
+ await options.onLockAcquired?.();
155
+ return await task();
156
+ } finally {
157
+ clearInterval(heartbeat);
158
+ const stillOwned = await readJson(owner)
159
+ .then((value) => value?.token === ownerToken)
160
+ .catch(() => false);
161
+ if (stillOwned) {
162
+ await fs.rm(lockPath, { recursive: true, force: true });
163
+ }
164
+ }
165
+ }
166
+
167
+ function upsertRevision(entries, next) {
168
+ return [
169
+ ...entries.filter((entry) => entry.sourceRevision !== next.sourceRevision),
170
+ next,
171
+ ]
172
+ .sort((a, b) => b.generation - a.generation)
173
+ .slice(
174
+ 0,
175
+ PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.revisionsPerNamespace,
176
+ );
177
+ }
178
+
179
+ function upsertFrame(entries, next) {
180
+ const updated = [
181
+ ...entries.filter(
182
+ (entry) =>
183
+ entry.id !== next.id ||
184
+ entry.fingerprint !== next.fingerprint ||
185
+ (next.fingerprint == null &&
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
+
220
+ const namespaceHash = (namespace) => sha256(namespace);
221
+ const manifestPath = (namespace) =>
222
+ path.join(root, 'namespaces', `${namespaceHash(namespace)}.json`);
223
+ const lockPath = (namespace) =>
224
+ path.join(root, 'locks', `${namespaceHash(namespace)}.lock`);
225
+ const objectPath = (hash) =>
226
+ path.join(root, 'objects', hash.slice(0, 2), `${hash}.json`);
227
+
228
+ const readManifest = async (namespace) => {
229
+ try {
230
+ return validateManifest(await readJson(manifestPath(namespace)), namespace);
231
+ } catch (error) {
232
+ if (error?.code === 'ENOENT') return null;
233
+ throw error;
234
+ }
235
+ };
236
+
237
+ const writeObject = async (artifact) => {
238
+ const validation = validateRoutePreviewArtifactBundle(artifact);
239
+ if (!validation.valid) {
240
+ throw new Error('Preview artifact store refused an invalid artifact.');
241
+ }
242
+ const serialized = JSON.stringify(artifact);
243
+ const hash = sha256(serialized);
244
+ const target = objectPath(hash);
245
+ try {
246
+ await fs.access(target);
247
+ return hash;
248
+ } catch {
249
+ await writeJsonAtomic(target, artifact);
250
+ return hash;
251
+ }
252
+ };
253
+
254
+ const readObject = async (hash) => {
255
+ if (!OBJECT_HASH.test(hash)) return null;
256
+ try {
257
+ const raw = await fs.readFile(objectPath(hash), 'utf8');
258
+ const artifact = JSON.parse(raw);
259
+ if (sha256(JSON.stringify(artifact)) !== hash) return null;
260
+ return validateRoutePreviewArtifactBundle(artifact).valid ? artifact : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ };
265
+
266
+ const materializeManifest = async (manifest, sourceRevision) => {
267
+ const latestById = new Map();
268
+ for (const entry of manifest.frames) {
269
+ const current = latestById.get(entry.id);
270
+ if (!current || current.generation < entry.generation) {
271
+ latestById.set(entry.id, entry);
272
+ }
273
+ }
274
+ let aggregate = null;
275
+ for (const entry of [...latestById.values()].sort((a, b) =>
276
+ a.id.localeCompare(b.id),
277
+ )) {
278
+ const artifact = await readObject(entry.object);
279
+ if (!artifact || artifact.version !== 3 || artifact.namespace !== manifest.namespace) {
280
+ continue;
281
+ }
282
+ aggregate = mergeRoutePreviewArtifactV3(aggregate, artifact);
283
+ }
284
+ if (!aggregate) return null;
285
+ const { sourceRevision: _mixedRevision, ...withoutRevision } = aggregate;
286
+ return {
287
+ ...withoutRevision,
288
+ ...(sourceRevision ? { sourceRevision } : {}),
289
+ };
290
+ };
291
+
292
+ const publishArtifact = async (
293
+ artifact,
294
+ {
295
+ recordRevision = true,
296
+ sourceRevision = artifact?.sourceRevision,
297
+ materializeLegacy = true,
298
+ } = {},
299
+ ) => {
300
+ const validation = validateRoutePreviewArtifactBundle(artifact);
301
+ if (!validation.valid || typeof artifact.namespace !== 'string') {
302
+ throw new Error('Preview artifact store refused an invalid artifact.');
303
+ }
304
+ if (
305
+ recordRevision &&
306
+ sourceRevision &&
307
+ artifact.sourceRevision !== sourceRevision
308
+ ) {
309
+ throw new Error('Preview artifact store refused a mismatched revision.');
310
+ }
311
+ const namespace = artifact.namespace;
312
+ return withFilesystemLock(
313
+ lockPath(namespace),
314
+ async () => {
315
+ // Cross-process correctness depends on this read happening after the
316
+ // lock is held. Every writer merges its entries into the newest manifest.
317
+ const stored = await readManifest(namespace);
318
+ const generation = (stored?.generation ?? 0) + 1;
319
+ let manifest = stored ?? {
320
+ version: PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION,
321
+ namespace,
322
+ generation: 0,
323
+ revisions: [],
324
+ frames: [],
325
+ };
326
+ const previousObjectReferences = new Set(
327
+ [
328
+ ...(stored?.revisions ?? []),
329
+ ...(stored?.frames ?? []),
330
+ ].map((entry) => entry.object),
331
+ );
332
+ if (recordRevision && sourceRevision) {
333
+ const wholeObject = await writeObject(artifact);
334
+ const nextRevision = {
335
+ sourceRevision,
336
+ object: wholeObject,
337
+ generation,
338
+ };
339
+ const alreadyRecorded = manifest.revisions.some(
340
+ (entry) =>
341
+ entry.sourceRevision === nextRevision.sourceRevision &&
342
+ entry.object === nextRevision.object,
343
+ );
344
+ if (!alreadyRecorded) {
345
+ manifest = {
346
+ ...manifest,
347
+ revisions: upsertRevision(manifest.revisions, nextRevision),
348
+ };
349
+ }
350
+ }
351
+ if (artifact.version === 3) {
352
+ let frames = manifest.frames;
353
+ for (const [id, frame] of Object.entries(artifact.frames)) {
354
+ const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
355
+ const object = await writeObject(selected.bundle);
356
+ const nextFrame = {
357
+ id,
358
+ fingerprint:
359
+ typeof frame.fingerprint === 'string' ? frame.fingerprint : null,
360
+ sourceRevision: sourceRevision ?? null,
361
+ object,
362
+ generation,
363
+ };
364
+ const alreadyRecorded = frames.some(
365
+ (entry) =>
366
+ entry.id === nextFrame.id &&
367
+ entry.fingerprint === nextFrame.fingerprint &&
368
+ entry.sourceRevision === nextFrame.sourceRevision &&
369
+ entry.object === nextFrame.object,
370
+ );
371
+ if (!alreadyRecorded) frames = upsertFrame(frames, nextFrame);
372
+ }
373
+ manifest = { ...manifest, frames };
374
+ }
375
+ manifest = { ...manifest, generation };
376
+ if (
377
+ manifest.revisions.length + manifest.frames.length >
378
+ MANIFEST_ENTRY_LIMIT
379
+ ) {
380
+ throw new Error('Preview artifact store manifest is too large.');
381
+ }
382
+ await writeJsonAtomic(manifestPath(namespace), manifest);
383
+
384
+ // Objects contain the namespace, so an identical hash cannot be owned
385
+ // by another namespace. Under this namespace lock it is safe to remove
386
+ // entries retention just made unreachable, after the manifest commit.
387
+ const retainedObjectReferences = new Set(
388
+ [...manifest.revisions, ...manifest.frames].map(
389
+ (entry) => entry.object,
390
+ ),
391
+ );
392
+ await Promise.all(
393
+ [...previousObjectReferences]
394
+ .filter((hash) => !retainedObjectReferences.has(hash))
395
+ .map((hash) => fs.rm(objectPath(hash), { force: true })),
396
+ );
397
+
398
+ if (materializeLegacy) {
399
+ // Never stamp a cross-revision frame aggregate with the current
400
+ // revision. The compatibility file remains an honest exact snapshot;
401
+ // endpoint frame selection is served from the manifest above.
402
+ let materialized = recordRevision ? artifact : null;
403
+ if (artifact.version === 3 && !recordRevision) {
404
+ const exactRevision =
405
+ manifest.revisions.find(
406
+ (entry) => entry.sourceRevision === sourceRevision,
407
+ ) ??
408
+ [...manifest.revisions].sort(
409
+ (a, b) => b.generation - a.generation,
410
+ )[0];
411
+ materialized = exactRevision
412
+ ? await readObject(exactRevision.object)
413
+ : null;
414
+ }
415
+ // A partial frame capture is never written as a whole revision. If no
416
+ // exact revision exists yet, leave the compatibility file untouched.
417
+ if (materialized) {
418
+ await writeJsonAtomic(legacyFile, materialized);
419
+ legacyCache = {
420
+ hash: sha256(JSON.stringify(materialized)),
421
+ status: 'valid',
422
+ artifact: materialized,
423
+ };
424
+ }
425
+ }
426
+ return artifact;
427
+ },
428
+ { lockTimeoutMs, lockStaleMs, onLockAcquired },
429
+ );
430
+ };
431
+
432
+ const importLegacyArtifact = async () => {
433
+ let raw;
434
+ try {
435
+ raw = await fs.readFile(legacyFile, 'utf8');
436
+ } catch (error) {
437
+ if (error?.code === 'ENOENT') return { status: 'missing', artifact: null };
438
+ throw error;
439
+ }
440
+ let artifact;
441
+ try {
442
+ artifact = JSON.parse(raw);
443
+ } catch {
444
+ return { status: 'invalid', artifact: null };
445
+ }
446
+ const hash = sha256(JSON.stringify(artifact));
447
+ if (legacyCache?.hash === hash) return legacyCache;
448
+ if (!validateRoutePreviewArtifactBundle(artifact).valid) {
449
+ legacyCache = { hash, status: 'invalid', artifact: null };
450
+ return legacyCache;
451
+ }
452
+ // The compatibility contract treats this file as one exact snapshot. Frame
453
+ // consumers still decide exactness from their requested fingerprints; the
454
+ // idempotent entries below ensure restarting Vite cannot promote this seed
455
+ // above a newer partial capture merely by importing it again.
456
+ await publishArtifact(artifact, { materializeLegacy: false });
457
+ legacyCache = { hash, status: 'valid', artifact };
458
+ return legacyCache;
459
+ };
460
+
461
+ const readExactArtifact = async (namespace, sourceRevision) => {
462
+ const manifest = await readManifest(namespace);
463
+ const entry = manifest?.revisions.find(
464
+ (candidate) => candidate.sourceRevision === sourceRevision,
465
+ );
466
+ if (!entry) return null;
467
+ const artifact = await readObject(entry.object);
468
+ return artifact?.namespace === namespace &&
469
+ artifact.sourceRevision === sourceRevision
470
+ ? artifact
471
+ : null;
472
+ };
473
+
474
+ const readLatestArtifact = async (namespace) => {
475
+ const manifest = await readManifest(namespace);
476
+ if (!manifest) return null;
477
+ const revision = [...manifest.revisions].sort(
478
+ (a, b) => b.generation - a.generation,
479
+ )[0];
480
+ if (revision) {
481
+ const artifact = await readObject(revision.object);
482
+ if (artifact?.namespace === namespace) return artifact;
483
+ }
484
+ return materializeManifest(manifest);
485
+ };
486
+
487
+ const readFrameSelection = async (namespace, sourceRevision, wanted) => {
488
+ const manifest = await readManifest(namespace);
489
+ let aggregate = null;
490
+ if (manifest) {
491
+ for (const request of wanted) {
492
+ const candidates = manifest.frames
493
+ .filter((entry) => entry.id === request.id)
494
+ .sort((a, b) => b.generation - a.generation);
495
+ const exact = candidates.filter((entry) =>
496
+ request.fingerprint != null
497
+ ? entry.fingerprint === request.fingerprint
498
+ : entry.sourceRevision === sourceRevision,
499
+ );
500
+ const ordered = [...exact, ...candidates.filter((entry) => !exact.includes(entry))];
501
+ for (const entry of ordered) {
502
+ const artifact = await readObject(entry.object);
503
+ if (
504
+ artifact?.version === 3 &&
505
+ artifact.namespace === namespace &&
506
+ artifact.frames[request.id]
507
+ ) {
508
+ aggregate = mergeRoutePreviewArtifactV3(aggregate, artifact);
509
+ break;
510
+ }
511
+ }
512
+ }
513
+ }
514
+ return selectRoutePreviewArtifactFrames(
515
+ aggregate ?? emptyBundle(namespace, sourceRevision),
516
+ wanted,
517
+ { includeStale: true },
518
+ );
519
+ };
520
+
521
+ return {
522
+ artifactFile: legacyFile,
523
+ storeDirectory: root,
524
+ importLegacyArtifact,
525
+ publishArtifact,
526
+ readExactArtifact,
527
+ readFrameSelection,
528
+ readLatestArtifact,
529
+ };
530
+ }
@@ -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
+ }