@pygmalionjs/pygmalion 0.5.15 → 0.5.17

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 "./FrozenRoutePreview-BR5CBAD3.js";
1
+ import { F as s, N as a, e as r, s as i, a as t } from "./FrozenRoutePreview-CZOpvN6z.js";
2
2
  export {
3
3
  s as FrozenRoutePreviewView,
4
4
  a as NodeModel,
@@ -87,6 +87,25 @@ export interface DesignChangeResult {
87
87
  export declare function setAppOrigin(origin: string): void;
88
88
  export declare function getAppOrigin(): string;
89
89
  export declare function subscribeAppOrigin(listener: () => void): () => void;
90
+ export declare function setArtifactOrigin(origin: string): void;
91
+ /**
92
+ * Origin a captured snapshot resolves its relative assets against.
93
+ *
94
+ * This is not the same question as "where does the live screen run". A capture is
95
+ * already rendered — it needs somewhere to fetch the images and fonts its markup
96
+ * still points at — while a live frame needs the checkout of a specific revision.
97
+ * Answering both with one value tied the catalog to a checkout that takes minutes
98
+ * to build, so opening the editor showed a spinner instead of the frames sitting
99
+ * on disk.
100
+ *
101
+ * The order is most-accurate-first: an origin the host declared, then the live
102
+ * one once a runtime exists, then the editor's own — which serves the same
103
+ * application and lets the catalog paint immediately. Assets it cannot resolve
104
+ * are the ones the working tree no longer has; the live origin replaces them the
105
+ * moment it comes up, because these readers re-run when it does.
106
+ */
107
+ export declare function getArtifactOrigin(): string;
108
+ export declare function subscribeArtifactOrigin(listener: () => void): () => void;
90
109
  /** Baseline code version of the preview (dev SHA, etc.). When a change is made, a new static screen cache of the same URL is also created. */
91
110
  export declare function setPreviewRevision(revision: string): void;
92
111
  export declare function getPreviewRevision(): string;
@@ -77,7 +77,7 @@ export type RoutePreviewArtifactResolution = {
77
77
  status: 'generating';
78
78
  } | {
79
79
  status: 'error';
80
- reason: 'unavailable' | 'rejected';
80
+ reason: 'unavailable' | 'rejected' | 'not-prepared';
81
81
  message: string;
82
82
  } | {
83
83
  status: 'exact';
@@ -30,6 +30,7 @@ export { createDesignComponentCatalog, createDesignComponentVariantMatrix, creat
30
30
  export type { DesignCatalogBuild, DesignCatalogFrame, DesignCatalogItem, DesignCatalogKind, DesignCatalogPageDescriptor, DesignComponentCatalogOptions, DesignComponentVariantMatrix, DesignGalleryCatalogOptions, PygmalionCatalogDocumentProps, PygmalionCatalogMatrixProps, PygmalionCatalogSectionProps, } from './editor/catalog';
31
31
  export { setTokens, applyTokenOverride, resetTokenOverrides, } from './editor/tokens';
32
32
  export { setAppOrigin as setPygmalionAppOrigin } from './editor/host';
33
+ export { setArtifactOrigin as setPygmalionArtifactOrigin } from './editor/host';
33
34
  export { setPreviewRevision as setPygmalionPreviewRevision } from './editor/host';
34
35
  export { clearRoutePreviewSnapshots as clearPygmalionPreviewCache, recommendedRoutePreviewConcurrency as getRecommendedPygmalionPreviewConcurrency, setRoutePreviewConcurrency as setPygmalionPreviewConcurrency, } from './editor/routePreview';
35
36
  export type { RoutePreviewArtifactBundle } from './editor/routePreview';
@@ -100,6 +101,25 @@ export declare const __debug: {
100
101
  } | null;
101
102
  hasShadowArtifact: boolean;
102
103
  hasSnapshot: boolean;
104
+ keys: {
105
+ which: "active" | "inactive";
106
+ sameAsDebugKey: boolean;
107
+ hasShadowArtifact: boolean;
108
+ hasSnapshot: boolean;
109
+ }[];
110
+ isActivePage: boolean;
111
+ snapshotShape: {
112
+ length: number;
113
+ bodyLength: number;
114
+ elements: number;
115
+ basePlaceholderLeft: boolean;
116
+ baseHref: string | null;
117
+ head: string;
118
+ } | null;
119
+ previewIdentityReady: boolean;
120
+ seedRevision: string;
121
+ hostRevision: string;
122
+ staticSurface: import("./editor/routePreviewStatus").StaticRoutePreviewSurface;
103
123
  tier: import("./editor/frameLod").FrameLifecycleTier;
104
124
  magnified: boolean;
105
125
  promotion: {
@@ -29,6 +29,9 @@ const LEASE_HEARTBEAT_MS = 2_000;
29
29
  // costs a checkout and a dependency install, so the reaper errs toward keeping.
30
30
  const DEFAULT_MIRROR_GRACE_MS = 6 * 60 * 60 * 1_000;
31
31
  const LEASE_WAIT_TIMEOUT_MS = 180_000;
32
+ // A serving editor holds this for its whole session. It marks the checkout as in
33
+ // use for the reaper; it must never stand in the way of a sync.
34
+ const SERVE_LEASE_LABEL = 'serve';
32
35
 
33
36
  export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
34
37
  return path.resolve(inventory?.outputRoot ?? mirrorAppRoot);
@@ -469,10 +472,25 @@ export async function acquireDevMirrorLease({ repoRoot, mirrorRoot, label = 'cap
469
472
  };
470
473
  }
471
474
 
472
- async function waitForLeasesToClear(repoRoot, mirrorRoot, timeoutMs = LEASE_WAIT_TIMEOUT_MS) {
473
- const deadline = Date.now() + timeoutMs;
475
+ /**
476
+ * Leases that a sync must wait out.
477
+ *
478
+ * A reader lease is short: a capture holds one while it walks the checkout, and
479
+ * moving the files under it would corrupt what it is reading. A serve lease is
480
+ * the opposite — it lasts as long as an editor is open, and it exists so a
481
+ * reaper can tell a live checkout from an abandoned one. Treating the two the
482
+ * same wedged the second editor on a ref: it waited three minutes for a lease
483
+ * that, by design, never clears, and then reported the mirror as held.
484
+ */
485
+ export function leasesBlockingSync(leases) {
486
+ return leases.filter((lease) => lease.label !== SERVE_LEASE_LABEL);
487
+ }
488
+
489
+ async function waitForLeasesToClear(repoRoot, mirrorRoot, timeoutMs) {
490
+ const limit = Number.isFinite(timeoutMs) ? timeoutMs : LEASE_WAIT_TIMEOUT_MS;
491
+ const deadline = Date.now() + limit;
474
492
  while (true) {
475
- const held = await freshLeases(repoRoot, mirrorRoot);
493
+ const held = leasesBlockingSync(await freshLeases(repoRoot, mirrorRoot));
476
494
  if (held.length === 0) return;
477
495
  if (Date.now() >= deadline) {
478
496
  const labels = [...new Set(held.map((lease) => lease.label))].join(', ');
@@ -844,7 +862,7 @@ export function pygmalionDevMirrorPlugin(options) {
844
862
  const lease = await acquireDevMirrorLease({
845
863
  repoRoot,
846
864
  mirrorRoot: activeRoot,
847
- label: 'serve',
865
+ label: SERVE_LEASE_LABEL,
848
866
  }).catch(() => null);
849
867
  if (lease) serveLease = { mirrorRoot: activeRoot, lease };
850
868
  };
@@ -854,6 +872,9 @@ export function pygmalionDevMirrorPlugin(options) {
854
872
  await held?.lease.release().catch(() => undefined);
855
873
  };
856
874
 
875
+ // Whether anything has asked for the checkout yet. The runtime is built once,
876
+ // on first demand, and every later demand joins the same work.
877
+ let runtimeRequested = false;
857
878
  let previewChild = null;
858
879
  let previewPort = null;
859
880
  let syncPromise = null;
@@ -1117,8 +1138,9 @@ export function pygmalionDevMirrorPlugin(options) {
1117
1138
  let warning = null;
1118
1139
  try {
1119
1140
  return await withSharedSyncLock(async () => {
1120
- // A capture reading this checkout must finish before it moves.
1121
- await waitForLeasesToClear(repoRoot, mirrorRoot);
1141
+ // A capture reading this checkout must finish before it moves. A serving
1142
+ // editor is not that kind of holder — see leasesBlockingSync.
1143
+ await waitForLeasesToClear(repoRoot, mirrorRoot, options.leaseWaitTimeoutMs);
1122
1144
  const mirrorState = await syncDevMirrorWorktree({
1123
1145
  repoRoot,
1124
1146
  mirrorRoot,
@@ -1195,14 +1217,27 @@ export function pygmalionDevMirrorPlugin(options) {
1195
1217
  process.once('exit', () => {
1196
1218
  previewChild?.kill('SIGTERM');
1197
1219
  });
1198
- // Synchronizes once when the editor server runs. Screen mount and manual refresh requests reuse the same Promise.
1199
- void syncMirror().then(() =>
1200
- reapDevMirrorWorktrees({
1201
- repoRoot,
1202
- mirrorBaseRoot,
1203
- keep: [mirrorRoot],
1204
- })
1205
- .then(({ removed }) => {
1220
+ // Nothing is prepared until something asks for it.
1221
+ //
1222
+ // Opening the editor used to build the checkout: fetch, worktree, install,
1223
+ // inventory — minutes before the first pixel, on every start, even when all
1224
+ // anyone did was look at the catalog. The captured frames are already on
1225
+ // disk and belong to the consuming app, so browsing needs none of it.
1226
+ //
1227
+ // What genuinely needs the checkout asks for it: a refresh or a revision
1228
+ // switch (below), and any request for the preview origin — the live frame,
1229
+ // its assets, a capture. Those requests kick the work off and answer
1230
+ // "preparing" until it lands, which is what they already did while a
1231
+ // start-time sync was in flight.
1232
+ const startRuntime = () => {
1233
+ if (runtimeRequested) return;
1234
+ runtimeRequested = true;
1235
+ void syncMirror()
1236
+ .then(() =>
1237
+ reapDevMirrorWorktrees({ repoRoot, mirrorBaseRoot, keep: [mirrorRoot] }),
1238
+ )
1239
+ .then((result) => {
1240
+ const removed = result?.removed ?? [];
1206
1241
  if (removed.length > 0) {
1207
1242
  console.log(
1208
1243
  `[pygmalion] released ${removed.length} unused dev screen checkout(s): ${removed
@@ -1211,8 +1246,8 @@ export function pygmalionDevMirrorPlugin(options) {
1211
1246
  );
1212
1247
  }
1213
1248
  })
1214
- .catch(() => undefined),
1215
- );
1249
+ .catch(() => undefined);
1250
+ };
1216
1251
 
1217
1252
  server.middlewares.use(async (req, res, next) => {
1218
1253
  const url = new URL(req.url ?? '/', 'http://localhost');
@@ -1249,6 +1284,10 @@ export function pygmalionDevMirrorPlugin(options) {
1249
1284
  }
1250
1285
  let nextStatus;
1251
1286
  try {
1287
+ // An explicit refresh is a demand like any other — mark the runtime as
1288
+ // started so a later preview request joins this work instead of
1289
+ // scheduling a second one.
1290
+ runtimeRequested = true;
1252
1291
  nextStatus = await syncMirror(requestedRef);
1253
1292
  } catch (error) {
1254
1293
  json(res, 400, {
@@ -1265,7 +1304,14 @@ export function pygmalionDevMirrorPlugin(options) {
1265
1304
  // no longer matches the revision this origin is named after.
1266
1305
  const current = await verifiedStatus();
1267
1306
  if (current.state !== 'ready' || previewPort == null) {
1268
- json(res, 503, { ok: false, error: current.error ?? 'Preparing the dev screen' });
1307
+ // This is the first thing that actually needs the checkout, so it is
1308
+ // the thing that starts it.
1309
+ startRuntime();
1310
+ json(res, 503, {
1311
+ ok: false,
1312
+ error: current.error ?? 'Preparing the dev screen',
1313
+ preparing: true,
1314
+ });
1269
1315
  return;
1270
1316
  }
1271
1317
  proxyRequest(req, res, previewPort, prefix);
@@ -207,6 +207,43 @@ export function pygmalionPreviewArtifactPlugin({
207
207
  return current;
208
208
  }
209
209
 
210
+ /**
211
+ * Answers a generation failure as what it is.
212
+ *
213
+ * A defective generator is a 500 the consumer must surface. A capture that
214
+ * could not run is a 503 it should wait out — the frame keeps whatever it has
215
+ * and says "not prepared yet" instead of painting a red failure over a screen
216
+ * whose only problem is that no runtime was asked for.
217
+ */
218
+ function sendGenerationFailure(response, error) {
219
+ if (error?.pygmalionBadGeneratorOutput) {
220
+ sendJson(response, 500, { ok: false, error: 'artifact_generation_failed' });
221
+ return;
222
+ }
223
+ sendJson(response, 503, {
224
+ ok: false,
225
+ error: 'artifact_generation_unavailable',
226
+ retryable: true,
227
+ details: [
228
+ error instanceof Error ? error.message.split('\n')[0] : String(error),
229
+ ],
230
+ });
231
+ }
232
+
233
+ /**
234
+ * A generator that ran and produced the wrong thing.
235
+ *
236
+ * Two very different failures reach the same catch: the capture could not run
237
+ * at all — the editor no longer prepares a checkout just because it started,
238
+ * so asking for a frame before anyone asked for the runtime is ordinary — and
239
+ * the capture ran but handed back something invalid or belonging to another
240
+ * revision. Only the second is a defect. Reporting both as one made the first
241
+ * shout, and quieting both would have hushed the second.
242
+ */
243
+ function badGeneratorOutput(message) {
244
+ return Object.assign(new Error(message), { pygmalionBadGeneratorOutput: true });
245
+ }
246
+
210
247
  async function withCaptureLease(sourceRevision, task) {
211
248
  // A capture reads the dev mirror for minutes. Hold a lease for that whole
212
249
  // window so a sync on another server cannot switch the checkout the frames
@@ -241,10 +278,10 @@ export function pygmalionPreviewArtifactPlugin({
241
278
  );
242
279
  const validation = validateRoutePreviewArtifactBundle(generated);
243
280
  if (!validation.valid) {
244
- throw new Error('Generated preview artifact is invalid.');
281
+ throw badGeneratorOutput('Generated preview artifact is invalid.');
245
282
  }
246
283
  if (!exactArtifact(generated, namespace, sourceRevision)) {
247
- throw new Error(
284
+ throw badGeneratorOutput(
248
285
  'Generated preview artifact identity does not match the request.',
249
286
  );
250
287
  }
@@ -426,11 +463,8 @@ export function pygmalionPreviewArtifactPlugin({
426
463
  sourceRevision,
427
464
  wanted,
428
465
  );
429
- } catch {
430
- sendJson(response, 500, {
431
- ok: false,
432
- error: 'artifact_generation_failed',
433
- });
466
+ } catch (error) {
467
+ sendGenerationFailure(response, error);
434
468
  return;
435
469
  }
436
470
  }
@@ -458,11 +492,8 @@ export function pygmalionPreviewArtifactPlugin({
458
492
  sourceRevision,
459
493
  captureBaseUrl,
460
494
  );
461
- } catch {
462
- sendJson(response, 500, {
463
- ok: false,
464
- error: 'artifact_generation_failed',
465
- });
495
+ } catch (error) {
496
+ sendGenerationFailure(response, error);
466
497
  return;
467
498
  }
468
499
  }
@@ -498,11 +529,8 @@ export function pygmalionPreviewArtifactPlugin({
498
529
  sourceRevision,
499
530
  captureBaseUrl,
500
531
  );
501
- } catch {
502
- sendJson(response, 500, {
503
- ok: false,
504
- error: 'artifact_generation_failed',
505
- });
532
+ } catch (error) {
533
+ sendGenerationFailure(response, error);
506
534
  return;
507
535
  }
508
536
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {