@kumwe/studio-preview 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +83 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/preview-client.d.ts +96 -0
- package/dist/preview-client.d.ts.map +1 -0
- package/dist/preview-client.js +638 -0
- package/dist/preview-client.js.map +1 -0
- package/dist/preview-host.d.ts +68 -0
- package/dist/preview-host.d.ts.map +1 -0
- package/dist/preview-host.js +486 -0
- package/dist/preview-host.js.map +1 -0
- package/dist/preview-identity.d.ts +29 -0
- package/dist/preview-identity.d.ts.map +1 -0
- package/dist/preview-identity.js +61 -0
- package/dist/preview-identity.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,638 @@
|
|
|
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
|
+
}
|
|
21
|
+
export class PreviewClient {
|
|
22
|
+
#activationListeners = new Set();
|
|
23
|
+
#channelId;
|
|
24
|
+
#listener;
|
|
25
|
+
#listeners = new Set();
|
|
26
|
+
#markerInventory = new Set();
|
|
27
|
+
#pending = new Map();
|
|
28
|
+
#pendingMeasures = new Map();
|
|
29
|
+
#pendingReady = new Set();
|
|
30
|
+
#sessionGeneration;
|
|
31
|
+
#source;
|
|
32
|
+
#target;
|
|
33
|
+
#targetOrigin;
|
|
34
|
+
#timeoutMilliseconds;
|
|
35
|
+
#usedRequestIds = new Set();
|
|
36
|
+
#disposed = false;
|
|
37
|
+
#lastInboundSequence = -1;
|
|
38
|
+
#latestRenderRequestId;
|
|
39
|
+
#latestRenderedDigest;
|
|
40
|
+
#readyPayload;
|
|
41
|
+
#sequence = 0;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.#targetOrigin = normalizeOrigin(options.targetOrigin);
|
|
44
|
+
this.#channelId = options.channelId;
|
|
45
|
+
this.#sessionGeneration = options.sessionGeneration;
|
|
46
|
+
this.#source = options.source;
|
|
47
|
+
this.#target = options.target;
|
|
48
|
+
this.#timeoutMilliseconds = options.timeoutMilliseconds ?? 10_000;
|
|
49
|
+
this.#listener = (event) => {
|
|
50
|
+
this.#receive(event);
|
|
51
|
+
};
|
|
52
|
+
this.#source.addEventListener('message', this.#listener);
|
|
53
|
+
}
|
|
54
|
+
dispose() {
|
|
55
|
+
if (this.#disposed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.#disposed = true;
|
|
59
|
+
this.#source.removeEventListener('message', this.#listener);
|
|
60
|
+
for (const pending of this.#pending.values()) {
|
|
61
|
+
clearTimeout(pending.timeout);
|
|
62
|
+
pending.cleanup();
|
|
63
|
+
pending.reject(new Error('Preview client was disposed.'));
|
|
64
|
+
}
|
|
65
|
+
this.#pending.clear();
|
|
66
|
+
for (const pending of this.#pendingMeasures.values()) {
|
|
67
|
+
clearTimeout(pending.timeout);
|
|
68
|
+
pending.cleanup();
|
|
69
|
+
pending.reject(new Error('Preview client was disposed.'));
|
|
70
|
+
}
|
|
71
|
+
this.#pendingMeasures.clear();
|
|
72
|
+
for (const pending of this.#pendingReady) {
|
|
73
|
+
clearTimeout(pending.timeout);
|
|
74
|
+
pending.cleanup();
|
|
75
|
+
pending.reject(new Error('Preview client was disposed.'));
|
|
76
|
+
}
|
|
77
|
+
this.#pendingReady.clear();
|
|
78
|
+
this.#activationListeners.clear();
|
|
79
|
+
this.#listeners.clear();
|
|
80
|
+
this.#latestRenderRequestId = undefined;
|
|
81
|
+
this.#latestRenderedDigest = undefined;
|
|
82
|
+
this.#markerInventory.clear();
|
|
83
|
+
}
|
|
84
|
+
onMessage(listener) {
|
|
85
|
+
this.#listeners.add(listener);
|
|
86
|
+
return () => {
|
|
87
|
+
this.#listeners.delete(listener);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves once the host announces `studio.preview/ready` on this channel. If the
|
|
92
|
+
* announcement was already received, the cached payload resolves immediately.
|
|
93
|
+
*
|
|
94
|
+
* `isPreviewMessage` accepts only ready payloads carrying the exact draft wire protocol
|
|
95
|
+
* version, so an announcement from an incompatible host is filtered out and never resolves
|
|
96
|
+
* this promise — the wait times out instead. The promise also rejects on abort or when the
|
|
97
|
+
* client is disposed.
|
|
98
|
+
*/
|
|
99
|
+
ready(options = {}) {
|
|
100
|
+
if (this.#disposed) {
|
|
101
|
+
return Promise.reject(new Error('Preview client was disposed.'));
|
|
102
|
+
}
|
|
103
|
+
if (options.signal?.aborted === true) {
|
|
104
|
+
return Promise.reject(new Error('Preview ready wait was aborted.', { cause: options.signal.reason }));
|
|
105
|
+
}
|
|
106
|
+
if (this.#readyPayload !== undefined) {
|
|
107
|
+
return Promise.resolve(this.#readyPayload);
|
|
108
|
+
}
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const abort = () => {
|
|
111
|
+
if (this.#pendingReady.delete(pending)) {
|
|
112
|
+
clearTimeout(pending.timeout);
|
|
113
|
+
pending.cleanup();
|
|
114
|
+
pending.reject(new Error('Preview ready wait was aborted.'));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const cleanup = () => {
|
|
118
|
+
options.signal?.removeEventListener('abort', abort);
|
|
119
|
+
};
|
|
120
|
+
const timeout = setTimeout(() => {
|
|
121
|
+
if (this.#pendingReady.delete(pending)) {
|
|
122
|
+
pending.cleanup();
|
|
123
|
+
pending.reject(new Error('Preview ready wait timed out.'));
|
|
124
|
+
}
|
|
125
|
+
}, this.#timeoutMilliseconds);
|
|
126
|
+
const pending = { cleanup, reject, resolve, timeout };
|
|
127
|
+
this.#pendingReady.add(pending);
|
|
128
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
render(payload, options = {}) {
|
|
132
|
+
if (this.#disposed) {
|
|
133
|
+
return Promise.reject(new Error('Preview client was disposed.'));
|
|
134
|
+
}
|
|
135
|
+
if (options.signal?.aborted === true) {
|
|
136
|
+
return Promise.reject(new Error('Preview render was aborted.', { cause: options.signal.reason }));
|
|
137
|
+
}
|
|
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.`));
|
|
166
|
+
}
|
|
167
|
+
for (const [requestId, pending] of this.#pending) {
|
|
168
|
+
clearTimeout(pending.timeout);
|
|
169
|
+
pending.cleanup();
|
|
170
|
+
pending.reject(new Error(`Preview render ${requestId} was superseded by ${request.requestId}.`));
|
|
171
|
+
}
|
|
172
|
+
this.#pending.clear();
|
|
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();
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const abort = () => {
|
|
180
|
+
const pending = this.#pending.get(request.requestId);
|
|
181
|
+
if (pending !== undefined) {
|
|
182
|
+
clearTimeout(pending.timeout);
|
|
183
|
+
this.#pending.delete(request.requestId);
|
|
184
|
+
pending.cleanup();
|
|
185
|
+
if (this.#latestRenderRequestId === request.requestId) {
|
|
186
|
+
this.#latestRenderRequestId = undefined;
|
|
187
|
+
}
|
|
188
|
+
pending.reject(new Error('Preview render was aborted.'));
|
|
189
|
+
this.#revokeRemoteRender(request.draftDigest, 'studio.preview/client-aborted');
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
options.signal?.removeEventListener('abort', abort);
|
|
194
|
+
};
|
|
195
|
+
const timeout = setTimeout(() => {
|
|
196
|
+
const pending = this.#pending.get(request.requestId);
|
|
197
|
+
if (pending !== undefined) {
|
|
198
|
+
this.#pending.delete(request.requestId);
|
|
199
|
+
pending.cleanup();
|
|
200
|
+
if (this.#latestRenderRequestId === request.requestId) {
|
|
201
|
+
this.#latestRenderRequestId = undefined;
|
|
202
|
+
}
|
|
203
|
+
pending.reject(new Error(`Preview render ${request.requestId} timed out.`));
|
|
204
|
+
this.#revokeRemoteRender(request.draftDigest, 'studio.preview/client-timeout');
|
|
205
|
+
}
|
|
206
|
+
}, this.#timeoutMilliseconds);
|
|
207
|
+
this.#pending.set(request.requestId, {
|
|
208
|
+
cleanup,
|
|
209
|
+
payload: request,
|
|
210
|
+
reject,
|
|
211
|
+
resolve,
|
|
212
|
+
timeout,
|
|
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
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Requests the on-screen geometry of render markers from the responder. Resolves with a
|
|
231
|
+
* `measured` outcome carrying viewport-relative CSS-pixel rectangles, or a `stale`
|
|
232
|
+
* outcome when the response was measured against a render this client no longer
|
|
233
|
+
* considers latest. Rejects on teardown, reload, supersession, abort, timeout, and
|
|
234
|
+
* disposal exactly like `render()` does. Requires a completed render: geometry is a
|
|
235
|
+
* volatile measurement of a specific render digest, never document state.
|
|
236
|
+
*/
|
|
237
|
+
measure(payload, options = {}) {
|
|
238
|
+
if (this.#disposed) {
|
|
239
|
+
return Promise.reject(new Error('Preview client was disposed.'));
|
|
240
|
+
}
|
|
241
|
+
if (options.signal?.aborted === true) {
|
|
242
|
+
return Promise.reject(new Error('Preview measure was aborted.', { cause: options.signal.reason }));
|
|
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
|
+
}
|
|
253
|
+
if (this.#latestRenderedDigest === undefined) {
|
|
254
|
+
return Promise.reject(new Error('Preview measure requires a completed render.'));
|
|
255
|
+
}
|
|
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));
|
|
279
|
+
}
|
|
280
|
+
for (const [requestId, pending] of this.#pendingMeasures) {
|
|
281
|
+
clearTimeout(pending.timeout);
|
|
282
|
+
pending.cleanup();
|
|
283
|
+
pending.reject(new Error(`Preview measure ${requestId} was superseded by ${request.requestId}.`));
|
|
284
|
+
}
|
|
285
|
+
this.#pendingMeasures.clear();
|
|
286
|
+
this.#usedRequestIds.add(request.requestId);
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const abort = () => {
|
|
289
|
+
const pending = this.#pendingMeasures.get(request.requestId);
|
|
290
|
+
if (pending !== undefined) {
|
|
291
|
+
clearTimeout(pending.timeout);
|
|
292
|
+
this.#pendingMeasures.delete(request.requestId);
|
|
293
|
+
pending.cleanup();
|
|
294
|
+
pending.reject(new Error('Preview measure was aborted.'));
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const cleanup = () => {
|
|
298
|
+
options.signal?.removeEventListener('abort', abort);
|
|
299
|
+
};
|
|
300
|
+
const timeout = setTimeout(() => {
|
|
301
|
+
const pending = this.#pendingMeasures.get(request.requestId);
|
|
302
|
+
if (pending !== undefined) {
|
|
303
|
+
this.#pendingMeasures.delete(request.requestId);
|
|
304
|
+
pending.cleanup();
|
|
305
|
+
pending.reject(new Error(`Preview measure ${request.requestId} timed out.`));
|
|
306
|
+
}
|
|
307
|
+
}, this.#timeoutMilliseconds);
|
|
308
|
+
this.#pendingMeasures.set(request.requestId, {
|
|
309
|
+
cleanup,
|
|
310
|
+
payload: request,
|
|
311
|
+
reject,
|
|
312
|
+
resolve,
|
|
313
|
+
timeout,
|
|
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
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Drive the preview surface to a semantic viewport role or to bounded
|
|
329
|
+
* explicit dimensions. The two are alternatives, so a payload carrying both
|
|
330
|
+
* is refused before it reaches the channel.
|
|
331
|
+
*/
|
|
332
|
+
setViewport(payload) {
|
|
333
|
+
this.#assertActive();
|
|
334
|
+
const hasRole = payload.viewport !== undefined;
|
|
335
|
+
const hasDimensions = payload.width !== undefined || payload.height !== undefined;
|
|
336
|
+
if (hasRole === hasDimensions) {
|
|
337
|
+
throw new RangeError('A viewport message carries either a semantic role or explicit dimensions, never both.');
|
|
338
|
+
}
|
|
339
|
+
const message = {
|
|
340
|
+
channelId: this.#channelId,
|
|
341
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
342
|
+
kind: 'preview-message',
|
|
343
|
+
payload,
|
|
344
|
+
sequence: this.#sequence,
|
|
345
|
+
sessionGeneration: this.#sessionGeneration,
|
|
346
|
+
type: 'studio.preview/viewport',
|
|
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);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Instruct the renderer to revoke the resources it holds for a superseded
|
|
354
|
+
* draft while the channel stays open. This is not teardown: teardown ends
|
|
355
|
+
* the session, dispose frees a render's resources within it.
|
|
356
|
+
*/
|
|
357
|
+
disposeDraft(payload) {
|
|
358
|
+
this.#assertActive();
|
|
359
|
+
const message = {
|
|
360
|
+
channelId: this.#channelId,
|
|
361
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
362
|
+
kind: 'preview-message',
|
|
363
|
+
payload,
|
|
364
|
+
sequence: this.#sequence,
|
|
365
|
+
sessionGeneration: this.#sessionGeneration,
|
|
366
|
+
type: 'studio.preview/dispose',
|
|
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);
|
|
387
|
+
}
|
|
388
|
+
/** Observe trusted marker interactions the renderer reports. */
|
|
389
|
+
onActivated(listener) {
|
|
390
|
+
this.#activationListeners.add(listener);
|
|
391
|
+
return () => {
|
|
392
|
+
this.#activationListeners.delete(listener);
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
select(payload) {
|
|
396
|
+
this.#assertActive();
|
|
397
|
+
this.#post({
|
|
398
|
+
channelId: this.#channelId,
|
|
399
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
400
|
+
kind: 'preview-message',
|
|
401
|
+
payload,
|
|
402
|
+
sequence: this.#sequence,
|
|
403
|
+
sessionGeneration: this.#sessionGeneration,
|
|
404
|
+
type: 'studio.preview/select',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
#assertActive() {
|
|
408
|
+
if (this.#disposed) {
|
|
409
|
+
throw new Error('Preview client was disposed.');
|
|
410
|
+
}
|
|
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
|
+
}
|
|
417
|
+
#post(message) {
|
|
418
|
+
this.#assertOutbound(message);
|
|
419
|
+
this.#sequence += 1;
|
|
420
|
+
this.#target.postMessage(message, this.#targetOrigin);
|
|
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
|
+
}
|
|
450
|
+
#receive(event) {
|
|
451
|
+
if (event.origin !== this.#targetOrigin ||
|
|
452
|
+
event.source !== this.#target ||
|
|
453
|
+
!isPreviewMessage(event.data) ||
|
|
454
|
+
event.data.channelId !== this.#channelId ||
|
|
455
|
+
event.data.sessionGeneration !== this.#sessionGeneration ||
|
|
456
|
+
event.data.sequence <= this.#lastInboundSequence) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.#lastInboundSequence = event.data.sequence;
|
|
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
|
+
}
|
|
465
|
+
for (const listener of this.#activationListeners) {
|
|
466
|
+
listener(event.data.payload);
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (event.data.type === 'studio.preview/rendered') {
|
|
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.'));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (pending !== undefined) {
|
|
484
|
+
clearTimeout(pending.timeout);
|
|
485
|
+
pending.cleanup();
|
|
486
|
+
this.#pending.delete(event.data.payload.requestId);
|
|
487
|
+
this.#latestRenderRequestId = undefined;
|
|
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
|
+
}
|
|
493
|
+
pending.resolve(event.data.payload);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else if (event.data.type === 'studio.preview/measurements') {
|
|
497
|
+
const pending = this.#pendingMeasures.get(event.data.payload.requestId);
|
|
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
|
+
}
|
|
511
|
+
clearTimeout(pending.timeout);
|
|
512
|
+
pending.cleanup();
|
|
513
|
+
this.#pendingMeasures.delete(event.data.payload.requestId);
|
|
514
|
+
// Geometry measured against a superseded render is discarded, not surfaced:
|
|
515
|
+
// the typed stale outcome tells the caller to re-measure.
|
|
516
|
+
if (event.data.payload.draftDigest === this.#latestRenderedDigest) {
|
|
517
|
+
pending.resolve({ geometry: event.data.payload, status: 'measured' });
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
pending.resolve({ measuredDigest: event.data.payload.draftDigest, status: 'stale' });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if (event.data.type === 'studio.preview/error') {
|
|
525
|
+
const message = event.data.payload.message.defaultMessage ?? event.data.payload.message.key;
|
|
526
|
+
const correlationId = event.data.payload.correlationId;
|
|
527
|
+
if (correlationId !== undefined) {
|
|
528
|
+
const pendingRender = this.#pending.get(correlationId);
|
|
529
|
+
const pendingMeasure = this.#pendingMeasures.get(correlationId);
|
|
530
|
+
if (pendingRender === undefined && pendingMeasure === undefined) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (pendingRender !== undefined) {
|
|
534
|
+
clearTimeout(pendingRender.timeout);
|
|
535
|
+
pendingRender.cleanup();
|
|
536
|
+
pendingRender.reject(new PreviewChannelError(event.data.payload.code, message, event.data.payload.retryable));
|
|
537
|
+
this.#pending.delete(correlationId);
|
|
538
|
+
if (this.#latestRenderRequestId === correlationId) {
|
|
539
|
+
this.#latestRenderRequestId = undefined;
|
|
540
|
+
this.#markerInventory.clear();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (pendingMeasure !== undefined) {
|
|
544
|
+
clearTimeout(pendingMeasure.timeout);
|
|
545
|
+
pendingMeasure.cleanup();
|
|
546
|
+
pendingMeasure.reject(new PreviewChannelError(event.data.payload.code, message, event.data.payload.retryable));
|
|
547
|
+
this.#pendingMeasures.delete(correlationId);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
for (const pending of this.#pending.values()) {
|
|
552
|
+
clearTimeout(pending.timeout);
|
|
553
|
+
pending.cleanup();
|
|
554
|
+
pending.reject(new Error(message));
|
|
555
|
+
}
|
|
556
|
+
this.#pending.clear();
|
|
557
|
+
for (const pending of this.#pendingMeasures.values()) {
|
|
558
|
+
clearTimeout(pending.timeout);
|
|
559
|
+
pending.cleanup();
|
|
560
|
+
pending.reject(new Error(message));
|
|
561
|
+
}
|
|
562
|
+
this.#pendingMeasures.clear();
|
|
563
|
+
this.#latestRenderRequestId = undefined;
|
|
564
|
+
this.#latestRenderedDigest = undefined;
|
|
565
|
+
this.#markerInventory.clear();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else if (event.data.type === 'studio.preview/ready') {
|
|
569
|
+
this.#readyPayload = event.data.payload;
|
|
570
|
+
const waiters = [...this.#pendingReady];
|
|
571
|
+
this.#pendingReady.clear();
|
|
572
|
+
for (const pending of waiters) {
|
|
573
|
+
clearTimeout(pending.timeout);
|
|
574
|
+
pending.cleanup();
|
|
575
|
+
pending.resolve(event.data.payload);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else if (event.data.type === 'studio.preview/reload' ||
|
|
579
|
+
event.data.type === 'studio.preview/teardown') {
|
|
580
|
+
const reason = event.data.type === 'studio.preview/reload'
|
|
581
|
+
? 'Preview renderer reloaded before responding.'
|
|
582
|
+
: 'Preview channel was torn down.';
|
|
583
|
+
for (const pending of this.#pending.values()) {
|
|
584
|
+
clearTimeout(pending.timeout);
|
|
585
|
+
pending.cleanup();
|
|
586
|
+
pending.reject(new Error(reason));
|
|
587
|
+
}
|
|
588
|
+
this.#pending.clear();
|
|
589
|
+
// A reload voids in-flight measurements exactly like it voids renders, and the
|
|
590
|
+
// digest they were bound to no longer describes what the surface shows.
|
|
591
|
+
for (const pending of this.#pendingMeasures.values()) {
|
|
592
|
+
clearTimeout(pending.timeout);
|
|
593
|
+
pending.cleanup();
|
|
594
|
+
pending.reject(new Error(reason));
|
|
595
|
+
}
|
|
596
|
+
this.#pendingMeasures.clear();
|
|
597
|
+
this.#latestRenderRequestId = undefined;
|
|
598
|
+
this.#latestRenderedDigest = undefined;
|
|
599
|
+
this.#markerInventory.clear();
|
|
600
|
+
// A reloaded renderer announces itself again; the cached payload may
|
|
601
|
+
// no longer describe it.
|
|
602
|
+
this.#readyPayload = undefined;
|
|
603
|
+
}
|
|
604
|
+
const teardown = event.data.type === 'studio.preview/teardown';
|
|
605
|
+
for (const listener of this.#listeners) {
|
|
606
|
+
listener(event.data);
|
|
607
|
+
}
|
|
608
|
+
if (teardown) {
|
|
609
|
+
this.dispose();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** Announce channel closure to the host, then dispose this client. */
|
|
613
|
+
teardown(reason) {
|
|
614
|
+
this.#assertActive();
|
|
615
|
+
this.#post({
|
|
616
|
+
channelId: this.#channelId,
|
|
617
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
618
|
+
kind: 'preview-message',
|
|
619
|
+
payload: { reason },
|
|
620
|
+
sequence: this.#sequence,
|
|
621
|
+
sessionGeneration: this.#sessionGeneration,
|
|
622
|
+
type: 'studio.preview/teardown',
|
|
623
|
+
});
|
|
624
|
+
this.dispose();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Shared with PreviewHost; intentionally not re-exported from the package index.
|
|
628
|
+
export function normalizeOrigin(input) {
|
|
629
|
+
if (input === '*') {
|
|
630
|
+
throw new TypeError('Preview target origin must be exact; wildcard origins are forbidden.');
|
|
631
|
+
}
|
|
632
|
+
const url = new URL(input);
|
|
633
|
+
if (url.origin === 'null') {
|
|
634
|
+
throw new TypeError('Preview target origin must use a network origin.');
|
|
635
|
+
}
|
|
636
|
+
return url.origin;
|
|
637
|
+
}
|
|
638
|
+
//# sourceMappingURL=preview-client.js.map
|