@floegence/redevplugin-ui 0.4.3 → 0.5.1

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/dist/surface.js CHANGED
@@ -1,14 +1,17 @@
1
- import { PluginBridgeError, pluginBridgeErrorCodes } from "./errors.js";
1
+ import { PluginBridgeError, PluginPlatformRequestError, PluginTransportError, pluginBridgeErrorCodes, } from "./errors.js";
2
+ import { pluginBridgeCapabilityEffect, } from "./bridge-capability.js";
2
3
  import { pluginUIProtocolVersion } from "./contracts.gen.js";
3
4
  import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
4
- import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readHostEnvelope, } from "./http.js";
5
+ import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readMutationPlatformResponse, readPlatformResponse, } from "./http.js";
5
6
  import { defaultPluginSurfaceScope, registerPluginSurface, } from "./surface-scope.js";
6
- export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v2";
7
+ import { PluginUIReconcileError, reconcileValidatedPluginUITrees, validatePluginUITree, } from "./ui-reconciler.js";
8
+ export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v3";
7
9
  export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
8
- const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridgeV2";
10
+ const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridge";
9
11
  const maxPendingPluginBridgeRequests = 256;
10
- const maxPluginBridgeMessageBytes = 256 * 1024;
12
+ const maxPluginBridgeMessageBytes = opaqueSurfaceRenderLimits.max_message_bytes;
11
13
  const maxRetainedPluginStreamHandles = 128;
14
+ const maxPluginJSONStructuralNodes = 32 * 1024;
12
15
  const streamCredentialInvalidatingErrorCodes = new Set([
13
16
  "PLUGIN_BRIDGE_DISPOSED",
14
17
  "PLUGIN_BRIDGE_HANDSHAKE_FAILED",
@@ -21,7 +24,7 @@ const streamCredentialInvalidatingErrorCodes = new Set([
21
24
  "PLUGIN_GRANT_INVALID",
22
25
  "PLUGIN_LEASE_INVALID",
23
26
  "PLUGIN_LEASE_REPLAYED",
24
- "PLUGIN_STATE_VERSION_MISMATCH",
27
+ "PLUGIN_MANAGEMENT_REVISION_MISMATCH",
25
28
  "PLUGIN_STREAM_CANCELLED",
26
29
  "PLUGIN_STREAM_TICKET_INVALID",
27
30
  "PLUGIN_TOKEN_EXPIRED",
@@ -49,7 +52,15 @@ export class PluginBridgeClient {
49
52
  #readyPromise;
50
53
  #resolveReady;
51
54
  #rejectReady;
52
- #disposed = false;
55
+ #lifecycleState = "active";
56
+ #handlingLifecycle = false;
57
+ #renderRevision = 0;
58
+ #committedTree;
59
+ #pendingRender;
60
+ #renderLoop;
61
+ #controlEditRevisions = new Map();
62
+ #pendingStreamDeliveries = new Map();
63
+ #streamReadTails = new Map();
53
64
  #onMessage = (event) => {
54
65
  void this.#handleMessage(event);
55
66
  };
@@ -72,7 +83,11 @@ export class PluginBridgeClient {
72
83
  this.#assertActive();
73
84
  return this.#ready ? Promise.resolve() : this.#readyPromise;
74
85
  }
75
- call(method, params) {
86
+ call(method, params, options = {}) {
87
+ const effect = options[pluginBridgeCapabilityEffect];
88
+ return this.#call(method, params, options, effect === undefined || effect !== "read");
89
+ }
90
+ #call(method, params, options, mutation) {
76
91
  this.#assertActive();
77
92
  if (!validMethod(method)) {
78
93
  throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin bridge method and params are invalid");
@@ -91,21 +106,58 @@ export class PluginBridgeClient {
91
106
  return this.#request(id, {
92
107
  type: "redevplugin.bridge.call",
93
108
  request,
94
- });
109
+ }, { mutation, signal: options.signal });
95
110
  }
96
- readStream(streamHandle) {
111
+ readStream(streamHandle, options = {}) {
97
112
  this.#assertActive();
98
113
  if (!validOpaqueHandle(streamHandle, "stream")) {
99
114
  throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin stream handle is invalid");
100
115
  }
116
+ const previous = this.#streamReadTails.get(streamHandle);
117
+ const read = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => {
118
+ this.#assertActive();
119
+ if (options.signal?.aborted)
120
+ throw streamReadAbortedError();
121
+ return this.#readStream(streamHandle, options.signal);
122
+ });
123
+ this.#streamReadTails.set(streamHandle, read);
124
+ void read.finally(() => {
125
+ if (this.#streamReadTails.get(streamHandle) === read)
126
+ this.#streamReadTails.delete(streamHandle);
127
+ }).catch(() => undefined);
128
+ return abortableStreamRead(read, options.signal);
129
+ }
130
+ async #readStream(streamHandle, signal) {
131
+ const pending = this.#pendingStreamDeliveries.get(streamHandle);
132
+ if (pending) {
133
+ await this.#acknowledgeStream(streamHandle, pending.deliveryID, signal);
134
+ this.#pendingStreamDeliveries.delete(streamHandle);
135
+ return pending.result;
136
+ }
101
137
  const id = this.#requestID("stream");
102
- return this.#request(id, {
138
+ const privateResult = await this.#request(id, {
103
139
  type: "redevplugin.bridge.stream.read",
104
140
  id,
105
141
  stream_handle: streamHandle,
106
- });
142
+ }, { cancellationKind: "stream", signal });
143
+ const { delivery_id: deliveryID, ...result } = privateResult;
144
+ if (!deliveryID)
145
+ return result;
146
+ this.#pendingStreamDeliveries.set(streamHandle, { deliveryID, result });
147
+ await this.#acknowledgeStream(streamHandle, deliveryID, signal);
148
+ this.#pendingStreamDeliveries.delete(streamHandle);
149
+ return result;
150
+ }
151
+ async #acknowledgeStream(streamHandle, deliveryID, signal) {
152
+ const id = this.#requestID("stream_ack");
153
+ await this.#request(id, {
154
+ type: "redevplugin.bridge.stream.ack",
155
+ id,
156
+ stream_handle: streamHandle,
157
+ delivery_id: deliveryID,
158
+ }, { cancellationKind: "stream", signal });
107
159
  }
108
- cancelOperation(operationID, reason) {
160
+ cancelOperation(operationID, reason, options = {}) {
109
161
  this.#assertActive();
110
162
  if (!validOpaqueHandle(operationID, "operation") || (reason !== undefined && (typeof reason !== "string" || reason.length > 256))) {
111
163
  throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin operation cancellation is invalid");
@@ -116,15 +168,22 @@ export class PluginBridgeClient {
116
168
  id,
117
169
  operation_id: operationID,
118
170
  reason,
119
- }));
171
+ }), { mutation: true, signal: options.signal });
120
172
  }
121
173
  render(tree) {
122
174
  this.#assertActive();
123
- const id = this.#requestID("render");
124
- return this.#request(id, {
125
- type: "redevplugin.ui.render",
126
- id,
127
- tree,
175
+ let validated;
176
+ try {
177
+ validated = validatePluginUITree(tree);
178
+ }
179
+ catch (error) {
180
+ throw new PluginBridgeError("PLUGIN_UI_PROTOCOL_VIOLATION", error instanceof Error ? error.message : "Plugin UI tree is invalid");
181
+ }
182
+ return new Promise((resolve, reject) => {
183
+ const waiters = this.#pendingRender?.waiters ?? [];
184
+ waiters.push({ resolve, reject });
185
+ this.#pendingRender = { tree: validated, waiters };
186
+ this.#startRenderLoop();
128
187
  });
129
188
  }
130
189
  openCanvas(canvasId) {
@@ -137,7 +196,7 @@ export class PluginBridgeClient {
137
196
  type: "redevplugin.ui.canvas.open",
138
197
  id,
139
198
  canvas_id: canvasId,
140
- }, "canvas", canvasId);
199
+ }, { kind: "canvas", identifier: canvasId });
141
200
  }
142
201
  updateCanvasAccessibility(canvasId, state) {
143
202
  this.#assertActive();
@@ -163,7 +222,7 @@ export class PluginBridgeClient {
163
222
  type: "redevplugin.ui.asset.image.open",
164
223
  id,
165
224
  asset_id: assetId,
166
- }, "asset", assetId);
225
+ }, { kind: "asset", identifier: assetId });
167
226
  }
168
227
  onAction(action, handler) {
169
228
  this.#assertActive();
@@ -201,24 +260,97 @@ export class PluginBridgeClient {
201
260
  };
202
261
  }
203
262
  dispose() {
204
- if (this.#disposed)
263
+ if (this.#lifecycleState === "disposed")
205
264
  return;
206
- this.#disposed = true;
265
+ this.#lifecycleState = "disposed";
207
266
  const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
208
267
  if (!this.#ready)
209
268
  this.#rejectReady(disposedError);
210
269
  this.#port.removeEventListener("message", this.#onMessage);
211
270
  for (const [id, pending] of this.#pending) {
212
271
  clearTimeout(pending.timer);
272
+ pending.abortCleanup?.();
213
273
  pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} was disposed`));
214
274
  }
215
275
  this.#pending.clear();
276
+ this.#pendingStreamDeliveries.clear();
216
277
  this.#actionHandlers.clear();
217
278
  this.#canvasInputHandlers.clear();
218
279
  this.#lifecycleHandlers.clear();
280
+ for (const waiter of this.#pendingRender?.waiters ?? [])
281
+ waiter.reject(disposedError);
282
+ this.#pendingRender = undefined;
283
+ this.#controlEditRevisions.clear();
219
284
  this.#port.close();
220
285
  }
221
- #request(id, message, kind = "json", identifier) {
286
+ async #drainRenders() {
287
+ while (this.#pendingRender && this.#acceptsPluginWork()) {
288
+ const pending = this.#pendingRender;
289
+ this.#pendingRender = undefined;
290
+ const id = this.#requestID("render");
291
+ const nextRevision = this.#renderRevision + 1;
292
+ try {
293
+ if (!this.#committedTree) {
294
+ await this.#request(id, {
295
+ type: "redevplugin.ui.mount",
296
+ id,
297
+ revision: 1,
298
+ tree: pending.tree,
299
+ });
300
+ }
301
+ else {
302
+ const operations = reconcileValidatedPluginUITrees(this.#committedTree, pending.tree, {
303
+ controlEditRevisions: this.#controlEditRevisions,
304
+ });
305
+ if (operations.length > 0) {
306
+ await this.#request(id, {
307
+ type: "redevplugin.ui.patch",
308
+ id,
309
+ base_revision: this.#renderRevision,
310
+ revision: nextRevision,
311
+ operations,
312
+ });
313
+ this.#renderRevision = nextRevision;
314
+ }
315
+ }
316
+ this.#committedTree = pending.tree;
317
+ if (this.#renderRevision === 0)
318
+ this.#renderRevision = 1;
319
+ for (const waiter of pending.waiters)
320
+ waiter.resolve();
321
+ }
322
+ catch (error) {
323
+ const bridgeError = error instanceof PluginUIReconcileError
324
+ ? new PluginBridgeError("PLUGIN_UI_PROTOCOL_VIOLATION", error.message)
325
+ : error;
326
+ for (const waiter of pending.waiters)
327
+ waiter.reject(bridgeError);
328
+ const queued = this.#pendingRender;
329
+ for (const waiter of queued?.waiters ?? [])
330
+ waiter.reject(bridgeError);
331
+ this.#pendingRender = undefined;
332
+ throw bridgeError;
333
+ }
334
+ }
335
+ if (this.#pendingRender) {
336
+ const error = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin UI cannot render after quiescing starts");
337
+ for (const waiter of this.#pendingRender.waiters)
338
+ waiter.reject(error);
339
+ this.#pendingRender = undefined;
340
+ }
341
+ }
342
+ #startRenderLoop() {
343
+ if (this.#renderLoop)
344
+ return;
345
+ this.#renderLoop = this.#drainRenders()
346
+ .catch(() => undefined)
347
+ .finally(() => {
348
+ this.#renderLoop = undefined;
349
+ if (this.#pendingRender)
350
+ this.#startRenderLoop();
351
+ });
352
+ }
353
+ #request(id, message, options = {}) {
222
354
  let normalizedMessage;
223
355
  try {
224
356
  normalizedMessage = normalizePluginJSONObject(message);
@@ -232,9 +364,13 @@ export class PluginBridgeClient {
232
364
  if (this.#pending.size >= maxPendingPluginBridgeRequests) {
233
365
  throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge has too many pending requests");
234
366
  }
367
+ if (options.signal?.aborted)
368
+ return Promise.reject(requestCancelledError(options, false));
369
+ let posted = false;
235
370
  const result = new Promise((resolve, reject) => {
236
371
  const timer = setTimeout(() => {
237
- if (!this.#pending.delete(id))
372
+ const pending = this.#takePending(id);
373
+ if (!pending)
238
374
  return;
239
375
  try {
240
376
  this.#port.postMessage({ type: "redevplugin.bridge.cancel", id });
@@ -242,39 +378,65 @@ export class PluginBridgeClient {
242
378
  catch {
243
379
  // The request is already locally cancelled when the port closes concurrently.
244
380
  }
245
- reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin bridge request ${id} timed out`));
381
+ pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin bridge request ${id} timed out`, undefined, undefined, mutationOutcome(options, posted)));
246
382
  }, this.timeoutMs);
383
+ const onAbort = () => {
384
+ const pending = this.#takePending(id);
385
+ if (!pending)
386
+ return;
387
+ if (posted) {
388
+ try {
389
+ this.#port.postMessage({ type: "redevplugin.bridge.cancel", id });
390
+ }
391
+ catch {
392
+ // The request is already locally cancelled when the port closes concurrently.
393
+ }
394
+ }
395
+ pending.reject(requestCancelledError(options, posted));
396
+ };
247
397
  this.#pending.set(id, {
248
398
  resolve: (value) => resolve(value),
249
399
  reject,
250
400
  timer,
251
- kind,
252
- identifier,
401
+ abortCleanup: options.signal ? () => options.signal?.removeEventListener("abort", onAbort) : undefined,
402
+ kind: options.kind ?? "json",
403
+ identifier: options.identifier,
253
404
  });
405
+ options.signal?.addEventListener("abort", onAbort, { once: true });
406
+ if (options.signal?.aborted)
407
+ onAbort();
254
408
  });
409
+ if (!this.#pending.has(id))
410
+ return result;
255
411
  try {
256
412
  this.#port.postMessage(normalizedMessage);
413
+ posted = true;
257
414
  }
258
415
  catch {
259
- const pending = this.#pending.get(id);
416
+ const pending = this.#takePending(id);
260
417
  if (pending) {
261
- this.#pending.delete(id);
262
- clearTimeout(pending.timer);
263
- pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} could not be posted`));
418
+ pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} could not be posted`, undefined, undefined, options.mutation ? "not_committed" : undefined));
264
419
  }
265
420
  }
266
421
  return result;
267
422
  }
423
+ #takePending(id) {
424
+ const pending = this.#pending.get(id);
425
+ if (!pending)
426
+ return undefined;
427
+ this.#pending.delete(id);
428
+ clearTimeout(pending.timer);
429
+ pending.abortCleanup?.();
430
+ return pending;
431
+ }
268
432
  async #handleMessage(event) {
269
- if (this.#disposed)
433
+ if (this.#lifecycleState === "disposed")
270
434
  return;
271
435
  const data = event.data;
272
436
  if (isCanvasReadyCandidate(data)) {
273
- const pending = this.#pending.get(data.id);
437
+ const pending = this.#takePending(data.id);
274
438
  if (!pending)
275
439
  return;
276
- this.#pending.delete(data.id);
277
- clearTimeout(pending.timer);
278
440
  if (pending.kind !== "canvas" || pending.identifier !== data.canvas_id || !isCanvasReadyMessage(data)) {
279
441
  pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin canvas response ${data.id} is invalid`));
