@kumwe/studio-preview 0.1.0-alpha.5 → 0.1.0-alpha.6

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/README.md CHANGED
@@ -14,7 +14,8 @@ still owns authentication, authorization, CSP, sandboxing, and rendering.
14
14
  `PreviewClient` is the Studio half of the channel; `PreviewHost` is the preview-surface half. Both
15
15
  pin an exact target origin and drop any inbound message whose source window, origin, schema, channel
16
16
  ID, session generation, or sequence does not match; each direction stamps its own monotonic sequence
17
- counter.
17
+ counter. Outbound client messages cross the same canonical guard before posting, and the draft.2
18
+ message vocabulary is closed.
18
19
 
19
20
  The handshake and request flow is announce → ready → render → rendered:
20
21
 
@@ -22,21 +23,47 @@ The handshake and request flow is announce → ready → render → rendered:
22
23
  callback, then calls `announce()` to post `studio.preview/ready`.
23
24
  2. Studio awaits `client.ready()`, which resolves with the announced payload — immediately when the
24
25
  announcement already arrived — and rejects on timeout, abort, or disposal.
25
- 3. Studio requests `studio.preview/render` via `client.render()`. The host invokes its render
26
- callback and replies with `studio.preview/rendered` carrying the same draft digest as the
27
- request. A newer request supersedes an in-flight one on both sides, so a stale result is never
28
- posted or resolved.
26
+ 3. Studio requests `studio.preview/render` via `client.render()` with a session-unique `requestId`.
27
+ The host invokes its render callback with an `AbortSignal` and replies with
28
+ `studio.preview/rendered` carrying the same request ID and draft digest. A newer request aborts and
29
+ generation-invalidates in-flight work on both sides, so a stale result is never posted or resolved
30
+ even when a retry uses the same digest at another viewport.
29
31
  4. A failed render is answered with `studio.preview/error` (code `studio.preview/render-failed`, the
30
- draft digest as `correlationId`, `retryable: true`) and a generic message — renderer failure
32
+ request ID as `correlationId`, `retryable: true`) and a generic message — renderer failure
31
33
  details never cross the channel.
32
34
  5. `client.select()` forwards `studio.preview/select` to the host's `onSelect` listeners.
33
35
 
36
+ ## Portable draft identity and markers
37
+
38
+ `canonicalPreviewDraftBytes(draft)` returns exactly the canonical UTF-8 bytes of a complete Studio
39
+ artifact. `computePreviewDraftDigest(draft)` hashes those bytes as lowercase hexadecimal SHA-256; it
40
+ does not add an envelope, prefix, viewport, BOM, or newline. The host still validates and authorizes
41
+ the staged draft, recomputes its digest, and compares it before rendering — the digest is never a
42
+ credential.
43
+
44
+ `createPreviewMarker(digest, ordinal)` implements the canonical
45
+ `studio.preview/node/<digest>/<ordinal>` grammar. `createPreviewMarkerInventory(blueprint, digest)`
46
+ enumerates roots, each node before descendants, sorted slot names, then child arrays. Every rendered
47
+ response requires an exact one-to-one `markerMap`; the generic responder refuses a mismatched digest,
48
+ grammar, contiguous order, duplicate, or incomplete map. Because the channel does not hold the draft,
49
+ the authoritative renderer additionally compares node mapping with `createPreviewMarkerInventory`;
50
+ the standalone reference renderer does so. `announceActivation()` accepts only a marker from the
51
+ currently accepted inventory, and the client independently drops invented or revoked activation
52
+ traffic.
53
+
54
+ These semantics require wire protocol `0.1.0-draft.2`. A `.1` peer is incompatible rather than
55
+ silently accepted. Portable implementations replay `@kumwe/studio-testkit/vectors/preview/` against
56
+ their own canonicalizer and traversal.
57
+
34
58
  ## Marker geometry
35
59
 
36
- `client.measure()` posts `studio.preview/measure` with a bounded marker list and resolves with the
60
+ `client.measure()` posts `studio.preview/measure` with a non-empty bounded marker list and resolves with the
37
61
  `studio.preview/measurements` answer: per marker, one or more CSS-pixel rectangles relative to the
