@pygmalionjs/pygmalion 0.2.37 → 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.
- package/dist-lib/{App-BE2sN74X.js → App-CJz8NrxS.js} +3815 -3736
- package/dist-lib/pygmalion.js +678 -684
- package/dist-lib/testing.js +1 -1
- package/node/design-session.mjs +6 -0
- package/node/dev-mirror.mjs +8 -2
- package/node/dev-view.vite.mjs +4 -0
- package/node/preview-artifact-plugin.mjs +0 -0
- package/node/preview-artifact-store.mjs +530 -0
- package/node/preview-vite-cache.mjs +26 -0
- package/node/route-dependency-digest.mjs +62 -0
- package/node/route-preview-artifact-v3.mjs +2 -1
- package/node/storyboard.mjs +2 -0
- package/node/vite.mjs +6 -0
- package/package.json +4 -1
- package/storyboard.d.ts +1 -0
- package/vite.d.ts +12 -0
package/dist-lib/testing.js
CHANGED
package/node/design-session.mjs
CHANGED
|
@@ -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
|
},
|
package/node/dev-mirror.mjs
CHANGED
|
@@ -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 = {
|
package/node/dev-view.vite.mjs
CHANGED
|
@@ -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
|
+
}
|