280
442
  return;
@@ -289,11 +451,9 @@ export class PluginBridgeClient {
289
451
  return;
290
452
  }
291
453
  if (isImageReadyCandidate(data)) {
292
- const pending = this.#pending.get(data.id);
454
+ const pending = this.#takePending(data.id);
293
455
  if (!pending)
294
456
  return;
295
- this.#pending.delete(data.id);
296
- clearTimeout(pending.timer);
297
457
  if (pending.kind !== "asset" || pending.identifier !== data.asset_id || !isImageReadyMessage(data)) {
298
458
  pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin image response ${data.id} is invalid`));
299
459
  return;
@@ -304,11 +464,9 @@ export class PluginBridgeClient {
304
464
  if (!messageWithinLimit(data))
305
465
  return;
306
466
  if (isBridgeResponseCandidate(data)) {
307
- const pending = this.#pending.get(data.id);
467
+ const pending = this.#takePending(data.id);
308
468
  if (!pending)
309
469
  return;
310
- this.#pending.delete(data.id);
311
- clearTimeout(pending.timer);
312
470
  if (!isBridgeResponse(data)) {
313
471
  pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin bridge response ${data.id} is invalid`));
314
472
  return;
@@ -319,7 +477,7 @@ export class PluginBridgeClient {
319
477
  else if (data.ok)
320
478
  pending.resolve(data.data);
321
479
  else
322
- pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details));
480
+ pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details, data.mutation_outcome));
323
481
  return;
324
482
  }
325
483
  if (isLifecycleMessage(data)) {
@@ -327,6 +485,9 @@ export class PluginBridgeClient {
327
485
  this.#ready = true;
328
486
  this.#resolveReady();
329
487
  }
488
+ if (data.quiesce_id)
489
+ this.#lifecycleState = "quiescing";
490
+ this.#handlingLifecycle = true;
330
491
  await Promise.allSettled(Array.from(this.#lifecycleHandlers, async (handler) => {
331
492
  try {
332
493
  await handler(data.event);
@@ -335,19 +496,23 @@ export class PluginBridgeClient {
335
496
  // A plugin lifecycle observer cannot block bounded surface teardown.
336
497
  }
337
498
  }));
499
+ this.#handlingLifecycle = false;
338
500
  if (data.quiesce_id) {
339
501
  this.#port.postMessage({
340
502
  type: "redevplugin.bridge.lifecycle_ack",
341
503
  quiesce_id: data.quiesce_id,
342
504
  });
505
+ this.#lifecycleState = "quiesced";
343
506
  }
344
- if (data.event.type === "dispose")
507
+ if (data.event.type === "dispose" && !data.quiesce_id)
345
508
  this.dispose();
346
509
  return;
347
510
  }
348
511
  if (isActionMessage(data)) {
512
+ this.#controlEditRevisions.set(data.target_key, data.edit_revision);
513
+ const action = publicActionEvent(data);
349
514
  for (const handler of this.#actionHandlers.get(data.action) ?? [])
350
- handler(data);
515
+ handler(action);
351
516
  return;
352
517
  }
353
518
  const canvasInput = publicCanvasInputMessage(data);
@@ -360,10 +525,14 @@ export class PluginBridgeClient {
360
525
  return `${prefix}_${this.#nextID++}`;
361
526
  }
362
527
  #assertActive() {
363
- if (this.#disposed) {
528
+ if (!this.#acceptsPluginWork()) {
364
529
  throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
365
530
  }
366
531
  }
532
+ #acceptsPluginWork() {
533
+ return this.#lifecycleState === "active" ||
534
+ (this.#lifecycleState === "quiescing" && this.#handlingLifecycle);
535
+ }
367
536
  }
368
537
  export const defaultPluginSurfaceReloadMax = 2;
369
538
  export const defaultPluginSurfaceReloadWindowMs = 30_000;
@@ -449,14 +618,14 @@ export async function trustedParentBridgeHandshakeTranscriptSHA256(handshake, br
449
618
  }
450
619
  const encoder = new TextEncoder();
451
620
  const fields = [
452
- "redevplugin.bridge.handshake.v2",
621
+ "redevplugin.bridge.handshake.v3",
453
622
  handshake.plugin_id,
454
623
  handshake.surface_id,
455
624
  handshake.surface_instance_id,
456
625
  handshake.active_fingerprint,
457
626
  handshake.bridge_nonce,
458
627
  handshake.asset_session_nonce,
459
- String(handshake.plugin_state_version),
628
+ String(handshake.management_revision),
460
629
  String(handshake.revoke_epoch),
461
630
  handshake.ui_protocol_version,
462
631
  bridgeChannelID,
@@ -488,6 +657,53 @@ export function createReDevPluginSurfaceTransport(options = {}) {
488
657
  });
489
658
  return transport;
490
659
  }