38
62
  preview viewport origin (inline content fragments across lines), plus viewport metrics. Markers the
39
- renderer cannot place are returned in a distinct `unknown` list, never thrown.
63
+ renderer cannot place are returned in a distinct `unknown` list, never thrown. A marker outside the
64
+ latest digest-bound inventory is rejected locally with `PreviewChannelError` code
65
+ `studio.preview/measure-stale-marker`; the host independently rejects raw stale traffic without
66
+ invoking the measurer.
40
67
 
41
68
  The host never reads the DOM. The embedding renderer passes a `measure` callback in
42
69
  `PreviewHostOptions`; without one, measure requests are answered with the qualified
@@ -44,10 +71,12 @@ The host never reads the DOM. The embedding renderer passes a `measure` callback
44
71
  `studio.preview/measure-failed` — measurer failure details never cross the channel.
45
72
 
46
73
  Geometry is volatile, not document state: each response is stamped with the digest of the render it
47
- was measured against. A response whose digest no longer matches the client's latest completed render
48
- resolves as a typed `{ status: 'stale' }` outcome instead of geometry, and a reload voids in-flight
49
- measurements exactly like it voids renders. Newer measure requests supersede older ones on both
50
- sides.
74
+ was measured against. The host captures that digest and a private generation before invoking the
75
+ callback. A same-digest rerender, newer measurement, disposal, reload or teardown aborts and
76
+ invalidates the prior measurement. A viewport instruction also rejects the client's pending
77
+ measurement with `PreviewChannelError` code `studio.preview/measure-viewport-changed` and aborts the
78
+ host callback before viewport listeners run, so pre-resize geometry cannot settle late. Measured and
79
+ unknown entries must exactly partition the requested markers.
51
80
 
52
81
  Version negotiation currently requires the exact draft wire version on both sides: schema filtering
53
82
  accepts only `STUDIO_WIRE_PROTOCOL_VERSION`, so a ready announcement from a host speaking any other
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { PreviewClient, type PreviewClientOptions, type PreviewMeasureOptions, type PreviewMeasureOutcome, type PreviewMessageEvent, type PreviewMessageListener, type PreviewMessageSource, type PreviewMessageTarget, type PreviewProtocolListener, type PreviewReadyOptions, type PreviewRenderOptions, } from './preview-client.js';
1
+ export { PreviewClient, PreviewChannelError, type PreviewClientOptions, type PreviewMeasureOptions, type PreviewMeasureOutcome, type PreviewMessageEvent, type PreviewMessageListener, type PreviewMessageSource, type PreviewMessageTarget, type PreviewProtocolListener, type PreviewReadyOptions, type PreviewRenderOptions, } from './preview-client.js';
2
2
  export { PreviewHost, type PreviewHostOptions, type PreviewMeasureCallback, type PreviewMeasurement, type PreviewRenderCallback, type PreviewSelectListener, } from './preview-host.js';
3
+ export { canonicalPreviewDraftBytes, computePreviewDraftDigest, createPreviewMarker, createPreviewMarkerInventory, type PreviewDigestOptions, type PreviewMarkerInventory, } from './preview-identity.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,EACX,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,EACX,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,0BAA0B,EAC1B,yBAAyB,EACzB,mBAAmB,EACnB,4BAA4B,EAC5B,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,GAC5B,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
- export { PreviewClient, } from './preview-client.js';
1
+ export { PreviewClient, PreviewChannelError, } from './preview-client.js';
2
2
  export { PreviewHost, } from './preview-host.js';
