@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 +41 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/preview-client.d.ts +7 -1
- package/dist/preview-client.d.ts.map +1 -1
- package/dist/preview-client.js +254 -62
- package/dist/preview-client.js.map +1 -1
- package/dist/preview-host.d.ts +4 -2
- package/dist/preview-host.d.ts.map +1 -1
- package/dist/preview-host.js +182 -71
- package/dist/preview-host.js.map +1 -1
- 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 +3 -2
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()
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/preview-client.d.ts
CHANGED
|
@@ -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":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"preview-client.d.ts","sourceRoot":"","sources":["../src/preview-client.ts"],"names":[],"mappings":"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"}
|
package/dist/preview-client.js
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
114
|
-
|
|
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 [
|
|
167
|
+
for (const [requestId, pending] of this.#pending) {
|
|
117
168
|
clearTimeout(pending.timeout);
|
|
118
169
|
pending.cleanup();
|
|
119
|
-
pending.reject(new Error(`Preview render ${
|
|
170
|
+
pending.reject(new Error(`Preview render ${requestId} was superseded by ${request.requestId}.`));
|
|
120
171
|
}
|
|
121
172
|
this.#pending.clear();
|
|
122
|
-
this.#
|
|
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(
|
|
180
|
+
const pending = this.#pending.get(request.requestId);
|
|
126
181
|
if (pending !== undefined) {
|
|
127
182
|
clearTimeout(pending.timeout);
|
|
128
|
-
this.#pending.delete(
|
|
183
|
+
this.#pending.delete(request.requestId);
|
|
129
184
|
pending.cleanup();
|
|
130
|
-
if (this.#
|
|
131
|
-
this.#
|
|
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(
|
|
196
|
+
const pending = this.#pending.get(request.requestId);
|
|
141
197
|
if (pending !== undefined) {
|
|
142
|
-
this.#pending.delete(
|
|
198
|
+
this.#pending.delete(request.requestId);
|
|
143
199
|
pending.cleanup();
|
|
144
|
-
if (this.#
|
|
145
|
-
this.#
|
|
200
|
+
if (this.#latestRenderRequestId === request.requestId) {
|
|
201
|
+
this.#latestRenderRequestId = undefined;
|
|
146
202
|
}
|
|
147
|
-
pending.reject(new Error(`Preview render ${
|
|
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(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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 ${
|
|
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(
|
|
289
|
+
const pending = this.#pendingMeasures.get(request.requestId);
|
|
193
290
|
if (pending !== undefined) {
|
|
194
291
|
clearTimeout(pending.timeout);
|
|
195
|
-
this.#pendingMeasures.delete(
|
|
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(
|
|
301
|
+
const pending = this.#pendingMeasures.get(request.requestId);
|
|
205
302
|
if (pending !== undefined) {
|
|
206
|
-
this.#pendingMeasures.delete(
|
|
303
|
+
this.#pendingMeasures.delete(request.requestId);
|
|
207
304
|
pending.cleanup();
|
|
208
|
-
pending.reject(new Error(`Preview measure ${
|
|
305
|
+
pending.reject(new Error(`Preview measure ${request.requestId} timed out.`));
|
|
209
306
|
}
|
|
210
307
|
}, this.#timeoutMilliseconds);
|
|
211
|
-
this.#pendingMeasures.set(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
315
|
-
this.#
|
|
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
|
|
536
|
+
pendingRender.reject(new PreviewChannelError(event.data.payload.code, message, event.data.payload.retryable));
|
|
349
537
|
this.#pending.delete(correlationId);
|
|
350
|
-
if (this.#
|
|
351
|
-
this.#
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
});
|