660
+ function requireSurfaceTransportInternals(transport) {
661
+ const internals = surfaceTransportInternals.get(transport);
662
+ if (!internals) {
663
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
664
+ }
665
+ return internals;
666
+ }
667
+ async function revokeSurfaceBootstrap(transport, bootstrap, timeoutMs, keepalive) {
668
+ const path = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(bootstrap.surfaceInstanceId)}/dispose`;
669
+ const controller = new AbortController();
670
+ let timedOut = false;
671
+ let timer;
672
+ const timeout = new Promise((_resolve, reject) => {
673
+ timer = setTimeout(() => {
674
+ timedOut = true;
675
+ controller.abort();
676
+ reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, "unknown"));
677
+ }, timeoutMs);
678
+ });
679
+ try {
680
+ const response = await Promise.race([
681
+ transport.fetch(transport.apiBaseURL + path, {
682
+ method: "POST",
683
+ headers: { "Accept": "application/json", "Content-Type": "application/json" },
684
+ body: JSON.stringify({ bridge_nonce: bootstrap.bridgeNonce }),
685
+ credentials: "same-origin",
686
+ signal: controller.signal,
687
+ keepalive,
688
+ }),
689
+ timeout,
690
+ ]);
691
+ await readMutationPlatformResponse(response);
692
+ }
693
+ catch (error) {
694
+ if (timedOut) {
695
+ throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, "unknown");
696
+ }
697
+ if (error instanceof PluginBridgeError || error instanceof PluginPlatformRequestError || error instanceof PluginTransportError) {
698
+ throw error;
699
+ }
700
+ throw new PluginTransportError(`Plugin surface request failed for POST ${path}`, error, "unknown");
701
+ }
702
+ finally {
703
+ if (timer)
704
+ clearTimeout(timer);
705
+ }
706
+ }
491
707
  export function createOpaquePluginBootstrapHTML(options = {}) {
492
708
  const scriptNonce = options.scriptNonce ?? randomOpaqueNonce();
493
709
  if (!/^[A-Za-z0-9_-]{8,128}$/.test(scriptNonce)) {
@@ -520,6 +736,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
520
736
  const maxRendersPerSecond = ${opaqueSurfaceRenderLimits.max_renders_per_second};
521
737
  const maxRenderDepth = ${opaqueSurfaceRenderLimits.max_render_depth};
522
738
  const maxRenderNodes = ${opaqueSurfaceRenderLimits.max_render_nodes};
739
+ const maxPatchOperations = ${opaqueSurfaceRenderLimits.max_patch_operations};
523
740
  const maxAttributesPerElement = ${opaqueSurfaceRenderLimits.max_attributes_per_element};
524
741
  const maxTextLength = ${opaqueSurfaceRenderLimits.max_text_length};
525
742
  const maxAttributeValueLength = ${opaqueSurfaceRenderLimits.max_attribute_value_length};
@@ -553,7 +770,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
553
770
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
554
771
  const canonicalJSON = (value, depth = 0, state = { nodes: 0 }, seen = new Set()) => {
555
772
  state.nodes += 1;
556
- if (state.nodes > 4096 || depth > 64) return false;
773
+ if (state.nodes > ${maxPluginJSONStructuralNodes} || depth > 64) return false;
557
774
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
558
775
  if (typeof value === "number") return Number.isFinite(value);
559
776
  if (typeof value !== "object" || seen.has(value)) return false;
@@ -581,6 +798,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
581
798
  const validIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
582
799
  const validResourceIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
583
800
  const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
801
+ const validDeliveryID = (value) => typeof value === "string" && /^delivery_[A-Za-z0-9_-]{8,128}$/.test(value);
584
802
  const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
585
803
  const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
586
804
  const validAttribute = (tag, name, value) => {
@@ -635,9 +853,13 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
635
853
  let workerHeartbeatTimeout;
636
854
  let pendingQuiesceID;
637
855
  const pendingWorkerRequests = new Set();
638
- const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
856
+ const requestSequence = { rpc: 0, stream: 0, stream_ack: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
639
857
  let renderWindowStartedAt = 0;
640
858
  let renderCount = 0;
859
+ let uiRevision = 0;
860
+ let uiGraph = new Map();
861
+ let uiNodes = new Map();
862
+ const controlEdits = new Map();
641
863
  let lastAutofocusIdentity = "";
642
864
  const pendingAssets = new Map();
643
865
  const queuedAssets = [];
@@ -712,6 +934,27 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
712
934
  verifiedAssetBytes.clear();
713
935
  assetByLogicalID.clear();
714
936
  };
937
+ const terminateQuiescedWorker = () => {
938
+ if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
939
+ if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
940
+ workerHeartbeatTimer = undefined;
941
+ workerHeartbeatTimeout = undefined;
942
+ workerHeartbeatPendingID = undefined;
943
+ workerReady = false;
944
+ if (workerControlPort) {
945
+ workerControlPort.close();
946
+ workerControlPort = undefined;
947
+ }
948
+ if (workerPort) {
949
+ workerPort.close();
950
+ workerPort = undefined;
951
+ }
952
+ if (worker) {
953
+ worker.terminate();
954
+ worker = undefined;
955
+ }
956
+ pendingWorkerRequests.clear();
957
+ };
715
958
  const validateCanvasIdentifiers = (root) => {
716
959
  const identifiers = new Set();
717
960
  let totalPixels = 0;
@@ -749,39 +992,96 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
749
992
  document.head.append(style);
750
993
  }
751
994
  };
752
- const buildWorkerNode = (value, state, depth) => {
995
+ const applyWorkerAttribute = (element, tag, name, attributeValue) => {
996
+ const lower = name.toLowerCase();
997
+ if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
998
+ if (lower === "value" && "value" in element) element.value = String(attributeValue);
999
+ if (lower === "checked" && "checked" in element) element.checked = attributeValue === true;
1000
+ if (lower === "autofocus") {
1001
+ if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
1002
+ else element.removeAttribute("data-redevplugin-renderer-autofocus");
1003
+ return;
1004
+ }
1005
+ if (typeof attributeValue === "boolean") {
1006
+ const serialized = lower.startsWith("aria-") ? String(attributeValue) : "";
1007
+ if (attributeValue || serialized) element.setAttribute(name, serialized);
1008
+ else element.removeAttribute(name);
1009
+ return;
1010
+ }
1011
+ element.setAttribute(name, String(attributeValue));
1012
+ };
1013
+ const buildWorkerSubtree = (value, state, depth, parentKey, graph = new Map(), nodes = new Map()) => {
753
1014
  state.nodes += 1;
754
- if (state.nodes > maxRenderNodes || depth > maxRenderDepth) throw new Error("plugin render tree exceeds limits");
755
- if (typeof value === "string") {
756
- if (value.length > maxTextLength) throw new Error("plugin render text exceeds limits");
757
- return document.createTextNode(value);
1015
+ if (state.nodes > maxRenderNodes || depth > maxRenderDepth || !isRecord(value) || typeof value.key !== "string" ||
1016
+ !validResourceIdentifier(value.key) || graph.has(value.key)) {
1017
+ throw new Error("plugin render tree exceeds limits or contains an invalid key");
1018
+ }
1019
+ if (value.type === "text") {
1020
+ if (!exactKeys(value, ["type", "key", "text"]) || typeof value.text !== "string" || value.text.length > maxTextLength) {
1021
+ throw new Error("plugin render text node is invalid");
1022
+ }
1023
+ const node = document.createTextNode(value.text);
1024
+ graph.set(value.key, {
1025
+ key: value.key,
1026
+ type: "text",
1027
+ text: value.text,
1028
+ parentKey,
1029
+ prevKey: null,
1030
+ nextKey: null,
1031
+ firstChildKey: null,
1032
+ lastChildKey: null,
1033
+ height: 1,
1034
+ heightDirty: false,
1035
+ });
1036
+ nodes.set(value.key, node);
1037
+ return { node, graph, nodes };
1038
+ }
1039
+ if (!Object.keys(value).every((key) => ["type", "key", "tag", "attributes", "children"].includes(key)) ||
1040
+ value.type !== "element" || typeof value.tag !== "string") {
1041
+ throw new Error("plugin render node is invalid");
758
1042
  }
759
- if (!exactKeys(value, Object.prototype.hasOwnProperty.call(value, "attributes") && Object.prototype.hasOwnProperty.call(value, "children") ? ["type", "tag", "attributes", "children"] : Object.prototype.hasOwnProperty.call(value, "attributes") ? ["type", "tag", "attributes"] : Object.prototype.hasOwnProperty.call(value, "children") ? ["type", "tag", "children"] : ["type", "tag"]) || value.type !== "element" || typeof value.tag !== "string") throw new Error("plugin render node is invalid");
760
1043
  const tag = value.tag.toLowerCase();
761
1044
  if (!allowedTags.has(tag)) throw new Error("plugin render tag is not allowed");
762
- const element = document.createElement(tag);
1045
+ const attributes = {};
763
1046
  if (value.attributes !== undefined) {
764
1047
  if (!isRecord(value.attributes) || Object.keys(value.attributes).length > maxAttributesPerElement) throw new Error("plugin render attributes are invalid");
765
1048
  for (const [name, attributeValue] of Object.entries(value.attributes)) {
766
- const lower = name.toLowerCase();
767
1049
  if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
768
- if (lower === "autofocus") {
769
- if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
770
- continue;
771
- }
772
- if (typeof attributeValue === "boolean") {
773
- const serialized = lower.startsWith("aria-") ? String(attributeValue) : "";
774
- if (attributeValue || serialized) element.setAttribute(name, serialized);
775
- } else {
776
- element.setAttribute(name, String(attributeValue));
777
- }
1050
+ attributes[name] = attributeValue;
778
1051
  }
779
1052
  }
780
- if (value.children !== undefined) {
781
- if (!Array.isArray(value.children)) throw new Error("plugin render children are invalid");
782
- for (const child of value.children) element.append(buildWorkerNode(child, state, depth + 1));
783
- }
784
- return element;
1053
+ if (value.children !== undefined && !Array.isArray(value.children)) throw new Error("plugin render children are invalid");
1054
+ const element = document.createElement(tag);
1055
+ element.setAttribute("data-redevplugin-key", value.key);
1056
+ for (const [name, attributeValue] of Object.entries(attributes)) applyWorkerAttribute(element, tag, name, attributeValue);
1057
+ const record = {
1058
+ key: value.key,
1059
+ type: "element",
1060
+ tag,
1061
+ attributes,
1062
+ parentKey,
1063
+ prevKey: null,
1064
+ nextKey: null,
1065
+ firstChildKey: null,
1066
+ lastChildKey: null,
1067
+ height: 1,
1068
+ heightDirty: false,
1069
+ };
1070
+ graph.set(value.key, record);
1071
+ nodes.set(value.key, element);
1072
+ let previousKey = null;
1073
+ for (const child of value.children || []) {
1074
+ const built = buildWorkerSubtree(child, state, depth + 1, value.key, graph, nodes);
1075
+ const childRecord = graph.get(child.key);
1076
+ childRecord.prevKey = previousKey;
1077
+ if (previousKey) graph.get(previousKey).nextKey = child.key;
1078
+ else record.firstChildKey = child.key;
1079
+ record.lastChildKey = child.key;
1080
+ record.height = Math.max(record.height, childRecord.height + 1);
1081
+ previousKey = child.key;
1082
+ element.append(built.node);
1083
+ }
1084
+ return { node: element, graph, nodes };
785
1085
  };
786
1086
  const renderElementIdentity = (element) => ({
787
1087
  tag: element.tagName,
@@ -845,22 +1145,328 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
845
1145
  document.body.scrollTop = snapshot.bodyScrollTop;
846
1146
  document.body.scrollLeft = snapshot.bodyScrollLeft;
847
1147
  };
848
- const applyWorkerRender = (tree) => {
849
- if (canvasRuntimes.size > 0) throw new Error("plugin render cannot replace an active transferred canvas");
850
- const values = Array.isArray(tree) ? tree : [tree];
851
- const fragment = document.createDocumentFragment();
852
- const state = { nodes: 0 };
853
- for (const value of values) fragment.append(buildWorkerNode(value, state, 1));
854
- validateCanvasIdentifiers(fragment);
1148
+ const transferredCanvasKey = (key) => {
1149
+ const element = uiNodes.get(key);
1150
+ if (!(element instanceof HTMLCanvasElement)) return false;
1151
+ for (const runtime of canvasRuntimes.values()) if (runtime.canvas === element) return true;
1152
+ return false;
1153
+ };
1154
+ const editableControlAttribute = (tag, name) => {
1155
+ const normalized = name.toLowerCase();
1156
+ return (normalized === "value" && ["input", "textarea", "select", "option"].includes(tag)) ||
1157
+ (normalized === "checked" && tag === "input");
1158
+ };
1159
+ const createGraphOverlay = (base) => {
1160
+ const changed = new Map();
1161
+ const deleted = new Set();
1162
+ let count = base.size;
1163
+ const get = (key) => changed.has(key) ? changed.get(key) : deleted.has(key) ? undefined : base.get(key);
1164
+ const edit = (key) => {
1165
+ const current = get(key);
1166
+ if (!current) throw new Error("plugin UI patch target does not exist");
1167
+ if (changed.has(key)) return current;
1168
+ const copy = { ...current, ...(current.attributes ? { attributes: { ...current.attributes } } : {}) };
1169
+ changed.set(key, copy);
1170
+ return copy;
1171
+ };
1172
+ const add = (record) => {
1173
+ if (get(record.key)) throw new Error("plugin UI insertion duplicates a key");
1174
+ changed.set(record.key, { ...record, ...(record.attributes ? { attributes: { ...record.attributes } } : {}) });
1175
+ count += 1;
1176
+ };
1177
+ const remove = (key) => {
1178
+ if (!get(key)) throw new Error("plugin UI removal target does not exist");
1179
+ changed.delete(key);
1180
+ if (base.has(key)) deleted.add(key);
1181
+ count -= 1;
1182
+ };
1183
+ const commit = () => {
1184
+ for (const key of deleted) base.delete(key);
1185
+ for (const [key, record] of changed) base.set(key, record);
1186
+ };
1187
+ return { get, edit, add, remove, commit, count: () => count };
1188
+ };
1189
+ const graphDepth = (graph, key) => {
1190
+ let depth = 0;
1191
+ let cursor = graph.get(key);
1192
+ const seen = new Set();
1193
+ while (cursor) {
1194
+ if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
1195
+ seen.add(cursor.key);
1196
+ depth += 1;
1197
+ cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
1198
+ }
1199
+ return depth;
1200
+ };
1201
+ const graphSubtreeKeys = (graph, rootKey) => {
1202
+ const keys = [];
1203
+ const stack = [rootKey];
1204
+ while (stack.length > 0) {
1205
+ const key = stack.pop();
1206
+ const record = key && graph.get(key);
1207
+ if (!record) throw new Error("plugin UI graph is inconsistent");
1208
+ keys.push(key);
1209
+ const children = [];
1210
+ let childKey = record.firstChildKey;
1211
+ while (childKey) {
1212
+ const child = graph.get(childKey);
1213
+ if (!child || child.parentKey !== key) throw new Error("plugin UI child graph is inconsistent");
1214
+ children.push(childKey);
1215
+ childKey = child.nextKey;
1216
+ }
1217
+ stack.push(...children.reverse());
1218
+ }
1219
+ return keys;
1220
+ };
1221
+ const graphSubtreeHeight = (graph, rootKey) => {
1222
+ const record = graph.get(rootKey);
1223
+ if (!record) throw new Error("plugin UI graph is inconsistent");
1224
+ if (!record.heightDirty) return record.height;
1225
+ let height = 1;
1226
+ let childKey = record.firstChildKey;
1227
+ while (childKey) {
1228
+ const child = graph.get(childKey);
1229
+ if (!child || child.parentKey !== rootKey) throw new Error("plugin UI child graph is inconsistent");
1230
+ height = Math.max(height, graphSubtreeHeight(graph, childKey) + 1);
1231
+ childKey = child.nextKey;
1232
+ }
1233
+ const edited = graph.edit(rootKey);
1234
+ edited.height = height;
1235
+ edited.heightDirty = false;
1236
+ return height;
1237
+ };
1238
+ const graphParentChainContains = (graph, rootKey, targetKey) => {
1239
+ let cursor = graph.get(rootKey);
1240
+ const seen = new Set();
1241
+ while (cursor) {
1242
+ if (cursor.key === targetKey) return true;
1243
+ if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
1244
+ seen.add(cursor.key);
1245
+ cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
1246
+ }
1247
+ return false;
1248
+ };
1249
+ const graphSubtreeHasTransferredCanvas = (graph, rootKey) => {
1250
+ for (const runtime of canvasRuntimes.values()) {
1251
+ const key = runtime.canvas.getAttribute("data-redevplugin-key");
1252
+ if (!key) continue;
1253
+ let cursor = graph.get(key);
1254
+ const seen = new Set();
1255
+ while (cursor) {
1256
+ if (cursor.key === rootKey) return true;
1257
+ if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
1258
+ seen.add(cursor.key);
1259
+ cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
1260
+ }
1261
+ }
1262
+ return false;
1263
+ };
1264
+ const markGraphHeightDirty = (graph, key) => {
1265
+ let cursorKey = key;
1266
+ const seen = new Set();
1267
+ while (cursorKey) {
1268
+ if (seen.has(cursorKey)) throw new Error("plugin UI graph contains a cycle");
1269
+ seen.add(cursorKey);
1270
+ const record = graph.edit(cursorKey);
1271
+ record.heightDirty = true;
1272
+ cursorKey = record.parentKey;
1273
+ }
1274
+ };
1275
+ const detachGraphNode = (graph, key) => {
1276
+ const record = graph.edit(key);
1277
+ if (!record.parentKey) throw new Error("plugin UI root cannot be detached");
1278
+ const parentKey = record.parentKey;
1279
+ const parent = graph.edit(parentKey);
1280
+ if (record.prevKey) graph.edit(record.prevKey).nextKey = record.nextKey;
1281
+ else parent.firstChildKey = record.nextKey;
1282
+ if (record.nextKey) graph.edit(record.nextKey).prevKey = record.prevKey;
1283
+ else parent.lastChildKey = record.prevKey;
1284
+ record.parentKey = undefined;
1285
+ record.prevKey = null;
1286
+ record.nextKey = null;
1287
+ markGraphHeightDirty(graph, parentKey);
1288
+ };
1289
+ const attachGraphNode = (graph, key, parentKey, beforeKey) => {
1290
+ const record = graph.edit(key);
1291
+ const parent = graph.edit(parentKey);
1292
+ if (parent.type !== "element" || record.parentKey) throw new Error("plugin UI insertion parent is invalid");
1293
+ let previousKey = parent.lastChildKey;
1294
+ if (beforeKey !== null) {
1295
+ if (beforeKey === key) throw new Error("plugin UI anchor cannot target the moved node");
1296
+ const before = graph.get(beforeKey);
1297
+ if (!before || before.parentKey !== parentKey) throw new Error("plugin UI anchor is invalid");
1298
+ previousKey = before.prevKey;
1299
+ graph.edit(beforeKey).prevKey = key;
1300
+ } else {
1301
+ parent.lastChildKey = key;
1302
+ }
1303
+ if (previousKey) graph.edit(previousKey).nextKey = key;
1304
+ else parent.firstChildKey = key;
1305
+ record.parentKey = parentKey;
1306
+ record.prevKey = previousKey;
1307
+ record.nextKey = beforeKey;
1308
+ if (beforeKey === null) parent.lastChildKey = key;
1309
+ markGraphHeightDirty(graph, parentKey);
1310
+ };
1311
+ const validatePatch = (operations) => {
1312
+ if (!Array.isArray(operations) || operations.length > maxPatchOperations) throw new Error("plugin UI patch exceeds limits");
1313
+ const graph = createGraphOverlay(uiGraph);
1314
+ const plan = [];
1315
+ for (const operation of operations) {
1316
+ if (!isRecord(operation) || typeof operation.type !== "string") throw new Error("plugin UI patch operation is invalid");
1317
+ if (operation.type === "set_text") {
1318
+ if (!exactKeys(operation, ["type", "target_key", "text"]) || !validResourceIdentifier(operation.target_key) ||
1319
+ typeof operation.text !== "string" || operation.text.length > maxTextLength) throw new Error("plugin UI text patch is invalid");
1320
+ const target = graph.edit(operation.target_key);
1321
+ if (target.type !== "text") throw new Error("plugin UI text patch target is invalid");
1322
+ target.text = operation.text;
1323
+ plan.push(operation);
1324
+ } else if (operation.type === "patch_attributes") {
1325
+ if (!exactKeys(operation, ["type", "target_key", "set", "remove"]) || !validResourceIdentifier(operation.target_key) ||
1326
+ !isRecord(operation.set) || Object.keys(operation.set).length > maxAttributesPerElement || !Array.isArray(operation.remove) ||
1327
+ operation.remove.length > maxAttributesPerElement || new Set(operation.remove).size !== operation.remove.length ||
1328
+ operation.remove.some((name) => typeof name !== "string")) throw new Error("plugin UI attribute patch is invalid");
1329
+ const target = graph.edit(operation.target_key);
1330
+ if (target.type !== "element" || transferredCanvasKey(target.key) || Object.keys(operation.set).some((name) => editableControlAttribute(target.tag, name)) ||
1331
+ operation.remove.some((name) => editableControlAttribute(target.tag, name))) throw new Error("plugin UI attribute patch target is invalid");
1332
+ target.attributes ||= {};
1333
+ for (const [name, value] of Object.entries(operation.set)) {
1334
+ if (!validAttribute(target.tag, name, value)) throw new Error("plugin UI attribute patch is not allowed");
1335
+ target.attributes[name] = value;
1336
+ }
1337
+ for (const name of operation.remove) delete target.attributes[name];
1338
+ plan.push(operation);
1339
+ } else if (operation.type === "patch_control") {
1340
+ if (!Object.keys(operation).every((key) => ["type", "target_key", "edit_revision", "value", "checked"].includes(key)) ||
1341
+ !validResourceIdentifier(operation.target_key) || !Number.isSafeInteger(operation.edit_revision) || operation.edit_revision < 0 ||
1342
+ (operation.value === undefined && operation.checked === undefined) ||
1343
+ (operation.value !== undefined && operation.value !== null && typeof operation.value !== "string") ||
1344
+ (operation.checked !== undefined && operation.checked !== null && typeof operation.checked !== "boolean")) throw new Error("plugin UI control patch is invalid");
1345
+ const target = graph.edit(operation.target_key);
1346
+ if (target.type !== "element" || !["input", "textarea", "select", "option"].includes(target.tag)) throw new Error("plugin UI control patch target is invalid");
1347
+ target.attributes ||= {};
1348
+ if (operation.value !== undefined) operation.value === null ? delete target.attributes.value : target.attributes.value = operation.value;
1349
+ if (operation.checked !== undefined) operation.checked === null ? delete target.attributes.checked : target.attributes.checked = operation.checked;
1350
+ plan.push(operation);
1351
+ } else if (operation.type === "insert_child") {
1352
+ if (!exactKeys(operation, ["type", "parent_key", "before_key", "node"]) || !validResourceIdentifier(operation.parent_key) ||
1353
+ (operation.before_key !== null && !validResourceIdentifier(operation.before_key))) throw new Error("plugin UI insertion is invalid");
1354
+ const parent = graph.get(operation.parent_key);
1355
+ if (!parent || parent.type !== "element") throw new Error("plugin UI insertion target is invalid");
1356
+ const state = { nodes: 0 };
1357
+ const built = buildWorkerSubtree(operation.node, state, graphDepth(graph, operation.parent_key) + 1, undefined);
1358
+ if (graph.count() + state.nodes > maxRenderNodes) throw new Error("plugin UI insertion exceeds node limits");
1359
+ for (const record of built.graph.values()) graph.add(record);
1360
+ attachGraphNode(graph, operation.node.key, operation.parent_key, operation.before_key);
1361
+ plan.push({ ...operation, built });
1362
+ } else if (operation.type === "remove_child") {
1363
+ if (!exactKeys(operation, ["type", "target_key"]) || !validResourceIdentifier(operation.target_key)) throw new Error("plugin UI removal is invalid");
1364
+ const target = graph.get(operation.target_key);
1365
+ if (!target || !target.parentKey) throw new Error("plugin UI removal target is invalid");
1366
+ if (graphSubtreeHasTransferredCanvas(graph, operation.target_key)) throw new Error("transferred canvas cannot be removed");
1367
+ const removedKeys = graphSubtreeKeys(graph, operation.target_key);
1368
+ detachGraphNode(graph, operation.target_key);
1369
+ for (const key of removedKeys) graph.remove(key);
1370
+ plan.push({ ...operation, removedKeys });
1371
+ } else if (operation.type === "move_child") {
1372
+ if (!exactKeys(operation, ["type", "target_key", "parent_key", "before_key"]) || !validResourceIdentifier(operation.target_key) ||
1373
+ !validResourceIdentifier(operation.parent_key) || (operation.before_key !== null && !validResourceIdentifier(operation.before_key))) {
1374
+ throw new Error("plugin UI move is invalid");
1375
+ }
1376
+ const target = graph.get(operation.target_key);
1377
+ const parent = graph.get(operation.parent_key);
1378
+ if (!target || !target.parentKey || !parent || parent.type !== "element" ||
1379
+ graphParentChainContains(graph, operation.parent_key, operation.target_key) ||
1380
+ graphSubtreeHasTransferredCanvas(graph, operation.target_key)) throw new Error("plugin UI move target is invalid");
1381
+ if (graphDepth(graph, operation.parent_key) + graphSubtreeHeight(graph, operation.target_key) > maxRenderDepth) {
1382
+ throw new Error("plugin UI move exceeds depth limits");
1383
+ }
1384
+ detachGraphNode(graph, operation.target_key);
1385
+ attachGraphNode(graph, operation.target_key, operation.parent_key, operation.before_key);
1386
+ plan.push(operation);
1387
+ } else {
1388
+ throw new Error("plugin UI patch operation type is unsupported");
1389
+ }
1390
+ }
1391
+ return { graph, plan };
1392
+ };
1393
+ const applyPatchOperation = (operation) => {
1394
+ const parent = operation.parent_key && uiNodes.get(operation.parent_key);
1395
+ const target = operation.target_key && uiNodes.get(operation.target_key);
1396
+ if (operation.type === "set_text") {
1397
+ target.nodeValue = operation.text;
1398
+ } else if (operation.type === "patch_attributes") {
1399
+ for (const name of operation.remove) {
1400
+ target.removeAttribute(name);
1401
+ if (name.toLowerCase() === "autofocus") target.removeAttribute("data-redevplugin-renderer-autofocus");
1402
+ }
1403
+ for (const [name, value] of Object.entries(operation.set)) applyWorkerAttribute(target, target.tagName.toLowerCase(), name, value);
1404
+ } else if (operation.type === "patch_control") {
1405
+ const edit = controlEdits.get(operation.target_key) || { revision: 0, isComposing: false };
1406
+ if (edit.revision !== operation.edit_revision || edit.isComposing) return;
1407
+ if (operation.value !== undefined && "value" in target) target.value = operation.value === null ? "" : operation.value;
1408
+ if (operation.checked !== undefined && "checked" in target) target.checked = operation.checked === true;
1409
+ } else if (operation.type === "insert_child") {
1410
+ const before = operation.before_key === null ? null : uiNodes.get(operation.before_key);
1411
+ parent.insertBefore(operation.built.node, before || null);
1412
+ for (const [key, node] of operation.built.nodes) uiNodes.set(key, node);
1413
+ } else if (operation.type === "remove_child") {
1414
+ for (const key of operation.removedKeys) uiNodes.delete(key);
1415
+ target.remove();
1416
+ } else if (operation.type === "move_child") {
1417
+ const before = operation.before_key === null ? null : uiNodes.get(operation.before_key);
1418
+ parent.insertBefore(target, before || null);
1419
+ }
1420
+ };
1421
+ const onAnimationFrame = (commit) => new Promise((resolve, reject) => requestAnimationFrame(() => {
1422
+ try { commit(); resolve(); } catch (error) { reject(error); }
1423
+ }));
1424
+ const mountWorkerTree = async (tree) => {
1425
+ if (uiRevision !== 0) throw new Error("plugin UI mount may only occur once");
1426
+ const built = buildWorkerSubtree(tree, { nodes: 0 }, 1, undefined);
1427
+ const root = built.node;
1428
+ if (!(root instanceof Element)) throw new Error("plugin UI root must be an element");
1429
+ validateCanvasIdentifiers(root);
1430
+ const renderState = captureRenderState();
1431
+ await onAnimationFrame(() => {
1432
+ document.body.replaceChildren(root);
1433
+ restoreRenderState(renderState);
1434
+ });
1435
+ uiGraph = built.graph;
1436
+ uiNodes = built.nodes;
1437
+ uiRevision = 1;
1438
+ sendParent({ type: "redevplugin.surface.first_commit" });
1439
+ };
1440
+ const patchWorkerTree = async (baseRevision, revision, operations) => {
1441
+ if (uiRevision < 1 || baseRevision !== uiRevision || revision !== baseRevision + 1) throw new Error("plugin UI patch revision is invalid");
1442
+ const validated = validatePatch(operations);
855
1443
  const renderState = captureRenderState();
856
- document.body.replaceChildren(fragment);
857
- restoreRenderState(renderState);
1444
+ await onAnimationFrame(() => {
1445
+ for (const operation of validated.plan) applyPatchOperation(operation);
1446
+ validated.graph.commit();
1447
+ restoreRenderState(renderState);
1448
+ });
1449
+ uiRevision = revision;
858
1450
  };
859
1451
  const actionPayload = (event, element, eventType = event.type) => {
860
- const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event: eventType };
861
- const target = event.target;
862
- if (target && typeof target.value === "string") payload.value = target.value.slice(0, maxTextLength);
863
- if (target && typeof target.checked === "boolean") payload.checked = target.checked;
1452
+ const target = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : element;
1453
+ const targetKey = target?.getAttribute("data-redevplugin-key") || element.getAttribute("data-redevplugin-key");
1454
+ if (!validResourceIdentifier(targetKey)) throw new Error("plugin UI action target is invalid");
1455
+ const edit = controlEdits.get(targetKey) || { revision: 0, isComposing: false };
1456
+ if (eventType === "input" || eventType === "change") edit.revision += 1;
1457
+ edit.isComposing = Boolean(event.isComposing || edit.isComposing);
1458
+ controlEdits.set(targetKey, edit);
1459
+ const payload = {
1460
+ type: "redevplugin.ui.action",
1461
+ action: element.getAttribute("data-redevplugin-action"),
1462
+ event: eventType,
1463
+ target_key: targetKey,
1464
+ edit_revision: edit.revision,
1465
+ is_composing: edit.isComposing,
1466
+ };
1467
+ const eventTarget = event.target;
1468
+ if (eventTarget && typeof eventTarget.value === "string") payload.value = eventTarget.value.slice(0, maxTextLength);
1469
+ if (eventTarget && typeof eventTarget.checked === "boolean") payload.checked = eventTarget.checked;
864
1470
  if (eventType === "submit" && element.tagName === "FORM") {
865
1471
  const values = {};
866
1472
  let count = 0;
@@ -873,7 +1479,24 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
873
1479
  }
874
1480
  return payload;
875
1481
  };
1482
+ document.addEventListener("compositionstart", (event) => {
1483
+ const element = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : null;
1484
+ const key = element?.getAttribute("data-redevplugin-key");
1485
+ if (!validResourceIdentifier(key)) return;
1486
+ const edit = controlEdits.get(key) || { revision: 0, isComposing: false };
1487
+ edit.isComposing = true;
1488
+ controlEdits.set(key, edit);
1489
+ }, true);
1490
+ document.addEventListener("compositionend", (event) => {
1491
+ const element = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : null;
1492
+ const key = element?.getAttribute("data-redevplugin-key");
1493
+ if (!validResourceIdentifier(key)) return;
1494
+ const edit = controlEdits.get(key) || { revision: 0, isComposing: true };
1495
+ edit.isComposing = false;
1496
+ controlEdits.set(key, edit);
1497
+ }, true);
876
1498
  const handleAction = (event) => {
1499
+ if (pendingQuiesceID) return;
877
1500
  if (!["click", "input", "change", "submit"].includes(event.type)) return;
878
1501
  const origin = event.target instanceof Element ? event.target : null;
879
1502
  const element = origin ? origin.closest("[data-redevplugin-action]") : null;
@@ -925,7 +1548,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
925
1548
  if (!owner || !document.body.contains(owner) || !validIdentifier(action)) return;
926
1549
  event.preventDefault();
927
1550
  event.stopPropagation();
928
- sendWorker({ type: "redevplugin.ui.action", action, event: "escape" });
1551
+ const targetKey = owner.getAttribute("data-redevplugin-key");
1552
+ if (!validResourceIdentifier(targetKey)) return;
1553
+ const edit = controlEdits.get(targetKey) || { revision: 0, isComposing: false };
1554
+ sendWorker({
1555
+ type: "redevplugin.ui.action",
1556
+ action,
1557
+ event: "escape",
1558
+ target_key: targetKey,
1559
+ edit_revision: edit.revision,
1560
+ is_composing: edit.isComposing,
1561
+ });
929
1562
  }, true);
930
1563
  const pumpAssets = () => {
931
1564
  while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
@@ -1255,7 +1888,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1255
1888
  const validCall = (value) => exactKeys(value, ["type", "request"]) && value.type === "redevplugin.bridge.call" && isRecord(value.request) && Object.keys(value.request).every((key) => ["id", "method", "params"].includes(key)) && typeof value.request.id === "string" && value.request.id.length <= 128 && typeof value.request.method === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value.request.method) && (value.request.params === undefined || isRecord(value.request.params));
1256
1889
  const requestID = (value, expectedKind) => {
1257
1890
  if (typeof value !== "string") return undefined;
1258
- const match = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
1891
+ const match = /^(rpc|stream|stream_ack|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
1259
1892
  if (!match || match[1] !== expectedKind) return undefined;
1260
1893
  const sequence = Number(match[2]);
1261
1894
  return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
@@ -1473,7 +2106,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1473
2106
  if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
1474
2107
  pendingQuiesceID = undefined;
1475
2108
  sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
1476
- disposeRuntime();
2109
+ terminateQuiescedWorker();
1477
2110
  return;
1478
2111
  }
1479
2112
  if (validCall(message)) {
@@ -1486,6 +2119,12 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1486
2119
  sendParent(message);
1487
2120
  return;
1488
2121
  }
2122
+ if (exactKeys(message, ["type", "id", "stream_handle", "delivery_id"]) && message.type === "redevplugin.bridge.stream.ack" &&
2123
+ requestID(message.id, "stream_ack") && validOpaqueHandle(message.stream_handle, "stream") && validDeliveryID(message.delivery_id)) {
2124
+ if (!acceptWorkerRequest(message.id, "stream_ack")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
2125
+ sendParent(message);
2126
+ return;
2127
+ }
1489
2128
  if (isRecord(message) && Object.keys(message).every((key) => ["type", "id", "operation_id", "reason"].includes(key)) &&
1490
2129
  message.type === "redevplugin.bridge.operation.cancel" && typeof message.id === "string" &&
1491
2130
  validOpaqueHandle(message.operation_id, "operation") && (message.reason === undefined || (typeof message.reason === "string" && message.reason.length <= 256))) {
@@ -1513,20 +2152,30 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1513
2152
  void deliverImageAsset(message.id, message.asset_id, asset);
1514
2153
  return;
1515
2154
  }
1516
- if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.render" && typeof message.id === "string") {
2155
+ if (exactKeys(message, ["type", "id", "revision", "tree"]) && message.type === "redevplugin.ui.mount" &&
2156
+ typeof message.id === "string" && message.revision === 1) {
1517
2157
  if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
1518
2158
  if (!renderRateAllowed()) {
1519
2159
  completeWorkerRequest(message.id);
1520
2160
  return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
1521
2161
  }
1522
- try {
1523
- applyWorkerRender(message.tree);
2162
+ void mountWorkerTree(message.tree).then(() => {
1524
2163
  completeWorkerRequest(message.id);
1525
2164
  sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
1526
- } catch (error) {
2165
+ }, (error) => fail(error && error.message || "plugin UI mount violated the protocol"));
2166
+ return;
2167
+ }
2168
+ if (exactKeys(message, ["type", "id", "base_revision", "revision", "operations"]) && message.type === "redevplugin.ui.patch" &&
2169
+ typeof message.id === "string" && Number.isSafeInteger(message.base_revision) && Number.isSafeInteger(message.revision)) {
2170
+ if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
2171
+ if (!renderRateAllowed()) {
1527
2172
  completeWorkerRequest(message.id);
1528
- sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: false, error_code: "PLUGIN_CONTRACT_MISMATCH", error: String(error && error.message || error).slice(0, 512) });
2173
+ return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
1529
2174
  }
2175
+ void patchWorkerTree(message.base_revision, message.revision, message.operations).then(() => {
2176
+ completeWorkerRequest(message.id);
2177
+ sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
2178
+ }, (error) => fail(error && error.message || "plugin UI patch violated the protocol"));
1530
2179
  return;
1531
2180
  }
1532
2181
  if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
@@ -1599,9 +2248,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1599
2248
  })();`;