3
+ export { canonicalPreviewDraftBytes, computePreviewDraftDigest, createPreviewMarker, createPreviewMarkerInventory, } from './preview-identity.js';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,GAWd,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,GAMZ,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,mBAAmB,GAWpB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,GAMZ,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,0BAA0B,EAC1B,yBAAyB,EACzB,mBAAmB,EACnB,4BAA4B,GAG7B,MAAM,uBAAuB,CAAC"}
@@ -1,4 +1,10 @@
1
- import { type PreviewMeasurePayload, type PreviewMeasurementsPayload, type PreviewMessage, type PreviewReadyPayload, type PreviewRenderedPayload, type PreviewRenderPayload, type PreviewActivatedPayload, type PreviewDisposePayload, type PreviewSelectPayload, type PreviewViewportPayload, type PreviewTeardownPayload } from '@kumwe/studio-protocol';
1
+ import { type PreviewMeasurePayload, type PreviewMeasurementsPayload, type PreviewMessage, type PreviewReadyPayload, type PreviewRenderedPayload, type PreviewRenderPayload, type PreviewActivatedPayload, type PreviewDisposePayload, type PreviewSelectPayload, type PreviewViewportPayload, type PreviewTeardownPayload, type QualifiedName } from '@kumwe/studio-protocol';
2
+ /** Stable client-side and wire failure surfaced by the preview channel. */
3
+ export declare class PreviewChannelError extends Error {
4
+ readonly code: QualifiedName;
5
+ readonly retryable: boolean;
6
+ constructor(code: QualifiedName, message: string, retryable?: boolean);
7
+ }
2
8
  export interface PreviewMessageEvent {
3
9
  data: unknown;
4
10
  origin: string;
@@ -1 +1 @@
1
- {"version":3,"file":"preview-client.d.ts","sourceRoot":"","sources":["../src/preview-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"preview-client.d.ts","sourceRoot":"","sources":["../src/preview-client.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,29 @@
1
- import { isPreviewMessage, STUDIO_CONTRACT_VERSION, } from '@kumwe/studio-protocol';
1
+ import { isPreviewMarker, isPreviewMessage, STUDIO_CONTRACT_VERSION, } from '@kumwe/studio-protocol';
2
+ /** Stable client-side and wire failure surfaced by the preview channel. */
3
+ export class PreviewChannelError extends Error {
4
+ code;
5
+ retryable;
6
+ constructor(code, message, retryable = false) {
7
+ super(message);
8
+ this.name = 'PreviewChannelError';
9
+ this.code = code;
10
+ this.retryable = retryable;
11
+ }
12
+ }
13
+ function snapshotOutboundPayload(payload) {
14
+ try {
15
+ return structuredClone(payload);
16
+ }
17
+ catch {
18
+ throw new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.');
19
+ }
20
+ }
2
21
  export class PreviewClient {
3
22
  #activationListeners = new Set();
4
23
  #channelId;
5
24
  #listener;
6
25
  #listeners = new Set();
26
+ #markerInventory = new Set();
7
27
  #pending = new Map();
8
28
  #pendingMeasures = new Map();
9
29
  #pendingReady = new Set();
@@ -12,9 +32,10 @@ export class PreviewClient {
12
32
  #target;
13
33
  #targetOrigin;
14
34
  #timeoutMilliseconds;
35
+ #usedRequestIds = new Set();
15
36
  #disposed = false;
16
37
  #lastInboundSequence = -1;
17
- #latestRenderDigest;
38
+ #latestRenderRequestId;
18
39
  #latestRenderedDigest;
19
40
  #readyPayload;
20
41
  #sequence = 0;
@@ -54,7 +75,11 @@ export class PreviewClient {
54
75
  pending.reject(new Error('Preview client was disposed.'));
55
76
  }
56
77
  this.#pendingReady.clear();
78
+ this.#activationListeners.clear();
57
79
  this.#listeners.clear();
80
+ this.#latestRenderRequestId = undefined;
81
+ this.#latestRenderedDigest = undefined;
82
+ this.#markerInventory.clear();
58
83
  }
59
84
  onMessage(listener) {
60
85
  this.#listeners.add(listener);
@@ -110,54 +135,95 @@ export class PreviewClient {
110
135
  if (options.signal?.aborted === true) {
111
136
  return Promise.reject(new Error('Preview render was aborted.', { cause: options.signal.reason }));
112
137
  }
113
- if (this.#pending.has(payload.draftDigest)) {
114
- return Promise.reject(new Error(`Render ${payload.draftDigest} is already pending.`));
138
+ let request;
139
+ try {
140
+ request = snapshotOutboundPayload(payload);
141
+ }
142
+ catch (error) {
143
+ return Promise.reject(error instanceof Error
144
+ ? error
145
+ : new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.'));
146
+ }
147
+ const message = {
148
+ channelId: this.#channelId,
149
+ contractVersion: STUDIO_CONTRACT_VERSION,
150
+ kind: 'preview-message',
151
+ payload: request,
152
+ sequence: this.#sequence,
153
+ sessionGeneration: this.#sessionGeneration,
154
+ type: 'studio.preview/render',
155
+ };
156
+ try {
157
+ this.#assertOutbound(message);
158
+ }
159
+ catch (error) {
160
+ return Promise.reject(error instanceof Error
161
+ ? error
162
+ : new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.'));
163
+ }
164
+ if (this.#usedRequestIds.has(request.requestId)) {
165
+ return Promise.reject(new PreviewChannelError('studio.preview/request-id-reused', `Preview request ${request.requestId} was already used in this session.`));
115
166
  }
116
- for (const [digest, pending] of this.#pending) {
167
+ for (const [requestId, pending] of this.#pending) {
117
168
  clearTimeout(pending.timeout);
118
169
  pending.cleanup();
119
- pending.reject(new Error(`Preview render ${digest} was superseded by ${payload.draftDigest}.`));
170
+ pending.reject(new Error(`Preview render ${requestId} was superseded by ${request.requestId}.`));
120
171
  }
121
172
  this.#pending.clear();
122
- this.#latestRenderDigest = payload.draftDigest;
173
+ this.#rejectPendingMeasures(new Error(`Preview measurements were superseded by render ${request.requestId}.`));
174
+ this.#usedRequestIds.add(request.requestId);
175
+ this.#latestRenderRequestId = request.requestId;
176
+ this.#latestRenderedDigest = undefined;
177
+ this.#markerInventory.clear();
123
178
  return new Promise((resolve, reject) => {
124
179
  const abort = () => {
125
- const pending = this.#pending.get(payload.draftDigest);
180
+ const pending = this.#pending.get(request.requestId);
126
181
  if (pending !== undefined) {
127
182
  clearTimeout(pending.timeout);
128
- this.#pending.delete(payload.draftDigest);
183
+ this.#pending.delete(request.requestId);
129
184
  pending.cleanup();
130
- if (this.#latestRenderDigest === payload.draftDigest) {
131
- this.#latestRenderDigest = undefined;
185
+ if (this.#latestRenderRequestId === request.requestId) {
186
+ this.#latestRenderRequestId = undefined;
132
187
  }
133
188
  pending.reject(new Error('Preview render was aborted.'));
189
+ this.#revokeRemoteRender(request.draftDigest, 'studio.preview/client-aborted');
134
190
  }
135
191
  };
136
192
  const cleanup = () => {
137
193
  options.signal?.removeEventListener('abort', abort);
138
194
  };
139
195
  const timeout = setTimeout(() => {
140
- const pending = this.#pending.get(payload.draftDigest);
196
+ const pending = this.#pending.get(request.requestId);
141
197
  if (pending !== undefined) {
142
- this.#pending.delete(payload.draftDigest);
198
+ this.#pending.delete(request.requestId);
143
199
  pending.cleanup();
144
- if (this.#latestRenderDigest === payload.draftDigest) {
145
- this.#latestRenderDigest = undefined;
200
+ if (this.#latestRenderRequestId === request.requestId) {
201
+ this.#latestRenderRequestId = undefined;
146
202
  }
147
- pending.reject(new Error(`Preview render ${payload.draftDigest} timed out.`));
203
+ pending.reject(new Error(`Preview render ${request.requestId} timed out.`));
204
+ this.#revokeRemoteRender(request.draftDigest, 'studio.preview/client-timeout');
148
205
  }
149
206
  }, this.#timeoutMilliseconds);
150
- this.#pending.set(payload.draftDigest, { cleanup, reject, resolve, timeout });
151
- options.signal?.addEventListener('abort', abort, { once: true });
152
- this.#post({
153
- channelId: this.#channelId,
154
- contractVersion: STUDIO_CONTRACT_VERSION,
155
- kind: 'preview-message',
156
- payload,
157
- sequence: this.#sequence++,
158
- sessionGeneration: this.#sessionGeneration,
159
- type: 'studio.preview/render',
207
+ this.#pending.set(request.requestId, {
208
+ cleanup,
209
+ payload: request,
210
+ reject,
211
+ resolve,
212
+ timeout,
160
213
  });
214
+ options.signal?.addEventListener('abort', abort, { once: true });
215
+ try {
216
+ this.#post(message);
217
+ }
218
+ catch (error) {
219
+ clearTimeout(timeout);
220
+ cleanup();
221
+ this.#pending.delete(request.requestId);
222
+ if (this.#latestRenderRequestId === request.requestId) {
223
+ this.#latestRenderRequestId = undefined;
224
+ }
225
+ reject(error instanceof Error ? error : new Error('Preview transport failed.'));
226
+ }
161
227
  });
162
228
  }
163
229
  /**
@@ -175,24 +241,55 @@ export class PreviewClient {
175
241
  if (options.signal?.aborted === true) {
176
242
  return Promise.reject(new Error('Preview measure was aborted.', { cause: options.signal.reason }));
177
243
  }
244
+ let request;
245
+ try {
246
+ request = snapshotOutboundPayload(payload);
247
+ }
248
+ catch (error) {
249
+ return Promise.reject(error instanceof Error
250
+ ? error
251
+ : new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.'));
252
+ }
178
253
  if (this.#latestRenderedDigest === undefined) {
179
254
  return Promise.reject(new Error('Preview measure requires a completed render.'));
180
255
  }
181
- if (this.#pendingMeasures.has(payload.requestId)) {
182
- return Promise.reject(new Error(`Measure ${payload.requestId} is already pending.`));
256
+ const message = {
257
+ channelId: this.#channelId,
258
+ contractVersion: STUDIO_CONTRACT_VERSION,
259
+ kind: 'preview-message',
260
+ payload: request,
261
+ sequence: this.#sequence,
262
+ sessionGeneration: this.#sessionGeneration,
263
+ type: 'studio.preview/measure',
264
+ };
265
+ try {
266
+ this.#assertOutbound(message);
267
+ }
268
+ catch (error) {
269
+ return Promise.reject(error instanceof Error
270
+ ? error
271
+ : new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.'));
272
+ }
273
+ if (this.#usedRequestIds.has(request.requestId)) {
274
+ return Promise.reject(new PreviewChannelError('studio.preview/request-id-reused', `Preview request ${request.requestId} was already used in this session.`));
275
+ }
276
+ if (request.markers.some((marker) => !this.#markerInventory.has(marker) ||
277
+ !isPreviewMarker(marker, this.#latestRenderedDigest))) {
278
+ return Promise.reject(new PreviewChannelError('studio.preview/measure-stale-marker', 'Preview measurement markers must belong to the current render inventory.', true));
183
279
  }
184
280
  for (const [requestId, pending] of this.#pendingMeasures) {
185
281
  clearTimeout(pending.timeout);
186
282
  pending.cleanup();
187
- pending.reject(new Error(`Preview measure ${requestId} was superseded by ${payload.requestId}.`));
283
+ pending.reject(new Error(`Preview measure ${requestId} was superseded by ${request.requestId}.`));
188
284
  }
189
285
  this.#pendingMeasures.clear();
286
+ this.#usedRequestIds.add(request.requestId);
190
287
  return new Promise((resolve, reject) => {
191
288
  const abort = () => {
192
- const pending = this.#pendingMeasures.get(payload.requestId);
289
+ const pending = this.#pendingMeasures.get(request.requestId);
193
290
  if (pending !== undefined) {
194
291
  clearTimeout(pending.timeout);
195
- this.#pendingMeasures.delete(payload.requestId);
292
+ this.#pendingMeasures.delete(request.requestId);
196
293
  pending.cleanup();
197
294
  pending.reject(new Error('Preview measure was aborted.'));
198
295
  }
@@ -201,24 +298,30 @@ export class PreviewClient {
201
298
  options.signal?.removeEventListener('abort', abort);
202
299
  };
203
300
  const timeout = setTimeout(() => {
204
- const pending = this.#pendingMeasures.get(payload.requestId);
301
+ const pending = this.#pendingMeasures.get(request.requestId);
205
302
  if (pending !== undefined) {
206
- this.#pendingMeasures.delete(payload.requestId);
303
+ this.#pendingMeasures.delete(request.requestId);
207
304
  pending.cleanup();
208
- pending.reject(new Error(`Preview measure ${payload.requestId} timed out.`));
305
+ pending.reject(new Error(`Preview measure ${request.requestId} timed out.`));
209
306
  }
210
307
  }, this.#timeoutMilliseconds);
211
- this.#pendingMeasures.set(payload.requestId, { cleanup, reject, resolve, timeout });
212
- options.signal?.addEventListener('abort', abort, { once: true });
213
- this.#post({
214
- channelId: this.#channelId,
215
- contractVersion: STUDIO_CONTRACT_VERSION,
216
- kind: 'preview-message',
217
- payload,
218
- sequence: this.#sequence++,
219
- sessionGeneration: this.#sessionGeneration,
220
- type: 'studio.preview/measure',
308
+ this.#pendingMeasures.set(request.requestId, {
309
+ cleanup,
310
+ payload: request,
311
+ reject,
312
+ resolve,
313
+ timeout,
221
314
  });
315
+ options.signal?.addEventListener('abort', abort, { once: true });
316
+ try {
317
+ this.#post(message);
318
+ }
319
+ catch (error) {
320
+ clearTimeout(timeout);
321
+ cleanup();
322
+ this.#pendingMeasures.delete(request.requestId);
323
+ reject(error instanceof Error ? error : new Error('Preview transport failed.'));
324
+ }
222
325
  });
223
326
  }
224
327
  /**
@@ -233,15 +336,18 @@ export class PreviewClient {
233
336
  if (hasRole === hasDimensions) {
234
337
  throw new RangeError('A viewport message carries either a semantic role or explicit dimensions, never both.');
235
338
  }
236
- this.#post({
339
+ const message = {
237
340
  channelId: this.#channelId,
238
341
  contractVersion: STUDIO_CONTRACT_VERSION,
239
342
  kind: 'preview-message',
240
343
  payload,
241
- sequence: this.#sequence++,
344
+ sequence: this.#sequence,
242
345
  sessionGeneration: this.#sessionGeneration,
243
346
  type: 'studio.preview/viewport',
244
- });
347
+ };
348
+ this.#assertOutbound(message);
349
+ this.#rejectPendingMeasures(new PreviewChannelError('studio.preview/measure-viewport-changed', 'Preview measurement was invalidated by a viewport change.', true));
350
+ this.#post(message);
245
351
  }
246
352
  /**
247
353
  * Instruct the renderer to revoke the resources it holds for a superseded
@@ -250,15 +356,34 @@ export class PreviewClient {
250
356
  */
251
357
  disposeDraft(payload) {
252
358
  this.#assertActive();
253
- this.#post({
359
+ const message = {
254
360
  channelId: this.#channelId,
255
361
  contractVersion: STUDIO_CONTRACT_VERSION,
256
362
  kind: 'preview-message',
257
363
  payload,
258
- sequence: this.#sequence++,
364
+ sequence: this.#sequence,
259
365
  sessionGeneration: this.#sessionGeneration,
260
366
  type: 'studio.preview/dispose',
261
- });
367
+ };
368
+ this.#assertOutbound(message);
369
+ for (const [requestId, pending] of this.#pending) {
370
+ if (payload.draftDigest === undefined ||
371
+ pending.payload.draftDigest === payload.draftDigest) {
372
+ clearTimeout(pending.timeout);
373
+ pending.cleanup();
374
+ pending.reject(new PreviewChannelError('studio.preview/render-disposed', `Preview render ${requestId} was disposed before completion.`));
375
+ this.#pending.delete(requestId);
376
+ if (this.#latestRenderRequestId === requestId) {
377
+ this.#latestRenderRequestId = undefined;
378
+ }
379
+ }
380
+ }
381
+ if (payload.draftDigest === undefined || payload.draftDigest === this.#latestRenderedDigest) {
382
+ this.#latestRenderedDigest = undefined;
383
+ this.#markerInventory.clear();
384
+ this.#rejectPendingMeasures(new PreviewChannelError('studio.preview/measure-disposed', 'Preview measurement was disposed with its render.'));
385
+ }
386
+ this.#post(message);
262
387
  }
263
388
  /** Observe trusted marker interactions the renderer reports. */
264
389
  onActivated(listener) {
@@ -274,7 +399,7 @@ export class PreviewClient {
274
399
  contractVersion: STUDIO_CONTRACT_VERSION,
275
400
  kind: 'preview-message',
276
401
  payload,
277
- sequence: this.#sequence++,
402
+ sequence: this.#sequence,
278
403
  sessionGeneration: this.#sessionGeneration,
279
404
  type: 'studio.preview/select',
280
405
  });
@@ -284,9 +409,44 @@ export class PreviewClient {
284
409
  throw new Error('Preview client was disposed.');
285
410
  }
286
411
  }
412
+ #assertOutbound(message) {
413
+ if (!isPreviewMessage(message)) {
414
+ throw new PreviewChannelError('studio.preview/invalid-outbound-message', 'Refused an invalid outbound preview message.');
415
+ }
416
+ }
287
417
  #post(message) {
418
+ this.#assertOutbound(message);
419
+ this.#sequence += 1;
288
420
  this.#target.postMessage(message, this.#targetOrigin);
289
421
  }
422
+ #rejectPendingMeasures(reason) {
423
+ for (const pending of this.#pendingMeasures.values()) {
424
+ clearTimeout(pending.timeout);
425
+ pending.cleanup();
426
+ pending.reject(reason);
427
+ }
428
+ this.#pendingMeasures.clear();
429
+ }
430
+ #revokeRemoteRender(draftDigest, reason) {
431
+ if (this.#disposed) {
432
+ return;
433
+ }
434
+ try {
435
+ this.#post({
436
+ channelId: this.#channelId,
437
+ contractVersion: STUDIO_CONTRACT_VERSION,
438
+ kind: 'preview-message',
439
+ payload: { draftDigest, reason },
440
+ sequence: this.#sequence,
441
+ sessionGeneration: this.#sessionGeneration,
442
+ type: 'studio.preview/dispose',
443
+ });
444
+ }
445
+ catch {
446
+ // Local cancellation is already complete. A transport exception cannot
447
+ // resurrect the request, and private transport details are not surfaced.
448
+ }
449
+ }
290
450
  #receive(event) {
291
451
  if (event.origin !== this.#targetOrigin ||
292
452
  event.source !== this.#target ||
@@ -298,28 +458,56 @@ export class PreviewClient {
298
458
  }
299
459
  this.#lastInboundSequence = event.data.sequence;
300
460
  if (event.data.type === 'studio.preview/activated') {
461
+ if (!this.#markerInventory.has(event.data.payload.marker) ||
462
+ !isPreviewMarker(event.data.payload.marker, this.#latestRenderedDigest)) {
463
+ return;
464
+ }
301
465
  for (const listener of this.#activationListeners) {
302
466
  listener(event.data.payload);
303
467
  }
304
468
  return;
305
469
  }
306
470
  if (event.data.type === 'studio.preview/rendered') {
307
- if (event.data.payload.draftDigest !== this.#latestRenderDigest) {
471
+ if (event.data.payload.requestId !== this.#latestRenderRequestId) {
472
+ return;
473
+ }
474
+ const pending = this.#pending.get(event.data.payload.requestId);
475
+ if (pending !== undefined && event.data.payload.draftDigest !== pending.payload.draftDigest) {
476
+ clearTimeout(pending.timeout);
477
+ pending.cleanup();
478
+ this.#pending.delete(event.data.payload.requestId);
479
+ this.#latestRenderRequestId = undefined;
480
+ pending.reject(new PreviewChannelError('studio.preview/render-correlation-mismatch', 'Preview response digest did not match its render request.'));
308
481
  return;
309
482
  }
310
- const pending = this.#pending.get(event.data.payload.draftDigest);
311
483
  if (pending !== undefined) {
312
484
  clearTimeout(pending.timeout);
313
485
  pending.cleanup();
314
- this.#pending.delete(event.data.payload.draftDigest);
315
- this.#latestRenderDigest = undefined;
486
+ this.#pending.delete(event.data.payload.requestId);
487
+ this.#latestRenderRequestId = undefined;
316
488
  this.#latestRenderedDigest = event.data.payload.draftDigest;
489
+ this.#markerInventory.clear();
490
+ for (const marker of event.data.payload.markers) {
491
+ this.#markerInventory.add(marker);
492
+ }
317
493
  pending.resolve(event.data.payload);
318
494
  }
319
495
  }
320
496
  else if (event.data.type === 'studio.preview/measurements') {
321
497
  const pending = this.#pendingMeasures.get(event.data.payload.requestId);
322
498
  if (pending !== undefined) {
499
+ const responseMarkers = [
500
+ ...Object.keys(event.data.payload.measurements),
501
+ ...event.data.payload.unknown,
502
+ ];
503
+ if (responseMarkers.length !== pending.payload.markers.length ||
504
+ pending.payload.markers.some((marker) => !responseMarkers.includes(marker))) {
505
+ clearTimeout(pending.timeout);
506
+ pending.cleanup();
507
+ this.#pendingMeasures.delete(event.data.payload.requestId);
508
+ pending.reject(new PreviewChannelError('studio.preview/invalid-measurements', 'Preview measurements did not exactly partition the requested marker inventory.'));
509
+ return;
510
+ }
323
511
  clearTimeout(pending.timeout);
324
512
  pending.cleanup();
325
513
  this.#pendingMeasures.delete(event.data.payload.requestId);
@@ -345,16 +533,17 @@ export class PreviewClient {
345
533
  if (pendingRender !== undefined) {
346
534
  clearTimeout(pendingRender.timeout);
347
535
  pendingRender.cleanup();
348
- pendingRender.reject(new Error(message));
536
+ pendingRender.reject(new PreviewChannelError(event.data.payload.code, message, event.data.payload.retryable));
349
537
  this.#pending.delete(correlationId);
350
- if (this.#latestRenderDigest === correlationId) {
351
- this.#latestRenderDigest = undefined;
538
+ if (this.#latestRenderRequestId === correlationId) {
539
+ this.#latestRenderRequestId = undefined;
540
+ this.#markerInventory.clear();
352
541
  }
353
542
  }
354
543
  if (pendingMeasure !== undefined) {
355
544
  clearTimeout(pendingMeasure.timeout);
356
545
  pendingMeasure.cleanup();
357
- pendingMeasure.reject(new Error(message));
546
+ pendingMeasure.reject(new PreviewChannelError(event.data.payload.code, message, event.data.payload.retryable));
358
547
  this.#pendingMeasures.delete(correlationId);
359
548
  }
360
549
  }
@@ -371,7 +560,9 @@ export class PreviewClient {
371
560
  pending.reject(new Error(message));
372
561
  }
373
562
  this.#pendingMeasures.clear();
374
- this.#latestRenderDigest = undefined;
563
+ this.#latestRenderRequestId = undefined;
564
+ this.#latestRenderedDigest = undefined;
565
+ this.#markerInventory.clear();
375
566
  }
376
567
  }
377
568
  else if (event.data.type === 'studio.preview/ready') {
@@ -403,8 +594,9 @@ export class PreviewClient {
403
594
  pending.reject(new Error(reason));
404
595
  }
405
596
  this.#pendingMeasures.clear();
406
- this.#latestRenderDigest = undefined;
597
+ this.#latestRenderRequestId = undefined;
407
598
  this.#latestRenderedDigest = undefined;
599
+ this.#markerInventory.clear();
408
600
  // A reloaded renderer announces itself again; the cached payload may
409
601
  // no longer describe it.
410
602
  this.#readyPayload = undefined;
@@ -425,7 +617,7 @@ export class PreviewClient {
425
617
  contractVersion: STUDIO_CONTRACT_VERSION,
426
618
  kind: 'preview-message',
427
619
  payload: { reason },
428
- sequence: this.#sequence++,
620
+ sequence: this.#sequence,
429
621
  sessionGeneration: this.#sessionGeneration,
430
622
  type: 'studio.preview/teardown',
431
623
  });