@pygmalionjs/pygmalion 0.5.17 → 0.5.19

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,6 +1,7 @@
1
1
  import { type RoutePreviewArtifactBundleV2 } from './routePreviewArtifactV2.js';
2
2
  import { type RoutePreviewArtifactBundleV3, type RoutePreviewArtifactV3Diagnostic, type RoutePreviewArtifactV3Status } from './routePreviewArtifactV3.js';
3
3
  import type { StoryboardEnvironment } from './designImport';
4
+ import type { RoutePreviewFrameRecipe } from './previewBootstrap';
4
5
  import { type InstanceResolver } from './fiberMap';
5
6
  type SlotRelease = () => void;
6
7
  export interface RoutePreviewRecipe {
@@ -64,6 +65,13 @@ export interface RoutePreviewArtifactSeedResult {
64
65
  export interface RoutePreviewArtifactFrameIdentity {
65
66
  id: string;
66
67
  fingerprint?: string;
68
+ /**
69
+ * The recipe the fingerprint stands for, carried so a generator can reproduce
70
+ * it. Without this a host can only re-capture what it declared for the id and
71
+ * label it with the requested fingerprint — the frame then answers "exact"
72
+ * with the screen the axis was supposed to change.
73
+ */
74
+ recipe?: RoutePreviewFrameRecipe;
67
75
  }
68
76
  /** Lifecycle of resolving one frame against an exact artifact endpoint. */
69
77
  export type RoutePreviewArtifactResolution = {
@@ -87,6 +95,8 @@ export type RoutePreviewArtifactFrameRequestResult = {
87
95
  } | {
88
96
  status: 'unavailable';
89
97
  message?: string;
98
+ notPrepared?: boolean;
99
+ absent?: readonly string[];
90
100
  } | {
91
101
  status: 'rejected';
92
102
  message: string;
@@ -163,7 +173,11 @@ export declare function isRoutePreviewSnapshotHydrated(key: string): boolean;
163
173
  * Persistent cache identity excluding random mirror ports.
164
174
  * By normalizing the object key order, the same screen recipe always uses the same key.
165
175
  */
166
- type RoutePreviewFrameRequester = (frames: readonly RoutePreviewArtifactFrameIdentity[]) => RoutePreviewArtifactFrameRequestResult | Promise<RoutePreviewArtifactFrameRequestResult> | void;
176
+ export interface RoutePreviewFrameRequestOptions {
177
+ /** Where the capture queues among others; see PREVIEW_CAPTURE_PRIORITY. */
178
+ priority?: number;
179
+ }
180
+ type RoutePreviewFrameRequester = (frames: readonly RoutePreviewArtifactFrameIdentity[], options?: RoutePreviewFrameRequestOptions) => RoutePreviewArtifactFrameRequestResult | Promise<RoutePreviewArtifactFrameRequestResult> | void;
167
181
  /** Installed by the host adapter, which owns the transport. */
168
182
  export declare function setRoutePreviewArtifactFrameRequester(requester: RoutePreviewFrameRequester | null): void;
169
183
  /**
@@ -171,6 +185,7 @@ export declare function setRoutePreviewArtifactFrameRequester(requester: RoutePr
171
185
  * only an active or deliberately hovered frame may boot the live application.
172
186
  */
173
187
  export declare function setRoutePreviewArtifactEndpointConfigured(configured: boolean): void;
188
+ export declare function isRoutePreviewArtifactEndpointConfigured(): boolean;
174
189
  export declare function shouldStartRoutePreviewProducer({ detailActive, previewIntended, }: {
175
190
  detailActive: boolean;
176
191
  previewIntended: boolean;
@@ -194,19 +209,25 @@ export declare function shouldRequestRoutePreviewArtifactFrame({ cacheHydrated,
194
209
  */
195
210
  export declare function requestRoutePreviewArtifactFrame(id: string, fingerprint?: string, options?: {
196
211
  retry?: boolean;
212
+ recipe?: RoutePreviewFrameRecipe;
213
+ priority?: number;
197
214
  }): Promise<RoutePreviewArtifactResolution> | null;
198
215
  /**
199
216
  * Asks for many frames' captures in one requester call.
200
217
  *
201
- * The background sweep delegates its interaction screens in chunks, and a
202
- * chunk must not fan out into one transport round trip per frame. Identities
203
- * already resolved exactly, already generating, or already failed are skipped
204
- * (an error is retried only on request); the remaining frames share a single
205
- * requester call and a single resolution, and each identity joins the same
206
- * in-flight dedupe map the single-frame request uses.
218
+ * A canvas being filled and the background sweep both name frames in chunks,
219
+ * and a chunk must not fan out into one transport round trip per frame — nor
220
+ * into one capture job per frame, which is what left the worker's lanes idle.
221
+ * Identities already resolved exactly, already generating, or already failed
222
+ * are skipped (an error is retried only on request); the remaining frames
223
+ * share a single requester call and a single resolution, and each identity
224
+ * joins the same in-flight dedupe map the single-frame request uses. Each
225
+ * frame's recipe travels with it, so a generator can reproduce a variant
226
+ * instead of re-capturing what the host declared for the id.
207
227
  */
208
228
  export declare function requestRoutePreviewArtifactFrames(frames: readonly RoutePreviewArtifactFrameIdentity[], options?: {
209
229
  retry?: boolean;
230
+ priority?: number;
210
231
  }): Promise<RoutePreviewArtifactResolution> | null;
211
232
  export declare function createRoutePreviewRecipeKey(recipe: RoutePreviewRecipe): string;
212
233
  /**
@@ -196,6 +196,12 @@ export interface PageModel {
196
196
  /** Declared interactive axis the frame is currently held in, if any. */
197
197
  interactiveStateId?: string;
198
198
  interactiveOptionId?: string;
199
+ /**
200
+ * Options held together on a combining axis (InteractiveStateDef.multiple).
201
+ * Kept beside the single-option field rather than replacing it so an
202
+ * exclusive axis reads exactly as before.
203
+ */
204
+ interactiveOptionIds?: string[];
199
205
  /**
200
206
  * The case's own environment, kept so clearing an interactive option can put
201
207
  * `environment` back without reconstructing what the case declared. Same shape
@@ -0,0 +1,37 @@
1
+ import type { StoryboardGraphFrameView, StoryboardGraphViewModel } from './storyboardGraphView';
2
+ /**
3
+ * The journey a catalog actually tells, and everything that hangs off it.
4
+ *
5
+ * A storyboard reads as a list of relations: starts, branches, aliases. Each
6
+ * is true and none of them is the thing a person is looking for, which is
7
+ * "where am I in the walk through this product". Measured on one real catalog:
8
+ * 75 screens, 50 declared paths, and 30 of those paths two screens long — so
9
+ * the previous/next of a single frame was, for most frames, a fragment with
10
+ * nothing before or after it. The walk itself was never shown.
11
+ *
12
+ * The spine is the longest run of declared transitions from a start. On that
13
+ * same catalog it is twelve steps — entry through to the recommendations after
14
+ * a recording — and every other screen sits one hop off it. That makes the
15
+ * spine an index a reader can hold: the product's main line, with each
16
+ * detour named under the step it leaves from.
17
+ */
18
+ export interface StoryboardJourney {
19
+ /** Frame ids along the main line, in order. Empty when nothing is declared. */
20
+ spine: readonly string[];
21
+ /** Frames one hop off the spine, keyed by the spine step they leave from. */
22
+ detours: ReadonlyMap<string, readonly string[]>;
23
+ /** Where a frame sits: its spine step, for a frame that is not on the spine. */
24
+ detourParent: ReadonlyMap<string, string>;
25
+ /**
26
+ * Frames the walk never reaches. A frame is not automatically wrong for
27
+ * being here — an error state is a real end — but a catalog where half the
28
+ * screens are unreachable from its own main line is describing that.
29
+ */
30
+ offJourney: readonly string[];
31
+ }
32
+ /** Resolves the journey a model describes. */
33
+ export declare function storyboardJourney(model: StoryboardGraphViewModel): StoryboardJourney;
34
+ /** The step a frame is read against: itself when on the spine, else its parent. */
35
+ export declare function journeyAnchorFor(journey: StoryboardJourney, frameId: string | null | undefined): string | null;
36
+ /** Frames that end the walk — nothing declared follows them. */
37
+ export declare function journeyDeadEnds(model: StoryboardGraphViewModel): readonly StoryboardGraphFrameView[];
@@ -205,7 +205,7 @@ export declare function pygmalionOpenPreview(): void;
205
205
  export declare function pygmalionResetCurrentEdits(): boolean;
206
206
  export interface InitialPageDef extends DesignImportInitialPage {
207
207
  }
208
- export declare function PygmalionEditor({ registry, tokens, initialPages, initialCanvas, initialEditMode, componentConnections, viewportPresets, previewEnvironmentControls, designImport, onTokensChange, appOrigin, previewRevision, previewCacheNamespace, previewArtifacts, previewArtifactEndpoint, previewConcurrency, previewOpen, onPreviewOpenChange, flowCanvas, frameSurface, screenFlows, scenarioCoverage, screenDimensions, screenLists, sectionHeaders, sectionHeaderLabels, screenLanes, frameBranchKinds, frameLabelLabels, screenCards, liveScreenBudget, interactiveStates, surfaceClassifications, onApply, onDesignChange, onInspectApply, onInspectPreview, onInspectImpact, storyboard, storyboardDiscovery, storyboardDiscoveryEndpoint, }: {
208
+ export declare function PygmalionEditor({ registry, tokens, initialPages, initialCanvas, initialEditMode, componentConnections, viewportPresets, previewEnvironmentControls, designImport, onTokensChange, appOrigin, previewRevision, previewCacheNamespace, previewArtifacts, previewArtifactEndpoint, previewConcurrency, previewOpen, onPreviewOpenChange, flowCanvas, frameSurface, captureSupply, screenFlows, screenFlowConcurrency, scenarioCoverage, screenDimensions, screenLists, sectionHeaders, sectionHeaderLabels, screenLanes, frameBranchKinds, frameLabelLabels, screenCards, liveScreenBudget, interactiveStates, surfaceClassifications, onApply, onDesignChange, onInspectApply, onInspectPreview, onInspectImpact, storyboard, storyboardDiscovery, storyboardDiscoveryEndpoint, }: {
209
209
  registry?: ComponentRegistry;
210
210
  tokens?: TokenDef[];
211
211
  initialPages?: InitialPageDef[];
@@ -257,14 +257,34 @@ export declare function PygmalionEditor({ registry, tokens, initialPages, initia
257
257
  * promotion-ready without a mount wait.
258
258
  */
259
259
  frameSurface?: 'bitmap' | 'dom';
260
+ /**
261
+ * Who fills a canvas first. `worker-first` asks the capture worker behind
262
+ * `previewArtifactEndpoint` for the active canvas in batches (nearest the
263
+ * viewport first, other canvases after, in the background) and keeps the
264
+ * walking sessions and per-screen sweep as the fallback for what the worker
265
+ * cannot answer. `browser-first` fills the canvas inside the editor — the
266
+ * walking sessions and sweep — and uses the worker only for the frame the
267
+ * designer selects. Defaults to worker-first when an endpoint is configured
268
+ * and browser-first otherwise.
269
+ */
270
+ captureSupply?: 'worker-first' | 'browser-first';
260
271
  /**
261
272
  * Host-declared flow paths — the captureless surface supply. One hidden
262
273
  * live instance boots per path and walks its waypoints, publishing a
263
274
  * frozen snapshot per screen into the preview store; frames whose screen a
264
275
  * flow claims wait for the delivery instead of booting live themselves.
265
276
  * Screens no flow claims keep the classic artifact/sweep/boot supply.
277
+ * Under worker-first supply, flows walk only the screens the worker
278
+ * released.
266
279
  */
267
280
  screenFlows?: readonly ScreenFlowPath[];
281
+ /**
282
+ * How many flow walks run at once. Defaults to 1, because mocked backends
283
+ * are commonly single-session; a host whose fixtures survive concurrent
284
+ * sessions can raise it — measured, two walks filled a canvas in half the
285
+ * time with no more long tasks on the editor thread.
286
+ */
287
+ screenFlowConcurrency?: number;
268
288
  /**
269
289
  * Host-declared scenario coverage ledger: where each QA scenario is
270
290
  * verified (frame, folded screen state, component toggle, viewport
@@ -1,7 +1,10 @@
1
- import type { StoryboardConnectionMode } from '../canvas/StoryboardConnections';
2
1
  export declare const LayerTree: import("react").FunctionComponent<{
2
+ /**
3
+ * Which half of the panel to draw. 'story' answers where am I going — the
4
+ * canvases and the walk through them; 'frames' answers what is this made of
5
+ * — the frame list and the tree of the one in front of you.
6
+ */
7
+ view?: "story" | "frames";
3
8
  onFrameFocus?: (id: string) => void;
4
- storyboardConnectionMode?: StoryboardConnectionMode;
5
- onStoryboardConnectionModeChange?: (mode: StoryboardConnectionMode) => void;
6
9
  storyboardReady?: boolean;
7
10
  }>;
@@ -1,12 +1,9 @@
1
1
  import type { StoryboardGraphViewModel } from '../editor/storyboardGraphView';
2
2
  import type { StoryboardCompositionModel } from '../editor/storyboardComposition';
3
- import type { StoryboardConnectionMode } from '../canvas/StoryboardConnections';
4
3
  export interface StoryboardGraphPanelProps {
5
4
  model: StoryboardGraphViewModel;
6
5
  onSelectFrame: (frameId: string) => void;
7
6
  activeFrameId?: string | null;
8
- connectionMode: StoryboardConnectionMode;
9
- onConnectionModeChange: (mode: StoryboardConnectionMode) => void;
10
7
  /**
11
8
  * Structure for a canvas with no routes, where a journey graph has nothing
12
9
  * to say. Present only on catalog canvases.
@@ -15,9 +12,4 @@ export interface StoryboardGraphPanelProps {
15
12
  /** Frame names for composition rows, which carry ids rather than frames. */
16
13
  frameNameById?: ReadonlyMap<string, string>;
17
14
  }
18
- /**
19
- * Generic navigation and coverage surface for a project-supplied storyboard
20
- * graph. It never interprets domain labels and delegates camera focus to the
21
- * editor's existing frame-selection action.
22
- */
23
- export declare function StoryboardGraphPanel({ model, onSelectFrame, activeFrameId, connectionMode, onConnectionModeChange, composition, frameNameById, }: StoryboardGraphPanelProps): import("react").JSX.Element | null;
15
+ export declare function StoryboardGraphPanel({ model, onSelectFrame, activeFrameId, composition, frameNameById, }: StoryboardGraphPanelProps): import("react").JSX.Element | null;
@@ -879,6 +879,10 @@ export function pygmalionDevMirrorPlugin(options) {
879
879
  let previewPort = null;
880
880
  let syncPromise = null;
881
881
  let sharedLockPathPromise = null;
882
+ // Assigned once the server is configured. A capture asks for the checkout
883
+ // through the composition handle below, and that demand has to reach the same
884
+ // starter the preview-origin requests use.
885
+ let demandRuntime = () => {};
882
886
  let lastVerifiedAt = 0;
883
887
  let revision = 0;
884
888
  let status = {
@@ -1197,6 +1201,12 @@ export function pygmalionDevMirrorPlugin(options) {
1197
1201
  pygmalion: {
1198
1202
  acquireLease: (label) => acquireDevMirrorLease({ repoRoot, mirrorRoot, label }),
1199
1203
  activeMirrorRoot: () => mirrorRoot,
1204
+ // A capture needs the checkout as much as a live frame does, but it asks
1205
+ // at another plugin's endpoint. Without these two the demand never
1206
+ // reached the starter: the frame had no origin to request, so nothing
1207
+ // ever started the work the frame was waiting for.
1208
+ requestRuntime: () => demandRuntime(),
1209
+ runtimePrepared: () => status.state === 'ready',
1200
1210
  },
1201
1211
  configureServer(server) {
1202
1212
  server.httpServer?.once('close', () => {
@@ -1248,6 +1258,7 @@ export function pygmalionDevMirrorPlugin(options) {
1248
1258
  })
1249
1259
  .catch(() => undefined);
1250
1260
  };
1261
+ demandRuntime = startRuntime;
1251
1262
 
1252
1263
  server.middlewares.use(async (req, res, next) => {
1253
1264
  const url = new URL(req.url ?? '/', 'http://localhost');
@@ -11,6 +11,24 @@ const MAX_IDENTITY_LENGTH = 1_024;
11
11
  const MAX_CAPTURE_BASE_URL_LENGTH = 2_048;
12
12
  const MAX_FRAME_REQUEST_BYTES = 64 * 1024;
13
13
  const MAX_FRAME_REQUEST_COUNT = 2_048;
14
+ // A batch that generates carries a recipe per frame, which a query string
15
+ // cannot hold past a handful of frames (Node refuses the request at 16KB of
16
+ // headers). Those arrive as a JSON body instead; the ceiling keeps a stray
17
+ // client from parking megabytes in memory.
18
+ const MAX_FRAME_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
19
+ /**
20
+ * Where a request sits among captures waiting on the same identity. Smaller
21
+ * runs first. The editor sends a frame the designer selected ahead of a
22
+ * canvas being filled, and a canvas being filled ahead of one nobody is
23
+ * looking at; a request that says nothing sits with the canvas.
24
+ */
25
+ export const PREVIEW_CAPTURE_PRIORITY = Object.freeze({
26
+ selected: 0,
27
+ canvas: 10,
28
+ background: 20,
29
+ });
30
+ const DEFAULT_CAPTURE_PRIORITY = PREVIEW_CAPTURE_PRIORITY.canvas;
31
+ const MAX_CAPTURE_PRIORITY = 1_000;
14
32
 
15
33
  function isLocalRequestHost(value) {
16
34
  const raw = Array.isArray(value) ? value[0] : value;
@@ -33,15 +51,25 @@ function sendJson(response, statusCode, body) {
33
51
  response.end(payload);
34
52
  }
35
53
 
36
- function requestedIdentity(url, key) {
37
- const value = url.searchParams.get(key);
54
+ function requestedIdentity(params, key) {
55
+ const value = params.get(key);
38
56
  return value && value === value.trim() && value.length <= MAX_IDENTITY_LENGTH
39
57
  ? value
40
58
  : null;
41
59
  }
42
60
 
43
- function requestedCaptureBaseUrl(url, requestHost) {
44
- const value = url.searchParams.get('captureBaseUrl');
61
+ function requestedPriority(params) {
62
+ const raw = params.get('priority');
63
+ if (raw == null || raw === '') return DEFAULT_CAPTURE_PRIORITY;
64
+ const value = Number(raw);
65
+ if (!Number.isInteger(value) || value < 0 || value > MAX_CAPTURE_PRIORITY) {
66
+ return null;
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function requestedCaptureBaseUrl(params, requestHost) {
72
+ const value = params.get('captureBaseUrl');
45
73
  if (!value) return undefined;
46
74
  if (value !== value.trim() || value.length > MAX_CAPTURE_BASE_URL_LENGTH) {
47
75
  return null;
@@ -79,27 +107,40 @@ function requestedCaptureBaseUrl(url, requestHost) {
79
107
  * without starting work — otherwise naming every frame on mount would capture
80
108
  * the whole catalog, which is the cost this mode exists to remove.
81
109
  */
82
- function generationAllowed(url) {
83
- const raw = url.searchParams.get('generate');
110
+ function generationAllowed(params) {
111
+ const raw = params.get('generate');
84
112
  if (raw == null) return true;
85
113
  return !['0', 'false', 'no'].includes(raw.trim().toLowerCase());
86
114
  }
87
115
 
88
- function resolutionOnly(url) {
89
- const raw = url.searchParams.get('resolve');
116
+ function resolutionOnly(params) {
117
+ const raw = params.get('resolve');
90
118
  return raw != null && ['1', 'true', 'yes'].includes(raw.trim().toLowerCase());
91
119
  }
92
120
 
93
- function requestedFrames(url) {
94
- const raw = url.searchParams.get('frames');
121
+ /**
122
+ * The frame list of a request, whichever way it arrived.
123
+ *
124
+ * A GET carries it as JSON in the `frames` query parameter, capped so it fits a
125
+ * query string; a POST carries it in the body, where a batch of recipes fits.
126
+ */
127
+ function requestedFrames(params) {
128
+ const raw = params.get('frames');
95
129
  if (raw == null) return undefined;
96
- if (Buffer.byteLength(raw) > MAX_FRAME_REQUEST_BYTES) return null;
97
- let parsed;
98
- try {
99
- parsed = JSON.parse(raw);
100
- } catch {
130
+ const parsedBody = params.parsedFrames;
131
+ if (parsedBody === undefined && Buffer.byteLength(raw) > MAX_FRAME_REQUEST_BYTES) {
101
132
  return null;
102
133
  }
134
+ let parsed;
135
+ if (parsedBody !== undefined) {
136
+ parsed = parsedBody;
137
+ } else {
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
103
144
  if (!Array.isArray(parsed) || parsed.length > MAX_FRAME_REQUEST_COUNT) return null;
104
145
  const wanted = [];
105
146
  for (const entry of parsed) {
@@ -113,11 +154,75 @@ function requestedFrames(url) {
113
154
  if (typeof fingerprint !== 'string' || !fingerprint.trim()) return null;
114
155
  if (fingerprint.length > MAX_IDENTITY_LENGTH) return null;
115
156
  }
116
- wanted.push(fingerprint === undefined ? { id } : { id, fingerprint });
157
+ // The recipe the fingerprint stands for. A fingerprint is a hash — a
158
+ // generator handed one alone can only re-capture what the host declared for
159
+ // that id, which is a different screen wearing the requested name. Carried
160
+ // through opaquely: this plugin never interprets it, and the whole request
161
+ // is already capped by MAX_FRAME_REQUEST_BYTES.
162
+ const recipe = item.recipe;
163
+ if (recipe !== undefined) {
164
+ if (typeof recipe !== 'object' || recipe === null || Array.isArray(recipe)) {
165
+ return null;
166
+ }
167
+ }
168
+ wanted.push({
169
+ id,
170
+ ...(fingerprint === undefined ? {} : { fingerprint }),
171
+ ...(recipe === undefined ? {} : { recipe }),
172
+ });
117
173
  }
118
174
  return wanted;
119
175
  }
120
176
 
177
+ /**
178
+ * The parameters of a request, read the same way for both transports.
179
+ *
180
+ * GET keeps every field in the query string. POST carries a JSON object with
181
+ * the same field names; scalar fields are read as strings so the validators
182
+ * above apply unchanged, and `frames` is kept parsed so a large batch is not
183
+ * re-serialized only to be parsed again.
184
+ */
185
+ function paramsFromQuery(url) {
186
+ return { get: (key) => url.searchParams.get(key), parsedFrames: undefined };
187
+ }
188
+
189
+ function paramsFromBody(body) {
190
+ const scalar = (value) => {
191
+ if (value === undefined || value === null) return null;
192
+ if (typeof value === 'string') return value;
193
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
194
+ return null;
195
+ };
196
+ return {
197
+ get: (key) => {
198
+ if (key === 'frames') return body.frames === undefined ? null : '[]';
199
+ return scalar(body[key]);
200
+ },
201
+ parsedFrames: body.frames,
202
+ };
203
+ }
204
+
205
+ async function readJsonBody(request, limit) {
206
+ const chunks = [];
207
+ let received = 0;
208
+ for await (const chunk of request) {
209
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
210
+ received += buffer.length;
211
+ if (received > limit) return { ok: false, error: 'body_too_large' };
212
+ chunks.push(buffer);
213
+ }
214
+ if (received === 0) return { ok: false, error: 'invalid_body' };
215
+ try {
216
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
217
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
218
+ return { ok: false, error: 'invalid_body' };
219
+ }
220
+ return { ok: true, body: parsed };
221
+ } catch {
222
+ return { ok: false, error: 'invalid_body' };
223
+ }
224
+ }
225
+
121
226
  function exactArtifact(artifact, namespace, sourceRevision) {
122
227
  const validation = validateRoutePreviewArtifactBundle(artifact);
123
228
  return (
@@ -140,6 +245,8 @@ export function pygmalionPreviewArtifactPlugin({
140
245
  disabled = () => false,
141
246
  generateArtifact,
142
247
  acquireLease,
248
+ requestRuntime,
249
+ runtimePrepared = () => true,
143
250
  } = {}) {
144
251
  const resolvedRoot = path.resolve(root);
145
252
  const resolvedArtifact = path.resolve(resolvedRoot, artifactFile);
@@ -165,6 +272,16 @@ export function pygmalionPreviewArtifactPlugin({
165
272
  'Preview artifact generator option must be a function.',
166
273
  );
167
274
  }
275
+ if (requestRuntime != null && typeof requestRuntime !== 'function') {
276
+ throw new TypeError(
277
+ 'Preview artifact runtime request option must be a function.',
278
+ );
279
+ }
280
+ if (typeof runtimePrepared !== 'function') {
281
+ throw new TypeError(
282
+ 'Preview artifact runtime readiness option must be a function.',
283
+ );
284
+ }
168
285
 
169
286
  const artifactStore = createPreviewArtifactStore({
170
287
  artifactFile: resolvedArtifact,
@@ -175,20 +292,52 @@ export function pygmalionPreviewArtifactPlugin({
175
292
  const identityQueues = new Map();
176
293
  const inFlightRequests = new Map();
177
294
 
178
- function enqueueIdentity(namespace, sourceRevision, task) {
295
+ /**
296
+ * Captures on one identity run one at a time — they share the checkout and
297
+ * the worker — but not in the order they were asked. A frame the designer
298
+ * just selected must not sit behind a canvas being filled in the background,
299
+ * so the queue is ordered by priority and by arrival within a priority.
300
+ * The running capture is never interrupted; the next pick happens when it
301
+ * finishes.
302
+ */
303
+ function enqueueIdentity(namespace, sourceRevision, task, priority = DEFAULT_CAPTURE_PRIORITY) {
179
304
  const identity = `${namespace}\u0000${sourceRevision}`;
180
- const previous = identityQueues.get(identity) ?? Promise.resolve();
181
- const current = previous.catch(() => undefined).then(task);
182
- identityQueues.set(identity, current);
183
- void current.then(
184
- () => {
185
- if (identityQueues.get(identity) === current) identityQueues.delete(identity);
186
- },
187
- () => {
188
- if (identityQueues.get(identity) === current) identityQueues.delete(identity);
189
- },
190
- );
191
- return current;
305
+ let queue = identityQueues.get(identity);
306
+ if (!queue) {
307
+ queue = { pending: [], running: false, arrivals: 0 };
308
+ identityQueues.set(identity, queue);
309
+ }
310
+ return new Promise((resolve, reject) => {
311
+ queue.arrivals += 1;
312
+ queue.pending.push({
313
+ priority,
314
+ arrival: queue.arrivals,
315
+ run: task,
316
+ resolve,
317
+ reject,
318
+ });
319
+ queue.pending.sort(
320
+ (left, right) => left.priority - right.priority || left.arrival - right.arrival,
321
+ );
322
+ pumpIdentity(identity, queue);
323
+ });
324
+ }
325
+
326
+ function pumpIdentity(identity, queue) {
327
+ if (queue.running) return;
328
+ const next = queue.pending.shift();
329
+ if (!next) {
330
+ if (identityQueues.get(identity) === queue) identityQueues.delete(identity);
331
+ return;
332
+ }
333
+ queue.running = true;
334
+ Promise.resolve()
335
+ .then(next.run)
336
+ .then(next.resolve, next.reject)
337
+ .finally(() => {
338
+ queue.running = false;
339
+ pumpIdentity(identity, queue);
340
+ });
192
341
  }
193
342
 
194
343
  function deduplicateRequest(key, task) {
@@ -230,6 +379,26 @@ export function pygmalionPreviewArtifactPlugin({
230
379
  });
231
380
  }
232
381
 
382
+ /**
383
+ * Answers a frame whose capture cannot run yet, and starts what it needs.
384
+ *
385
+ * Asking for a frame is asking for the checkout — the capture reads it. The
386
+ * editor holds no preview origin until the mirror is ready, so this endpoint
387
+ * is the only place that demand arrives before one exists. Without starting
388
+ * the work here the canvas waited on a preparation nobody had ordered, and
389
+ * the only way out was a human pressing refresh.
390
+ */
391
+ function answerPreparing(response) {
392
+ requestRuntime?.();
393
+ sendJson(response, 503, {
394
+ ok: false,
395
+ error: 'artifact_generation_unavailable',
396
+ retryable: true,
397
+ preparing: true,
398
+ details: ['The dev screen checkout is being prepared.'],
399
+ });
400
+ }
401
+
233
402
  /**
234
403
  * A generator that ran and produced the wrong thing.
235
404
  *
@@ -300,7 +469,7 @@ export function pygmalionPreviewArtifactPlugin({
300
469
  * The stored bundle becomes a cache keyed per frame rather than one artifact
301
470
  * per revision, so replacing one screen no longer re-renders the others.
302
471
  */
303
- function generateFrames(namespace, sourceRevision, captureBaseUrl, wanted) {
472
+ function generateFrames(namespace, sourceRevision, captureBaseUrl, wanted, priority) {
304
473
  const request = `frames\u0000${namespace}\u0000${sourceRevision}\u0000${wanted
305
474
  .map((frame) => `${frame.id}@${frame.fingerprint ?? ''}`)
306
475
  .sort()
@@ -346,7 +515,7 @@ export function pygmalionPreviewArtifactPlugin({
346
515
  wanted,
347
516
  )
348
517
  ).bundle;
349
- }),
518
+ }, priority),
350
519
  );
351
520
  }
352
521
 
@@ -365,8 +534,8 @@ export function pygmalionPreviewArtifactPlugin({
365
534
  next();
366
535
  return;
367
536
  }
368
- if (request.method !== 'GET') {
369
- response.setHeader('allow', 'GET');
537
+ if (request.method !== 'GET' && request.method !== 'POST') {
538
+ response.setHeader('allow', 'GET, POST');
370
539
  sendJson(response, 405, {
371
540
  ok: false,
372
541
  error: 'method_not_allowed',
@@ -380,14 +549,33 @@ export function pygmalionPreviewArtifactPlugin({
380
549
  });
381
550
  return;
382
551
  }
383
- const namespace = requestedIdentity(url, 'namespace');
384
- const sourceRevision = requestedIdentity(url, 'sourceRevision');
552
+ let params;
553
+ if (request.method === 'POST') {
554
+ const read = await readJsonBody(request, MAX_FRAME_REQUEST_BODY_BYTES);
555
+ if (!read.ok) {
556
+ sendJson(response, read.error === 'body_too_large' ? 413 : 400, {
557
+ ok: false,
558
+ error: read.error,
559
+ });
560
+ return;
561
+ }
562
+ params = paramsFromBody(read.body);
563
+ } else {
564
+ params = paramsFromQuery(url);
565
+ }
566
+ const namespace = requestedIdentity(params, 'namespace');
567
+ const sourceRevision = requestedIdentity(params, 'sourceRevision');
385
568
  const captureBaseUrl = requestedCaptureBaseUrl(
386
- url,
569
+ params,
387
570
  Array.isArray(request.headers.host)
388
571
  ? request.headers.host[0]
389
572
  : request.headers.host,
390
573
  );
574
+ const priority = requestedPriority(params);
575
+ if (priority === null) {
576
+ sendJson(response, 400, { ok: false, error: 'invalid_priority' });
577
+ return;
578
+ }
391
579
  if (!namespace || !sourceRevision) {
392
580
  sendJson(response, 400, {
393
581
  ok: false,
@@ -416,13 +604,13 @@ export function pygmalionPreviewArtifactPlugin({
416
604
  return;
417
605
  }
418
606
 
419
- const wanted = requestedFrames(url);
607
+ const wanted = requestedFrames(params);
420
608
  if (wanted === null) {
421
609
  sendJson(response, 400, { ok: false, error: 'invalid_frames' });
422
610
  return;
423
611
  }
424
612
  if (wanted !== undefined) {
425
- if (resolutionOnly(url)) {
613
+ if (resolutionOnly(params)) {
426
614
  const resolution = await artifactStore.resolveFrameSelection(
427
615
  namespace,
428
616
  sourceRevision,
@@ -449,7 +637,11 @@ export function pygmalionPreviewArtifactPlugin({
449
637
  wanted,
450
638
  );
451
639
  const absent = [...picked.missing, ...picked.stale];
452
- if (absent.length && generateArtifact && generationAllowed(url)) {
640
+ if (absent.length && generateArtifact && generationAllowed(params)) {
641
+ if (!runtimePrepared()) {
642
+ answerPreparing(response);
643
+ return;
644
+ }
453
645
  const requested = new Set(absent);
454
646
  try {
455
647
  await generateFrames(
@@ -457,6 +649,7 @@ export function pygmalionPreviewArtifactPlugin({
457
649
  sourceRevision,
458
650
  captureBaseUrl,
459
651
  wanted.filter((frame) => requested.has(frame.id)),
652
+ priority,
460
653
  );
461
654
  picked = await artifactStore.readFrameSelection(
462
655
  namespace,
@@ -480,12 +673,16 @@ export function pygmalionPreviewArtifactPlugin({
480
673
  // A consumer that forbids capture must be obeyed in every mode. Honouring
481
674
  // it only for frame requests left the expensive path one missing
482
675
  // parameter away.
483
- const mayGenerate = generateArtifact && generationAllowed(url);
676
+ const mayGenerate = generateArtifact && generationAllowed(params);
484
677
  let artifact = await artifactStore.readExactArtifact(
485
678
  namespace,
486
679
  sourceRevision,
487
680
  );
488
681
  if (!artifact && mayGenerate) {
682
+ if (!runtimePrepared()) {
683
+ answerPreparing(response);
684
+ return;
685
+ }
489
686
  try {
490
687
  artifact = await generateExactArtifact(
491
688
  namespace,