1600
2249
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHTMLAttribute(csp)}"><title>Plugin</title></head><body><script nonce="${scriptNonce}">${bootstrapScript}</script></body></html>`;
1601
2250
  }
1602
- const pluginSurfaceHostConstructorToken = Symbol("redevplugin.surface-host.constructor");
1603
- export class PluginSurfaceHost {
2251
+ class PluginSurfaceHostImplementation {
1604
2252
  element;
2253
+ surfaceInstanceId;
1605
2254
  bootstrap;
1606
2255
  bridgeChannelId;
1607
2256
  frameGenerationId;
@@ -1609,6 +2258,7 @@ export class PluginSurfaceHost {
1609
2258
  #iframe;
1610
2259
  #transport;
1611
2260
  #createMessageChannel;
2261
+ #bridgeReady = false;
1612
2262
  #loadTimeoutMs;
1613
2263
  #requestTimeoutMs;
1614
2264
  #leaseRenewalLeadMs;
@@ -1657,20 +2307,10 @@ export class PluginSurfaceHost {
1657
2307
  const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", `Plugin iframe reloaded unexpectedly (attempt ${decision.attempt})`);
1658
2308
  void this.#failSurface(error);
1659
2309
  };
1660
- static create(options) {
1661
- validateHostBootstrap(options.bootstrap);
1662
- const transport = surfaceTransportInternals.get(options.hostTransport);
1663
- if (!transport) {
1664
- throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
1665
- }
1666
- return new PluginSurfaceHost(pluginSurfaceHostConstructorToken, options, transport, createPluginSurfaceFrame());
1667
- }
1668
- constructor(constructorToken, options, transport, iframe) {
1669
- if (constructorToken !== pluginSurfaceHostConstructorToken) {
1670
- throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface hosts must be created through PluginSurfaceHost.create()");
1671
- }
2310
+ constructor(options, transport, iframe) {
1672
2311
  this.element = iframe;
1673
2312
  this.bootstrap = options.bootstrap;
2313
+ this.surfaceInstanceId = options.bootstrap.surfaceInstanceId;
1674
2314
  this.bridgeChannelId = options.bridgeChannelId ?? randomOpaqueHandle("bridge");
1675
2315
  this.frameGenerationId = randomOpaqueHandle("frame");
1676
2316
  this.surfaceHandle = randomOpaqueHandle("surface");
@@ -1696,16 +2336,22 @@ export class PluginSurfaceHost {
1696
2336
  const startedAt = Date.now();
1697
2337
  const progressTimer = setTimeout(() => {
1698
2338
  if (!this.#ready && !this.#disposed) {
1699
- this.#onOpeningProgress?.({
2339
+ this.#reportOpeningProgress({
1700
2340
  phase: "opening",
1701
2341
  elapsedMs: Math.max(openingProgressDelayMs, Date.now() - startedAt),
1702
2342
  });
1703
2343
  }
1704
2344
  }, openingProgressDelayMs);
1705
- const signals = { portAcknowledged: deferred(), firstPaint: deferred(), workerReady: deferred() };
2345
+ const signals = {
2346
+ portAcknowledged: deferred(),
2347
+ firstPaint: deferred(),
2348
+ workerReady: deferred(),
2349
+ firstCommit: deferred(),
2350
+ };
1706
2351
  void signals.portAcknowledged.promise.catch(() => undefined);
1707
2352
  void signals.firstPaint.promise.catch(() => undefined);
1708
2353
  void signals.workerReady.promise.catch(() => undefined);
2354
+ void signals.firstCommit.promise.catch(() => undefined);
1709
2355
  this.#openSignals = signals;
1710
2356
  const scriptNonce = randomOpaqueNonce();
1711
2357
  this.#frameLoaded = false;
@@ -1722,7 +2368,7 @@ export class PluginSurfaceHost {
1722
2368
  if (bridgeError.errorCode === "PLUGIN_BRIDGE_TIMEOUT")
1723
2369
  this.#reloadLimiter.recordCrash();
1724
2370
  this.#reportError(bridgeError);
1725
- const revoke = this.#bestEffortRevokeSurface(false);
2371
+ const revoke = this.#revokeSurface(false);
1726
2372
  this.#disposeLocal();
1727
2373
  await revoke;
1728
2374
  throw bridgeError;
@@ -1767,18 +2413,27 @@ export class PluginSurfaceHost {
1767
2413
  });
1768
2414
  await Promise.all([signals.firstPaint.promise, signals.workerReady.promise]);
1769
2415
  this.#assertActive();
1770
- this.#ready = true;
2416
+ this.#bridgeReady = true;
1771
2417
  this.#scheduleLeaseRenewal();
1772
- this.#reloadLimiter.recordHealthyLoad();
1773
2418
  this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event: { type: "ready" } });
2419
+ await signals.firstCommit.promise;
2420
+ this.#assertActive();
2421
+ this.#ready = true;
2422
+ this.#reloadLimiter.recordHealthyLoad();
1774
2423
  }
1775
2424
  sendLifecycle(event) {
1776
2425
  this.#assertReady();
1777
2426
  this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
1778
2427
  }
1779
2428
  close() {
1780
- if (this.#disposed)
1781
- return Promise.resolve();
2429
+ if (this.#disposed) {
2430
+ const result = {
2431
+ quiesce: { outcome: "not_ready", durationMs: 0 },
2432
+ revokeDurationMs: 0,
2433
+ totalDurationMs: 0,
2434
+ };
2435
+ return this.#revokePromise ? this.#revokePromise.then(() => result) : Promise.resolve(result);
2436
+ }
1782
2437
  if (this.#closePromise)
1783
2438
  return this.#closePromise;
1784
2439
  this.#closePromise = this.#closeSurface();
@@ -1786,14 +2441,19 @@ export class PluginSurfaceHost {
1786
2441
  }
1787
2442
  dispose() {
1788
2443
  if (this.#disposed)
1789
- return;
1790
- void this.#bestEffortRevokeSurface(true);
2444
+ return this.#revokePromise ?? Promise.resolve();
2445
+ const revoke = this.#revokeSurface(true);
2446
+ void revoke.catch(() => undefined);
1791
2447
  this.#disposeLocal();
2448
+ return revoke;
1792
2449
  }
1793
2450
  async #closeSurface() {
1794
- await this.#quiesceSurface();
1795
- if (this.#disposed)
1796
- return;
2451
+ const startedAt = performance.now();
2452
+ const quiesce = await this.#quiesceSurface();
2453
+ if (this.#disposed) {
2454
+ return { quiesce, revokeDurationMs: 0, totalDurationMs: performance.now() - startedAt };
2455
+ }
2456
+ const revokeStartedAt = performance.now();
1797
2457
  const revoke = this.#revokeSurface(false);
1798
2458
  this.#disposeLocal();
1799
2459
  try {
@@ -1802,6 +2462,11 @@ export class PluginSurfaceHost {
1802
2462
  catch (error) {
1803
2463
  throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
1804
2464
  }
2465
+ return {
2466
+ quiesce,
2467
+ revokeDurationMs: performance.now() - revokeStartedAt,
2468
+ totalDurationMs: performance.now() - startedAt,
2469
+ };
1805
2470
  }
1806
2471
  #disposeLocal() {
1807
2472
  if (this.#disposed)
@@ -1810,6 +2475,7 @@ export class PluginSurfaceHost {
1810
2475
  this.#unregisterSurfaceScope?.();
1811
2476
  this.#unregisterSurfaceScope = undefined;
1812
2477
  this.#ready = false;
2478
+ this.#bridgeReady = false;
1813
2479
  if (this.#leaseRenewalTimer)
1814
2480
  clearTimeout(this.#leaseRenewalTimer);
1815
2481
  this.#leaseRenewalTimer = undefined;
@@ -1821,6 +2487,7 @@ export class PluginSurfaceHost {
1821
2487
  const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface host was disposed");
1822
2488
  this.#openSignals?.firstPaint.reject(disposedError);
1823
2489
  this.#openSignals?.workerReady.reject(disposedError);
2490
+ this.#openSignals?.firstCommit.reject(disposedError);
1824
2491
  this.#openSignals?.portAcknowledged.reject(disposedError);
1825
2492
  this.#initialFrameLoad?.reject(disposedError);
1826
2493
  this.#quiesce?.acknowledged.reject(disposedError);
@@ -1863,6 +2530,10 @@ export class PluginSurfaceHost {
1863
2530
  this.#openSignals?.workerReady.resolve();
1864
2531
  return;
1865
2532
  }
2533
+ if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.first_commit") {
2534
+ this.#openSignals?.firstCommit.resolve();
2535
+ return;
2536
+ }
1866
2537
  if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
1867
2538
  this.#quiesce.acknowledged.resolve();
1868
2539
  return;
@@ -1884,6 +2555,10 @@ export class PluginSurfaceHost {
1884
2555
  await this.#handleStreamRead(data.id, data.stream_handle);
1885
2556
  return;
1886
2557
  }
2558
+ if (isStreamAcknowledgeMessage(data)) {
2559
+ await this.#handleStreamAcknowledge(data.id, data.stream_handle, data.delivery_id);
2560
+ return;
2561
+ }
1887
2562
  if (isOperationCancelMessage(data)) {
1888
2563
  await this.#handleOperationCancel(data);
1889
2564
  return;
@@ -1897,7 +2572,7 @@ export class PluginSurfaceHost {
1897
2572
  }
1898
2573
  }
1899
2574
  async #handleCall(request) {
1900
- if (!this.#ready || !this.#gatewayToken) {
2575
+ if ((!this.#bridgeReady && !this.#quiesce) || !this.#gatewayToken) {
1901
2576
  this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
1902
2577
  return;
1903
2578
  }
@@ -1915,7 +2590,7 @@ export class PluginSurfaceHost {
1915
2590
  await this.#handleConfirmationRequired(request, bridgeError, controller.signal);
1916
2591
  return;
1917
2592
  }
1918
- this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
2593
+ this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
1919
2594
  }
1920
2595
  finally {
1921
2596
  this.#pendingRequestControllers.delete(request.id);
@@ -1923,11 +2598,11 @@ export class PluginSurfaceHost {
1923
2598
  }
1924
2599
  async #handleConfirmationRequired(request, originalError, signal) {
1925
2600
  if (!this.#confirm) {
1926
- this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details);
2601
+ this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details, originalError.mutationOutcome);
1927
2602
  return;
1928
2603
  }
1929
2604
  try {
1930
- const confirmation = await this.#prepareConfirmation(request, signal);
2605
+ const confirmation = await this.#preparePluginMethodConfirmation(request, signal);
1931
2606
  const decision = await abortableConfirmationDecision(this.#confirm({
1932
2607
  requestId: request.id,
1933
2608
  method: request.method,
@@ -1955,47 +2630,60 @@ export class PluginSurfaceHost {
1955
2630
  if (signal.aborted || this.#disposed)
1956
2631
  return;
1957
2632
  const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
1958
- this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
2633
+ this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
1959
2634
  }
1960
2635
  }
1961
2636
  async #handleStreamRead(id, streamHandle) {
1962
2637
  const credential = this.#streamCredentials.get(streamHandle);
1963
- if (!credential || credential.reading) {
1964
- this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid or already consumed");
2638
+ if (!credential || credential.completed || credential.reading || credential.acknowledging) {
2639
+ this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid, completed, or busy");
1965
2640
  return;
1966
2641
  }
1967
2642
  if (credential.expiresAtMs <= Date.now()) {
1968
2643
  this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is expired");
1969
2644
  return;
1970
2645
  }
2646
+ if (credential.pending) {
2647
+ this.#postResponse(id, credential.pending.response);
2648
+ return;
2649
+ }
1971
2650
  credential.reading = true;
1972
2651
  const controller = this.#registerPendingRequest(id);
1973
2652
  try {
1974
- const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`, () => ({ stream_id: credential.streamID, stream_ticket: credential.streamTicket }), controller.signal);
1975
- if (!isStreamReadResult(result, credential.streamID, credential.lastSequence)) {
2653
+ const readPath = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`;
2654
+ const readBody = () => ({ stream_id: credential.streamID, stream_ticket: credential.streamTicket, read_id: credential.readID });
2655
+ let result;
2656
+ try {
2657
+ result = await this.#postJSON(readPath, readBody, controller.signal);
2658
+ }
2659
+ catch (error) {
2660
+ if (!retryableStreamReadTransportFailure(error) || controller.signal.aborted || this.#disposed)
2661
+ throw error;
2662
+ result = await this.#postJSON(readPath, readBody, controller.signal);
2663
+ }
2664
+ if (!isStreamReadResult(result, credential.streamID, credential.readID, credential.lastSequence)) {
1976
2665
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream endpoint returned an invalid response");
1977
2666
  }
1978
2667
  const lastSequence = result.events.length > 0 ? result.events[result.events.length - 1].sequence : credential.lastSequence;
1979
- if (result.done) {
1980
- this.#streamCredentials.delete(streamHandle);
1981
- }
1982
- else {
1983
- const expiresAtMs = Date.parse(result.next_stream_expires_at);
1984
- credential.streamTicket = result.next_stream_ticket;
1985
- credential.expiresAtMs = expiresAtMs;
1986
- credential.lastSequence = lastSequence;
1987
- credential.reading = false;
2668
+ const events = result.events.map(publicPluginStreamEvent);
2669
+ const response = result.done
2670
+ ? { delivery_id: result.delivery_id, events, done: true, terminal_status: result.terminal_status, retry_after_ms: 0 }
2671
+ : { delivery_id: result.delivery_id, events, done: false, retry_after_ms: events.length === 0 ? 25 : 0 };
2672
+ if (result.delivery_id) {
2673
+ credential.pending = {
2674
+ deliveryID: result.delivery_id,
2675
+ lastSequence,
2676
+ done: result.done,
2677
+ response,
2678
+ };
1988
2679
  }
2680
+ credential.reading = false;
1989
2681
  if (!controller.signal.aborted && !this.#disposed)
1990
- this.#postResponse(id, {
1991
- events: result.events.map(publicPluginStreamEvent),
1992
- done: result.done,
1993
- ...(result.done ? { terminal_status: result.terminal_status } : {}),
1994
- retry_after_ms: result.events.length === 0 && !result.done ? 25 : 0,
1995
- });
2682
+ this.#postResponse(id, response);
1996
2683
  }
1997
2684
  catch (error) {
1998
- if (streamReadFailureInvalidatesCredential(error)) {
2685
+ const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
2686
+ if (streamReadFailureInvalidatesCredential(bridgeError)) {
1999
2687
  this.#streamCredentials.delete(streamHandle);
2000
2688
  }
2001
2689
  else {
@@ -2003,17 +2691,62 @@ export class PluginSurfaceHost {
2003
2691
  }
2004
2692
  if (controller.signal.aborted || this.#disposed)
2005
2693
  return;
2006
- const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
2007
2694
  this.#postError(id, bridgeError.errorCode, bridgeError.message);
2008
2695
  }
2009
2696
  finally {
2010
2697
  this.#pendingRequestControllers.delete(id);
2011
2698
  }
2012
2699
  }
2700
+ async #handleStreamAcknowledge(id, streamHandle, deliveryID) {
2701
+ const credential = this.#streamCredentials.get(streamHandle);
2702
+ if (!credential || credential.reading || credential.acknowledging) {
2703
+ this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery is invalid or busy", undefined, "not_committed");
2704
+ return;
2705
+ }
2706
+ if (credential.lastAcknowledgedDeliveryID === deliveryID) {
2707
+ this.#postResponse(id, undefined);
2708
+ return;
2709
+ }
2710
+ if (!credential.pending || credential.pending.deliveryID !== deliveryID) {
2711
+ this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery does not match the pending batch", undefined, "not_committed");
2712
+ return;
2713
+ }
2714
+ credential.acknowledging = true;
2715
+ const controller = this.#registerPendingRequest(id);
2716
+ try {
2717
+ const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/ack`, () => ({
2718
+ stream_id: credential.streamID,
2719
+ stream_ticket: credential.streamTicket,
2720
+ delivery_id: deliveryID,
2721
+ }), controller.signal);
2722
+ if (!hasExactKeys(result, ["acknowledged"]) || result.acknowledged !== true) {
2723
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream acknowledgement endpoint returned an invalid response", undefined, undefined, "unknown");
2724
+ }
2725
+ const pending = credential.pending;
2726
+ credential.lastAcknowledgedDeliveryID = deliveryID;
2727
+ credential.lastSequence = pending.lastSequence;
2728
+ credential.pending = undefined;
2729
+ credential.readID = randomOpaqueHandle("read");
2730
+ credential.completed = pending.done;
2731
+ credential.acknowledging = false;
2732
+ if (!controller.signal.aborted && !this.#disposed)
2733
+ this.#postResponse(id, undefined);
2734
+ }
2735
+ catch (error) {
2736
+ credential.acknowledging = false;
2737
+ if (controller.signal.aborted || this.#disposed)
2738
+ return;
2739
+ const bridgeError = toBridgeError(error, "PLUGIN_STREAM_DELIVERY_INVALID");
2740
+ this.#postError(id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
2741
+ }
2742
+ finally {
2743
+ this.#pendingRequestControllers.delete(id);
2744
+ }
2745
+ }
2013
2746
  async #handleOperationCancel(message) {
2014
2747
  const controller = this.#registerPendingRequest(message.id);
2015
2748
  try {
2016
- await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/operations/cancel`, { operation_id: message.operation_id, bridge_channel_id: this.bridgeChannelId, reason: message.reason }, controller.signal);
2749
+ await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/operations/cancel`, { operation_id: message.operation_id, bridge_channel_id: this.bridgeChannelId, reason: message.reason }, controller.signal);
2017
2750
  this.#releaseOperationStreams(message.operation_id);
2018
2751
  if (!controller.signal.aborted && !this.#disposed)
2019
2752
  this.#postResponse(message.id, undefined);
@@ -2022,7 +2755,7 @@ export class PluginSurfaceHost {
2022
2755
  if (controller.signal.aborted || this.#disposed)
2023
2756
  return;
2024
2757
  const bridgeError = toBridgeError(error, "PLUGIN_OPERATION_BLOCKED");
2025
- this.#postError(message.id, bridgeError.errorCode, bridgeError.message);
2758
+ this.#postError(message.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
2026
2759
  }
2027
2760
  finally {
2028
2761
  this.#pendingRequestControllers.delete(message.id);
@@ -2096,7 +2829,10 @@ export class PluginSurfaceHost {
2096
2829
  streamTicket: result.stream_ticket,
2097
2830
  expiresAtMs,
2098
2831
  lastSequence: 0,
2832
+ readID: randomOpaqueHandle("read"),
2099
2833
  reading: false,
2834
+ acknowledging: false,
2835
+ completed: false,
2100
2836
  });
2101
2837
  publicResult.stream_handle = handle;
2102
2838
  }
@@ -2115,13 +2851,13 @@ export class PluginSurfaceHost {
2115
2851
  }
2116
2852
  }
2117
2853
  #callRPC(request, confirmationID, signal) {
2118
- return this.#postJSON("/_redevplugin/api/plugins/rpc", () => this.#rpcBody(request, confirmationID), signal);
2854
+ return this.#postMutationJSON("/_redevplugin/api/plugins/rpc", () => this.#rpcBody(request, confirmationID), signal);
2119
2855
  }
2120
- #prepareConfirmation(request, signal) {
2121
- return this.#postJSON("/_redevplugin/api/plugins/confirm", () => this.#rpcBody(request), signal);
2856
+ #preparePluginMethodConfirmation(request, signal) {
2857
+ return this.#postMutationJSON("/_redevplugin/api/plugins/confirmations/prepare", () => this.#rpcBody(request), signal);
2122
2858
  }
2123
2859
  async #rejectConfirmation(confirmationID, signal) {
2124
- const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/confirmations/reject`, () => ({
2860
+ const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/confirmations/reject`, () => ({
2125
2861
  plugin_instance_id: this.bootstrap.pluginInstanceId,
2126
2862
  bridge_channel_id: this.bridgeChannelId,
2127
2863
  plugin_gateway_token: this.#gatewayToken,
@@ -2145,7 +2881,7 @@ export class PluginSurfaceHost {
2145
2881
  return body;
2146
2882
  }
2147
2883
  #prepareSurface() {
2148
- return this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/prepare`, { asset_ticket: this.bootstrap.assetTicket });
2884
+ return this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/prepare`, { asset_ticket: this.bootstrap.assetTicket });
2149
2885
  }
2150
2886
  async #mintBridgeToken(previousGatewayToken, direct = false) {
2151
2887
  const handshake = this.#handshake();
@@ -2157,7 +2893,7 @@ export class PluginSurfaceHost {
2157
2893
  handshake_transcript_sha256: transcript,
2158
2894
  ...(previousGatewayToken ? { previous_plugin_gateway_token: previousGatewayToken } : {}),
2159
2895
  };
2160
- return direct ? this.#fetchJSON(path, body) : this.#postJSON(path, body);
2896
+ return direct ? this.#fetchMutationJSON(path, body) : this.#postMutationJSON(path, body);
2161
2897
  }
2162
2898
  #handshake() {
2163
2899
  return {
@@ -2168,13 +2904,19 @@ export class PluginSurfaceHost {
2168
2904
  active_fingerprint: this.bootstrap.activeFingerprint,
2169
2905
  bridge_nonce: this.bootstrap.bridgeNonce,
2170
2906
  asset_session_nonce: this.bootstrap.assetSessionNonce,
2171
- plugin_state_version: this.bootstrap.pluginStateVersion,
2907
+ management_revision: this.bootstrap.managementRevision,
2172
2908
  revoke_epoch: this.bootstrap.revokeEpoch,
2173
2909
  ui_protocol_version: pluginUIProtocolVersion,
2174
2910
  };
2175
2911
  }
2176
2912
  async #postJSON(path, body, signal) {
2177
- if (this.#ready) {
2913
+ return this.#requestJSON(path, body, false, signal);
2914
+ }
2915
+ async #postMutationJSON(path, body, signal) {
2916
+ return this.#requestJSON(path, body, true, signal);
2917
+ }
2918
+ async #requestJSON(path, body, mutation, signal) {
2919
+ if (this.#bridgeReady) {
2178
2920
  if (Date.now() >= this.#leaseRenewAtMs)
2179
2921
  await this.#startLeaseRenewal();
2180
2922
  else if (this.#leaseRenewalPromise)
@@ -2183,7 +2925,9 @@ export class PluginSurfaceHost {
2183
2925
  const requestBody = typeof body === "function" ? body() : body;
2184
2926
  this.#activeTransportRequests += 1;
2185
2927
  try {
2186
- return await this.#fetchJSON(path, requestBody, signal);
2928
+ return mutation
2929
+ ? await this.#fetchMutationJSON(path, requestBody, signal)
2930
+ : await this.#fetchJSON(path, requestBody, signal);
2187
2931
  }
2188
2932
  finally {
2189
2933
  this.#activeTransportRequests -= 1;
@@ -2197,6 +2941,9 @@ export class PluginSurfaceHost {
2197
2941
  async #fetchJSON(path, body, signal) {
2198
2942
  return this.#fetchJSONRequest(path, body, { signal });
2199
2943
  }
2944
+ async #fetchMutationJSON(path, body, signal) {
2945
+ return this.#fetchJSONRequest(path, body, { signal, mutation: true });
2946
+ }
2200
2947
  async #fetchJSONRequest(path, body, options = {}) {
2201
2948
  const controller = new AbortController();
2202
2949
  let timedOut = false;
@@ -2213,7 +2960,7 @@ export class PluginSurfaceHost {
2213
2960
  timer = setTimeout(() => {
2214
2961
  timedOut = true;
2215
2962
  controller.abort();
2216
- reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`));
2963
+ reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined));
2217
2964
  }, this.#requestTimeoutMs);
2218
2965
  });
2219
2966
  try {
@@ -2228,12 +2975,16 @@ export class PluginSurfaceHost {
2228
2975
  }),
2229
2976
  timeout,
2230
2977
  ]);
2231
- return await readHostEnvelope(response, "PLUGIN_PERMISSION_DENIED");
2978
+ return options.mutation ? await readMutationPlatformResponse(response) : await readPlatformResponse(response);
2232
2979
  }
2233
2980
  catch (error) {
2234
- if (timedOut)
2235
- throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`);
2236
- throw error;
2981
+ if (timedOut) {
2982
+ throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined);
2983
+ }
2984
+ if (error instanceof PluginBridgeError || error instanceof PluginPlatformRequestError || error instanceof PluginTransportError) {
2985
+ throw error;
2986
+ }
2987
+ throw new PluginTransportError(`Plugin surface request failed for POST ${path}`, error, options.mutation ? "unknown" : undefined);
2237
2988
  }
2238
2989
  finally {
2239
2990
  if (timer)
@@ -2271,7 +3022,7 @@ export class PluginSurfaceHost {
2271
3022
  }
2272
3023
  #startLeaseRenewal() {
2273
3024
  this.#assertActive();
2274
- if (!this.#ready || !this.#gatewayToken) {
3025
+ if (!this.#bridgeReady || !this.#gatewayToken) {
2275
3026
  return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface lease cannot renew before readiness"));
2276
3027
  }
2277
3028
  if (this.#leaseRenewalPromise)
@@ -2305,28 +3056,21 @@ export class PluginSurfaceHost {
2305
3056
  #revokeSurface(keepalive) {
2306
3057
  if (this.#revokePromise)
2307
3058
  return this.#revokePromise;
2308
- this.#revokePromise = (async () => {
2309
- await this.#fetchJSONRequest(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/dispose`, { bridge_nonce: this.bootstrap.bridgeNonce }, { keepalive, independentLifecycle: true });
2310
- })();
3059
+ this.#revokePromise = revokeSurfaceBootstrap(this.#transport, this.bootstrap, this.#requestTimeoutMs, keepalive);
2311
3060
  return this.#revokePromise;
2312
3061
  }
2313
- async #bestEffortRevokeSurface(keepalive) {
2314
- try {
2315
- await this.#revokeSurface(keepalive);
2316
- }
2317
- catch {
2318
- // The server may already have revoked the surface after a failed prepare.
2319
- }
2320
- }
2321
3062
  async #quiesceSurface() {
2322
- if (!this.#ready || !this.#port || this.#quiesce)
2323
- return;
3063
+ const startedAt = performance.now();
3064
+ if (!this.#ready || !this.#port || this.#quiesce) {
3065
+ return { outcome: "not_ready", durationMs: performance.now() - startedAt };
3066
+ }
2324
3067
  const quiesce = {
2325
3068
  id: randomOpaqueHandle("quiesce"),
2326
3069
  acknowledged: deferred(),
2327
3070
  };
2328
3071
  void quiesce.acknowledged.promise.catch(() => undefined);
2329
3072
  this.#quiesce = quiesce;
3073
+ this.#ready = false;
2330
3074
  try {
2331
3075
  this.#postToRenderer({
2332
3076
  type: "redevplugin.bridge.lifecycle",
@@ -2334,9 +3078,12 @@ export class PluginSurfaceHost {
2334
3078
  quiesce_id: quiesce.id,
2335
3079
  });
2336
3080
  await withTimeout(quiesce.acknowledged.promise, Math.min(this.#requestTimeoutMs, maxSurfaceQuiesceMs), "Plugin surface quiesce timed out");
3081
+ return { outcome: "acknowledged", durationMs: performance.now() - startedAt };
2337
3082
  }
2338
3083
  catch {
2339
- // A non-responsive plugin cannot block session revocation and iframe teardown.
3084
+ const error = new PluginBridgeError("PLUGIN_SURFACE_QUIESCE_TIMEOUT", "Plugin surface quiesce timed out");
3085
+ this.#reportError(error);
3086
+ return { outcome: "timed_out", durationMs: performance.now() - startedAt };
2340
3087
  }
2341
3088
  finally {
2342
3089
  if (this.#quiesce === quiesce)
@@ -2347,10 +3094,11 @@ export class PluginSurfaceHost {
2347
3094
  if (this.#disposed)
2348
3095
  return;
2349
3096
  this.#ready = false;
3097
+ this.#bridgeReady = false;
2350
3098
  this.#openSignals?.firstPaint.reject(error);
2351
3099
  this.#openSignals?.workerReady.reject(error);
2352
3100
  this.#reportError(error);
2353
- const revoke = this.#bestEffortRevokeSurface(true);
3101
+ const revoke = this.#revokeSurface(true);
2354
3102
  this.#disposeLocal();
2355
3103
  await revoke;
2356
3104
  }
@@ -2362,16 +3110,29 @@ export class PluginSurfaceHost {
2362
3110
  }
2363
3111
  this.#postToRenderer(response);
2364
3112
  }
2365
- #postError(id, errorCode, error, details) {
3113
+ #postError(id, errorCode, error, details, mutationOutcome) {
2366
3114
  const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
2367
- this.#postToRenderer(removeUndefined({
3115
+ const response = removeUndefined({
2368
3116
  type: "redevplugin.bridge.response",
2369
3117
  id,
2370
3118
  ok: false,
2371
3119
  error_code: errorCode,
2372
3120
  error,
2373
3121
  error_details: errorDetails,
2374
- }));
3122
+ mutation_outcome: mutationOutcome,
3123
+ });
3124
+ if (!messageWithinLimit(response)) {
3125
+ this.#postToRenderer(removeUndefined({
3126
+ type: "redevplugin.bridge.response",
3127
+ id,
3128
+ ok: false,
3129
+ error_code: "PLUGIN_JSON_LIMIT_EXCEEDED",
3130
+ error: "Plugin error response exceeds the bridge message limit",
3131
+ mutation_outcome: mutationOutcome,
3132
+ }));
3133
+ return;
3134
+ }
3135
+ this.#postToRenderer(response);
2375
3136
  }
2376
3137
  #postToRenderer(message) {
2377
3138
  if (!this.#port) {
@@ -2387,6 +3148,14 @@ export class PluginSurfaceHost {
2387
3148
  // Observers cannot weaken revocation or local teardown invariants.
2388
3149
  }
2389
3150
  }
3151
+ #reportOpeningProgress(progress) {
3152
+ try {
3153
+ this.#onOpeningProgress?.(progress);
3154
+ }
3155
+ catch {
3156
+ // Observers cannot weaken opening, revocation, or local teardown invariants.
3157
+ }
3158
+ }
2390
3159
  #assertReady() {
2391
3160
  this.#assertActive();
2392
3161
  if (!this.#ready || !this.#port) {
@@ -2399,6 +3168,376 @@ export class PluginSurfaceHost {
2399
3168
  }
2400
3169
  }
2401
3170
  }
3171
+ export function createPreparedPluginSurfaceHost(options) {
3172
+ return createPluginSurfaceHostImplementation(options);
3173
+ }
3174
+ function createPluginSurfaceHostImplementation(options) {
3175
+ validateHostBootstrap(options.bootstrap);
3176
+ const transport = requireSurfaceTransportInternals(options.hostTransport);
3177
+ return new PluginSurfaceHostImplementation(options, transport, createPluginSurfaceFrame());
3178
+ }
3179
+ const surfaceSlotInternals = new WeakMap();
3180
+ class PluginSurfaceCleanupError extends Error {
3181
+ cause;
3182
+ constructor(cause) {
3183
+ super("Plugin surface cleanup failed", { cause });
3184
+ this.name = "PluginSurfaceCleanupError";
3185
+ this.cause = cause;
3186
+ }
3187
+ }
3188
+ export function openPluginSurfaceInSlot(slot, request) {
3189
+ const internals = surfaceSlotInternals.get(slot);
3190
+ if (!internals)
3191
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
3192
+ return internals.open(request);
3193
+ }
3194
+ export function openPreparedPluginSurfaceInSlot(slot, options) {
3195
+ const internals = surfaceSlotInternals.get(slot);
3196
+ if (!internals)
3197
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
3198
+ return internals.openPrepared(options);
3199
+ }
3200
+ function createPluginSurfaceOpeningLease(request) {
3201
+ let unregister = () => undefined;
3202
+ let revokePromise;
3203
+ let removeAbortListener = () => undefined;
3204
+ const lease = {
3205
+ options: Promise.resolve(undefined),
3206
+ pluginInstanceId: request.pluginInstanceId,
3207
+ signal: request.signal,
3208
+ abortError: request.abortError,
3209
+ owner: "opening",
3210
+ cancelled: false,
3211
+ adopt() {
3212
+ if (lease.owner !== "opening" || lease.cancelled) {
3213
+ throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was cancelled before adoption");
3214
+ }
3215
+ lease.owner = "host";
3216
+ unregister();
3217
+ removeAbortListener();
3218
+ },
3219
+ cancel() {
3220
+ lease.cancelled = true;
3221
+ if (lease.owner === "host")
3222
+ return Promise.resolve();
3223
+ return lease.revoke();
3224
+ },
3225
+ revoke() {
3226
+ if (lease.owner === "host") {
3227
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface ownership was already adopted");
3228
+ }
3229
+ lease.cancelled = true;
3230
+ lease.owner = "revoked";
3231
+ unregister();
3232
+ removeAbortListener();
3233
+ revokePromise ??= lease.options.then(async (options) => {
3234
+ await revokeSurfaceBootstrap(requireSurfaceTransportInternals(options.hostTransport), options.bootstrap, normalizeTimeout(options.requestTimeoutMs), false);
3235
+ }, () => undefined);
3236
+ return revokePromise;
3237
+ },
3238
+ };
3239
+ unregister = registerPluginSurface(request.surfaceScope, request.pluginInstanceId, () => lease.cancel());
3240
+ try {
3241
+ lease.options = Promise.resolve(request.open());
3242
+ }
3243
+ catch (error) {
3244
+ lease.options = Promise.reject(error);
3245
+ }
3246
+ void lease.options.catch(() => {
3247
+ if (lease.owner !== "opening")
3248
+ return;
3249
+ lease.owner = "revoked";
3250
+ unregister();
3251
+ removeAbortListener();
3252
+ });
3253
+ if (request.signal) {
3254
+ const onAbort = () => {
3255
+ void lease.cancel().catch(() => undefined);
3256
+ };
3257
+ request.signal.addEventListener("abort", onAbort, { once: true });
3258
+ removeAbortListener = () => request.signal?.removeEventListener("abort", onAbort);
3259
+ if (request.signal.aborted)
3260
+ onAbort();
3261
+ }
3262
+ return lease;
3263
+ }
3264
+ export class PluginSurfaceSlot {
3265
+ element;
3266
+ #onStateChange;
3267
+ #onSurfaceClosed;
3268
+ #active;
3269
+ #opening;
3270
+ #transitionController;
3271
+ #tail = Promise.resolve();
3272
+ #disposed = false;
3273
+ #retired = new WeakMap();
3274
+ #disposePromise;
3275
+ static create(options) {
3276
+ if (!options.stage || typeof options.stage.append !== "function" || !options.stage.dataset) {
3277
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot requires an HTML stage");
3278
+ }
3279
+ return new PluginSurfaceSlot(options);
3280
+ }
3281
+ constructor(options) {
3282
+ this.element = options.stage;
3283
+ this.#onStateChange = options.onStateChange;
3284
+ this.#onSurfaceClosed = options.onSurfaceClosed;
3285
+ surfaceSlotInternals.set(this, {
3286
+ open: (request) => {
3287
+ this.#assertActive();
3288
+ return this.#openLease(createPluginSurfaceOpeningLease(request));
3289
+ },
3290
+ openPrepared: (preparedOptions) => this.#queueOpening(Promise.resolve(preparedOptions)),
3291
+ });
3292
+ this.#setState("empty");
3293
+ }
3294
+ #openLease(lease) {
3295
+ return this.#queueOpening(lease.options, lease);
3296
+ }
3297
+ #queueOpening(resolvedOptions, lease) {
3298
+ this.#assertActive();
3299
+ void resolvedOptions.catch(() => undefined);
3300
+ this.#transitionController?.abort();
3301
+ const controller = new AbortController();
3302
+ this.#transitionController = controller;
3303
+ this.#setState("opening");
3304
+ const transition = this.#tail.then(() => this.#openSurface(resolvedOptions, controller, lease), async (error) => {
3305
+ await this.#revokeOpening(lease);
3306
+ throw error;
3307
+ });
3308
+ this.#tail = transition.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
3309
+ void this.#tail.catch(() => undefined);
3310
+ return transition.catch((error) => {
3311
+ if (error instanceof PluginSurfaceCleanupError)
3312
+ throw error.cause;
3313
+ throw error;
3314
+ });
3315
+ }
3316
+ async #openSurface(options, controller, lease) {
3317
+ let resolvedOptions;
3318
+ try {
3319
+ resolvedOptions = await options;
3320
+ }
3321
+ catch (error) {
3322
+ if (!this.#disposed && this.#transitionController === controller && !controller.signal.aborted) {
3323
+ this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
3324
+ }
3325
+ throw error;
3326
+ }
3327
+ if (lease && resolvedOptions.bootstrap.pluginInstanceId !== lease.pluginInstanceId) {
3328
+ await this.#revokeOpening(lease);
3329
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap identity does not match the opening lease");
3330
+ }
3331
+ if (this.#openingCancelled(controller, lease)) {
3332
+ await this.#revokeOpening(lease);
3333
+ this.#setCancelledState(controller);
3334
+ throw this.#openingCancellationError(lease);
3335
+ }
3336
+ const previous = this.#active;
3337
+ this.#active = undefined;
3338
+ if (previous) {
3339
+ try {
3340
+ await this.#awaitRetirement(previous);
3341
+ }
3342
+ catch (error) {
3343
+ await this.#revokeOpening(lease);
3344
+ throw error;
3345
+ }
3346
+ }
3347
+ if (this.#openingCancelled(controller, lease)) {
3348
+ await this.#revokeOpening(lease);
3349
+ this.#setCancelledState(controller);
3350
+ throw this.#openingCancellationError(lease);
3351
+ }
3352
+ let host;
3353
+ try {
3354
+ host = createPluginSurfaceHostImplementation(resolvedOptions);
3355
+ }
3356
+ catch (error) {
3357
+ await this.#revokeOpening(lease);
3358
+ if (!this.#disposed && this.#transitionController === controller) {
3359
+ this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
3360
+ }
3361
+ throw error;
3362
+ }
3363
+ this.#adoptOpening(lease);
3364
+ this.#opening = host;
3365
+ setSurfaceInteractive(host.element, false);
3366
+ this.element.append(host.element);
3367
+ try {
3368
+ await this.#openHostUntilCancelled(host, controller, lease);
3369
+ if (this.#openingCancelled(controller, lease) || this.#opening !== host) {
3370
+ await this.#awaitRetirement(host);
3371
+ this.#setCancelledState(controller);
3372
+ throw this.#openingCancellationError(lease);
3373
+ }
3374
+ this.#opening = undefined;
3375
+ this.#active = host;
3376
+ setSurfaceInteractive(host.element, true);
3377
+ this.#setState("ready");
3378
+ return host;
3379
+ }
3380
+ catch (error) {
3381
+ await this.#awaitRetirement(host);
3382
+ if (!this.#disposed && this.#transitionController === controller && !this.#openingCancelled(controller, lease)) {
3383
+ const bridgeError = toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED");
3384
+ this.#setState("error", bridgeError);
3385
+ }
3386
+ throw error;
3387
+ }
3388
+ }
3389
+ async close() {
3390
+ this.#assertActive();
3391
+ this.#transitionController?.abort();
3392
+ this.#transitionController = undefined;
3393
+ this.#setState("empty");
3394
+ const closing = this.#tail.then(() => this.#closeCurrentSurface());
3395
+ this.#tail = closing.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
3396
+ void this.#tail.catch(() => undefined);
3397
+ return this.#unwrapCleanupError(closing);
3398
+ }
3399
+ dispose() {
3400
+ if (this.#disposePromise)
3401
+ return this.#disposePromise;
3402
+ this.#disposed = true;
3403
+ this.#transitionController?.abort();
3404
+ this.#transitionController = undefined;
3405
+ this.#setState("disposed");
3406
+ const disposal = this.#tail.then(async () => {
3407
+ await this.#closeCurrentSurface();
3408
+ });
3409
+ this.#disposePromise = this.#unwrapCleanupError(disposal);
3410
+ void this.#disposePromise.catch(() => undefined);
3411
+ this.#tail = disposal.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
3412
+ void this.#tail.catch(() => undefined);
3413
+ return this.#disposePromise;
3414
+ }
3415
+ async #closeCurrentSurface() {
3416
+ const hosts = new Set([this.#active, this.#opening].filter((host) => host !== undefined));
3417
+ this.#active = undefined;
3418
+ this.#opening = undefined;
3419
+ let result;
3420
+ const failures = [];
3421
+ for (const host of hosts) {
3422
+ try {
3423
+ const closed = await this.#retire(host);
3424
+ result ??= closed;
3425
+ }
3426
+ catch (error) {
3427
+ failures.push(error);
3428
+ }
3429
+ }
3430
+ if (failures.length === 1)
3431
+ throw failures[0];
3432
+ if (failures.length > 1)
3433
+ throw new AggregateError(failures, "Plugin surface slot cleanup failed");
3434
+ return result;
3435
+ }
3436
+ #retire(host) {
3437
+ const existing = this.#retired.get(host);
3438
+ if (existing)
3439
+ return existing;
3440
+ setSurfaceInteractive(host.element, false);
3441
+ const closing = Promise.resolve().then(async () => {
3442
+ try {
3443
+ const result = await host.close();
3444
+ try {
3445
+ this.#onSurfaceClosed?.(result);
3446
+ }
3447
+ catch {
3448
+ // Observers cannot turn a completed revocation into a cleanup failure.
3449
+ }
3450
+ return result;
3451
+ }
3452
+ finally {
3453
+ host.element.remove();
3454
+ }
3455
+ });
3456
+ this.#retired.set(host, closing);
3457
+ return closing;
3458
+ }
3459
+ async #awaitRetirement(host) {
3460
+ try {
3461
+ return await this.#retire(host);
3462
+ }
3463
+ catch (error) {
3464
+ throw new PluginSurfaceCleanupError(error);
3465
+ }
3466
+ }
3467
+ async #openHostUntilCancelled(host, controller, lease) {
3468
+ const signals = [controller.signal, lease?.signal].filter((signal) => signal !== undefined);
3469
+ let cancel;
3470
+ const cancelled = new Promise((_resolve, reject) => {
3471
+ cancel = () => {
3472
+ void this.#awaitRetirement(host).then(() => reject(this.#openingCancellationError(lease)), reject);
3473
+ };
3474
+ for (const signal of signals)
3475
+ signal.addEventListener("abort", cancel, { once: true });
3476
+ if (signals.some((signal) => signal.aborted))
3477
+ cancel();
3478
+ });
3479
+ try {
3480
+ await Promise.race([host.open(), cancelled]);
3481
+ }
3482
+ finally {
3483
+ for (const signal of signals)
3484
+ signal.removeEventListener("abort", cancel);
3485
+ }
3486
+ }
3487
+ #openingCancelled(controller, lease) {
3488
+ return this.#disposed || controller.signal.aborted || lease?.cancelled === true || lease?.signal?.aborted === true;
3489
+ }
3490
+ #openingCancellationError(lease) {
3491
+ if (lease?.signal?.aborted)
3492
+ return lease.abortError();
3493
+ return new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was superseded");
3494
+ }
3495
+ #setCancelledState(controller) {
3496
+ if (!this.#disposed && this.#transitionController === controller)
3497
+ this.#setState("empty");
3498
+ }
3499
+ #adoptOpening(lease) {
3500
+ lease?.adopt();
3501
+ }
3502
+ async #revokeOpening(lease) {
3503
+ if (!lease || lease.owner === "host")
3504
+ return;
3505
+ try {
3506
+ await lease.revoke();
3507
+ }
3508
+ catch (error) {
3509
+ throw new PluginSurfaceCleanupError(error);
3510
+ }
3511
+ }
3512
+ async #unwrapCleanupError(result) {
3513
+ try {
3514
+ return await result;
3515
+ }
3516
+ catch (error) {
3517
+ if (error instanceof PluginSurfaceCleanupError)
3518
+ throw error.cause;
3519
+ throw error;
3520
+ }
3521
+ }
3522
+ #setState(state, error) {
3523
+ this.element.dataset.redevpluginSurfaceState = state;
3524
+ try {
3525
+ this.#onStateChange?.(state, error);
3526
+ }
3527
+ catch {
3528
+ // Observers cannot alter the slot lifecycle state machine.
3529
+ }
3530
+ }
3531
+ #assertActive() {
3532
+ if (this.#disposed)
3533
+ throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface slot is disposed");
3534
+ }
3535
+ }
3536
+ function setSurfaceInteractive(element, interactive) {
3537
+ element.hidden = !interactive;
3538
+ element.inert = !interactive;
3539
+ element.setAttribute("aria-hidden", interactive ? "false" : "true");
3540
+ }
2402
3541
  function claimOpaquePluginBridge() {
2403
3542
  const value = globalThis[opaquePluginBridgeGlobalKey];
2404
3543
  if (!isRecord(value) || typeof value.claim !== "function") {
@@ -2504,7 +3643,7 @@ function validateHostBootstrap(bootstrap) {
2504
3643
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap is incomplete");
2505
3644
  }
2506
3645
  }
2507
- if (!Number.isSafeInteger(bootstrap.pluginStateVersion) || bootstrap.pluginStateVersion < 1 ||
3646
+ if (!Number.isSafeInteger(bootstrap.managementRevision) || bootstrap.managementRevision < 1 ||
2508
3647
  !Number.isSafeInteger(bootstrap.revokeEpoch) || bootstrap.revokeEpoch < 1) {
2509
3648
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revision is invalid");
2510
3649
  }
@@ -2513,7 +3652,7 @@ function validateSurfacePreparation(bootstrap, preparation) {
2513
3652
  if (!isSurfacePreparationResult(preparation)) {
2514
3653
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface prepare returned an invalid opaque document");
2515
3654
  }
2516
- if (preparation.plugin_state_version !== bootstrap.pluginStateVersion || preparation.revoke_epoch !== bootstrap.revokeEpoch) {
3655
+ if (preparation.management_revision !== bootstrap.managementRevision || preparation.revoke_epoch !== bootstrap.revokeEpoch) {
2517
3656
  throw new PluginBridgeError("PLUGIN_GATEWAY_TOKEN_INVALID", "Plugin surface state changed during prepare");
2518
3657
  }
2519
3658
  if (preparation.asset_session_nonce !== bootstrap.assetSessionNonce) {
@@ -2531,7 +3670,7 @@ function isSurfacePreparationResult(value) {
2531
3670
  "asset_session_nonce",
2532
3671
  "entry_path",
2533
3672
  "entry_sha256",
2534
- "plugin_state_version",
3673
+ "management_revision",
2535
3674
  "revoke_epoch",
2536
3675
  "issued_at",
2537
3676
  "expires_at",
@@ -2545,7 +3684,7 @@ function isSurfacePreparationResult(value) {
2545
3684
  typeof value.asset_session_nonce === "string" && value.asset_session_nonce.length > 0 &&
2546
3685
  validPackagePath(value.entry_path) &&
2547
3686
  validSHA256(value.entry_sha256) &&
2548
- Number.isSafeInteger(value.plugin_state_version) && Number(value.plugin_state_version) >= 1 &&
3687
+ Number.isSafeInteger(value.management_revision) && Number(value.management_revision) >= 1 &&
2549
3688
  Number.isSafeInteger(value.revoke_epoch) && Number(value.revoke_epoch) >= 1 &&
2550
3689
  Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && expiresAt > issuedAt &&
2551
3690
  isOpaqueSurfaceDocument(value.document);
@@ -2611,6 +3750,13 @@ function isStreamReadMessage(value) {
2611
3750
  validBridgeRequestID(value.id, "stream") &&
2612
3751
  validOpaqueHandle(value.stream_handle, "stream");
2613
3752
  }
3753
+ function isStreamAcknowledgeMessage(value) {
3754
+ return hasExactKeys(value, ["type", "id", "stream_handle", "delivery_id"]) &&
3755
+ value.type === "redevplugin.bridge.stream.ack" &&
3756
+ validBridgeRequestID(value.id, "stream_ack") &&
3757
+ validOpaqueHandle(value.stream_handle, "stream") &&
3758
+ validDeliveryID(value.delivery_id);
3759
+ }
2614
3760
  function isOperationCancelMessage(value) {
2615
3761
  return hasAllowedKeys(value, ["type", "id", "operation_id", "reason"]) &&
2616
3762
  value.type === "redevplugin.bridge.operation.cancel" &&
@@ -2641,7 +3787,8 @@ function isBridgeResponse(value) {
2641
3787
  return Object.keys(value).every((key) => ["type", "id", "ok", "data"].includes(key));
2642
3788
  if (value.ok !== false || typeof value.error_code !== "string" || !pluginBridgeErrorCodeSet.has(value.error_code) ||
2643
3789
  typeof value.error !== "string" || value.error.length > 4096 ||
2644
- !Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details"].includes(key))) {
3790
+ !Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details", "mutation_outcome"].includes(key)) ||
3791
+ (value.mutation_outcome !== undefined && value.mutation_outcome !== "not_committed" && value.mutation_outcome !== "unknown")) {
2645
3792
  return false;
2646
3793
  }
2647
3794
  if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
@@ -2701,14 +3848,28 @@ function isLifecycleMessage(value) {
2701
3848
  (value.quiesce_id === undefined || (value.event.type === "dispose" && validOpaqueHandle(value.quiesce_id, "quiesce")));
2702
3849
  }
2703
3850
  function isActionMessage(value) {
2704
- return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
3851
+ return hasAllowedKeys(value, ["type", "action", "event", "target_key", "edit_revision", "is_composing", "value", "checked", "form_data"]) &&
2705
3852
  value.type === "redevplugin.ui.action" &&
2706
3853
  validActionID(value.action) &&
2707
3854
  (value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit" || value.event === "escape") &&
3855
+ validUIIdentifier(value.target_key) && Number.isSafeInteger(value.edit_revision) && Number(value.edit_revision) >= 0 &&
3856
+ typeof value.is_composing === "boolean" &&
2708
3857
  (value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
2709
3858
  (value.checked == null || typeof value.checked === "boolean") &&
2710
3859
  (value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
2711
3860
  }
3861
+ function publicActionEvent(value) {
3862
+ return removeUndefined({
3863
+ action: value.action,
3864
+ event: value.event,
3865
+ targetKey: value.target_key,
3866
+ editRevision: value.edit_revision,
3867
+ isComposing: value.is_composing,
3868
+ value: value.value,
3869
+ checked: value.checked,
3870
+ form_data: value.form_data,
3871
+ });
3872
+ }
2712
3873
  function isCanvasReadyCandidate(value) {
2713
3874
  return isRecord(value) && value.type === "redevplugin.ui.canvas.ready" && typeof value.id === "string" && typeof value.canvas_id === "string";
2714
3875
  }
@@ -2827,25 +3988,23 @@ function isGatewayTokenResult(value) {
2827
3988
  typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
2828
3989
  typeof value.issued_at === "string" && typeof value.expires_at === "string";
2829
3990
  }
2830
- function isStreamReadResult(value, expectedStreamID, previousSequence) {
3991
+ function isStreamReadResult(value, expectedStreamID, expectedReadID, previousSequence) {
2831
3992
  if (!isRecord(value) || typeof value.done !== "boolean" || !Array.isArray(value.events))
2832
3993
  return false;
3994
+ const hasDelivery = value.events.length > 0 || value.done;
2833
3995
  const expectedKeys = value.done
2834
- ? ["done", "events", "terminal_status"]
2835
- : ["done", "events", "next_stream_expires_at", "next_stream_ticket", "next_stream_ticket_id"];
3996
+ ? ["delivery_id", "done", "events", "read_id", "terminal_status"]
3997
+ : hasDelivery
3998
+ ? ["delivery_id", "done", "events", "read_id"]
3999
+ : ["done", "events", "read_id"];
2836
4000
  if (!hasExactKeys(value, expectedKeys))
2837
4001
  return false;
4002
+ if (value.read_id !== expectedReadID || (hasDelivery && !validDeliveryID(value.delivery_id)))
4003
+ return false;
2838
4004
  if (value.done) {
2839
4005
  if (!validPluginStreamTerminalStatus(value.terminal_status))
2840
4006
  return false;
2841
4007
  }
2842
- else {
2843
- const expiresAt = Date.parse(String(value.next_stream_expires_at));
2844
- if (typeof value.next_stream_ticket !== "string" || value.next_stream_ticket.length === 0 ||
2845
- typeof value.next_stream_ticket_id !== "string" || value.next_stream_ticket_id.length === 0 ||
2846
- !Number.isFinite(expiresAt) || expiresAt <= Date.now())
2847
- return false;
2848
- }
2849
4008
  let terminal = false;
2850
4009
  for (const event of value.events) {
2851
4010
  if (!isStreamEvent(event) || event.stream_id !== expectedStreamID || event.sequence <= previousSequence || terminal)
@@ -2886,7 +4045,7 @@ const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
2886
4045
  const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
2887
4046
  const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
2888
4047
  const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
2889
- const bridgeRequestIDPattern = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
4048
+ const bridgeRequestIDPattern = /^(rpc|stream|stream_ack|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
2890
4049
  function validBridgeRequestID(value, expectedKind) {
2891
4050
  if (typeof value !== "string")
2892
4051
  return false;
@@ -2907,6 +4066,9 @@ function validUIIdentifier(value) {
2907
4066
  function validOpaqueHandle(value, prefix) {
2908
4067
  return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
2909
4068
  }
4069
+ function validDeliveryID(value) {
4070
+ return typeof value === "string" && /^delivery_[A-Za-z0-9_-]{8,128}$/.test(value);
4071
+ }
2910
4072
  function validSHA256(value) {
2911
4073
  return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
2912
4074
  }
@@ -2994,6 +4156,38 @@ function isPluginRiskEffect(value) {
2994
4156
  function confirmationDecisionAccepted(decision) {
2995
4157
  return typeof decision === "boolean" ? decision : decision.confirmed;
2996
4158
  }
4159
+ function streamReadAbortedError() {
4160
+ return new PluginBridgeError("PLUGIN_STREAM_CANCELLED", "Plugin stream read was aborted");
4161
+ }
4162
+ function mutationOutcome(options, posted) {
4163
+ if (!options.mutation)
4164
+ return undefined;
4165
+ return posted ? "unknown" : "not_committed";
4166
+ }
4167
+ function requestCancelledError(options, posted) {
4168
+ if (options.cancellationKind === "stream")
4169
+ return streamReadAbortedError();
4170
+ return new PluginBridgeError("PLUGIN_BRIDGE_CANCELLED", "Plugin bridge request was cancelled", undefined, undefined, mutationOutcome(options, posted));
4171
+ }
4172
+ function abortableStreamRead(read, signal) {
4173
+ if (!signal)
4174
+ return read;
4175
+ if (signal.aborted)
4176
+ return Promise.reject(streamReadAbortedError());
4177
+ return new Promise((resolve, reject) => {
4178
+ let settled = false;
4179
+ const finish = (callback, value) => {
4180
+ if (settled)
4181
+ return;
4182
+ settled = true;
4183
+ signal.removeEventListener("abort", onAbort);
4184
+ callback(value);
4185
+ };
4186
+ const onAbort = () => finish(reject, streamReadAbortedError());
4187
+ signal.addEventListener("abort", onAbort, { once: true });
4188
+ read.then((value) => finish(resolve, value), (error) => finish(reject, error));
4189
+ });
4190
+ }
2997
4191
  function abortableConfirmationDecision(decision, signal) {
2998
4192
  if (signal.aborted) {
2999
4193
  return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin confirmation was aborted"));
@@ -3032,7 +4226,7 @@ function normalizePluginJSONObject(value) {
3032
4226
  }
3033
4227
  function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: new Set() }) {
3034
4228
  state.nodes += 1;
3035
- if (state.nodes > 4096 || depth > 64)
4229
+ if (state.nodes > maxPluginJSONStructuralNodes || depth > 64)
3036
4230
  throw new TypeError("JSON value exceeds structural limits");
3037
4231
  if (value === null || typeof value === "string" || typeof value === "boolean")
3038
4232
  return value;
@@ -3119,15 +4313,25 @@ function removeUndefined(value) {
3119
4313
  delete value[key];
3120
4314
  return value;
3121
4315
  }
3122
- function toBridgeError(error, fallbackCode) {
4316
+ function toBridgeError(error, defaultCode) {
3123
4317
  if (error instanceof PluginBridgeError)
3124
4318
  return error;
4319
+ if (error instanceof PluginPlatformRequestError) {
4320
+ return new PluginBridgeError(error.errorCode, error.message, undefined, error.details, error.mutationOutcome);
4321
+ }
4322
+ if (error instanceof PluginTransportError) {
4323
+ return new PluginBridgeError(defaultCode, error.message, undefined, undefined, error.mutationOutcome);
4324
+ }
3125
4325
  if (error instanceof Error)
3126
- return new PluginBridgeError(fallbackCode, error.message);
3127
- return new PluginBridgeError(fallbackCode, String(error));
4326
+ return new PluginBridgeError(defaultCode, error.message);
4327
+ return new PluginBridgeError(defaultCode, String(error));
3128
4328
  }
3129
4329
  function streamReadFailureInvalidatesCredential(error) {
3130
4330
  if (!(error instanceof PluginBridgeError))
3131
4331
  return true;
3132
4332
  return streamCredentialInvalidatingErrorCodes.has(error.errorCode);
3133
4333
  }
4334
+ function retryableStreamReadTransportFailure(error) {
4335
+ return error instanceof PluginTransportError ||
4336
+ (error instanceof PluginBridgeError && error.errorCode === "PLUGIN_BRIDGE_TIMEOUT");
4337
+ }