@floegence/redevplugin-ui 0.3.2 → 0.4.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
@@ -3,7 +3,7 @@ import { pluginUIProtocolVersion } from "./contracts.gen.js";
3
3
  import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
4
4
  import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readHostEnvelope, } from "./http.js";
5
5
  import { defaultPluginSurfaceScope, registerPluginSurface, } from "./surface-scope.js";
6
- export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v1";
6
+ export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v2";
7
7
  export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
8
8
  const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridgeV2";
9
9
  const maxPendingPluginBridgeRequests = 256;
@@ -30,6 +30,7 @@ const streamCredentialInvalidatingErrorCodes = new Set([
30
30
  const maxOpaqueSurfaceLazyAssets = 128;
31
31
  const maxOpaqueSurfaceLazyBytes = 32 * 1024 * 1024;
32
32
  const maxConcurrentAssetReads = 4;
33
+ const maxSurfaceQuiesceMs = 1500;
33
34
  const pluginBridgeErrorCodeSet = new Set(pluginBridgeErrorCodes);
34
35
  const hostCapabilityIDPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._-]*$");
35
36
  const canonicalSemverPattern = new RegExp("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
@@ -42,6 +43,7 @@ export class PluginBridgeClient {
42
43
  #port;
43
44
  #pending = new Map();
44
45
  #actionHandlers = new Map();
46
+ #canvasInputHandlers = new Map();
45
47
  #lifecycleHandlers = new Set();
46
48
  #ready = false;
47
49
  #readyPromise;
@@ -49,7 +51,7 @@ export class PluginBridgeClient {
49
51
  #rejectReady;
50
52
  #disposed = false;
51
53
  #onMessage = (event) => {
52
- this.#handleMessage(event);
54
+ void this.#handleMessage(event);
53
55
  };
54
56
  constructor(options = {}) {
55
57
  const claimed = options.port
@@ -125,6 +127,44 @@ export class PluginBridgeClient {
125
127
  tree,
126
128
  });
127
129
  }
130
+ openCanvas(canvasId) {
131
+ this.#assertActive();
132
+ if (!validUIIdentifier(canvasId)) {
133
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas identifier is invalid");
134
+ }
135
+ const id = this.#requestID("canvas");
136
+ return this.#request(id, {
137
+ type: "redevplugin.ui.canvas.open",
138
+ id,
139
+ canvas_id: canvasId,
140
+ }, "canvas", canvasId);
141
+ }
142
+ updateCanvasAccessibility(canvasId, state) {
143
+ this.#assertActive();
144
+ if (!validUIIdentifier(canvasId) || !validCanvasAccessibilityState(state)) {
145
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas accessibility state is invalid");
146
+ }
147
+ const id = this.#requestID("canvas");
148
+ return this.#request(id, {
149
+ type: "redevplugin.ui.canvas.accessibility",
150
+ id,
151
+ canvas_id: canvasId,
152
+ label: state.label,
153
+ description: state.description,
154
+ });
155
+ }
156
+ loadImageAsset(assetId) {
157
+ this.#assertActive();
158
+ if (!validUIIdentifier(assetId)) {
159
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin image asset identifier is invalid");
160
+ }
161
+ const id = this.#requestID("asset");
162
+ return this.#request(id, {
163
+ type: "redevplugin.ui.asset.image.open",
164
+ id,
165
+ asset_id: assetId,
166
+ }, "asset", assetId);
167
+ }
128
168
  onAction(action, handler) {
129
169
  this.#assertActive();
130
170
  if (!validActionID(action) || typeof handler !== "function") {
@@ -139,6 +179,20 @@ export class PluginBridgeClient {
139
179
  this.#actionHandlers.delete(action);
140
180
  };
141
181
  }
182
+ onCanvasInput(canvasId, handler) {
183
+ this.#assertActive();
184
+ if (!validUIIdentifier(canvasId) || typeof handler !== "function") {
185
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin canvas input subscription is invalid");
186
+ }
187
+ const handlers = this.#canvasInputHandlers.get(canvasId) ?? new Set();
188
+ handlers.add(handler);
189
+ this.#canvasInputHandlers.set(canvasId, handlers);
190
+ return () => {
191
+ handlers.delete(handler);
192
+ if (handlers.size === 0)
193
+ this.#canvasInputHandlers.delete(canvasId);
194
+ };
195
+ }
142
196
  onLifecycle(handler) {
143
197
  this.#assertActive();
144
198
  this.#lifecycleHandlers.add(handler);
@@ -160,10 +214,11 @@ export class PluginBridgeClient {
160
214
  }
161
215
  this.#pending.clear();
162
216
  this.#actionHandlers.clear();
217
+ this.#canvasInputHandlers.clear();
163
218
  this.#lifecycleHandlers.clear();
164
219
  this.#port.close();
165
220
  }
166
- #request(id, message) {
221
+ #request(id, message, kind = "json", identifier) {
167
222
  let normalizedMessage;
168
223
  try {
169
224
  normalizedMessage = normalizePluginJSONObject(message);
@@ -193,6 +248,8 @@ export class PluginBridgeClient {
193
248
  resolve: (value) => resolve(value),
194
249
  reject,
195
250
  timer,
251
+ kind,
252
+ identifier,
196
253
  });
197
254
  });
198
255
  try {
@@ -208,10 +265,44 @@ export class PluginBridgeClient {
208
265
  }
209
266
  return result;
210
267
  }
211
- #handleMessage(event) {
212
- if (this.#disposed || !messageWithinLimit(event.data))
268
+ async #handleMessage(event) {
269
+ if (this.#disposed)
213
270
  return;
214
271
  const data = event.data;
272
+ if (isCanvasReadyCandidate(data)) {
273
+ const pending = this.#pending.get(data.id);
274
+ if (!pending)
275
+ return;
276
+ this.#pending.delete(data.id);
277
+ clearTimeout(pending.timer);
278
+ if (pending.kind !== "canvas" || pending.identifier !== data.canvas_id || !isCanvasReadyMessage(data)) {
279
+ pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin canvas response ${data.id} is invalid`));
280
+ return;
281
+ }
282
+ pending.resolve({
283
+ canvas: data.canvas,
284
+ canvasId: data.canvas_id,
285
+ cssWidth: data.css_width,
286
+ cssHeight: data.css_height,
287
+ devicePixelRatio: data.device_pixel_ratio,
288
+ });
289
+ return;
290
+ }
291
+ if (isImageReadyCandidate(data)) {
292
+ const pending = this.#pending.get(data.id);
293
+ if (!pending)
294
+ return;
295
+ this.#pending.delete(data.id);
296
+ clearTimeout(pending.timer);
297
+ if (pending.kind !== "asset" || pending.identifier !== data.asset_id || !isImageReadyMessage(data)) {
298
+ pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin image response ${data.id} is invalid`));
299
+ return;
300
+ }
301
+ pending.resolve(data.image);
302
+ return;
303
+ }
304
+ if (!messageWithinLimit(data))
305
+ return;
215
306
  if (isBridgeResponseCandidate(data)) {
216
307
  const pending = this.#pending.get(data.id);
217
308
  if (!pending)
@@ -222,7 +313,10 @@ export class PluginBridgeClient {
222
313
  pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin bridge response ${data.id} is invalid`));
223
314
  return;
224
315
  }
225
- if (data.ok)
316
+ if (data.ok && pending.kind !== "json") {
317
+ pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin transfer response ${data.id} is invalid`));
318
+ }
319
+ else if (data.ok)
226
320
  pending.resolve(data.data);
227
321
  else
228
322
  pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details));
@@ -233,8 +327,20 @@ export class PluginBridgeClient {
233
327
  this.#ready = true;
234
328
  this.#resolveReady();
235
329
  }
236
- for (const handler of this.#lifecycleHandlers)
237
- handler(data.event);
330
+ await Promise.allSettled(Array.from(this.#lifecycleHandlers, async (handler) => {
331
+ try {
332
+ await handler(data.event);
333
+ }
334
+ catch {
335
+ // A plugin lifecycle observer cannot block bounded surface teardown.
336
+ }
337
+ }));
338
+ if (data.quiesce_id) {
339
+ this.#port.postMessage({
340
+ type: "redevplugin.bridge.lifecycle_ack",
341
+ quiesce_id: data.quiesce_id,
342
+ });
343
+ }
238
344
  if (data.event.type === "dispose")
239
345
  this.dispose();
240
346
  return;
@@ -242,6 +348,12 @@ export class PluginBridgeClient {
242
348
  if (isActionMessage(data)) {
243
349
  for (const handler of this.#actionHandlers.get(data.action) ?? [])
244
350
  handler(data);
351
+ return;
352
+ }
353
+ const canvasInput = publicCanvasInputMessage(data);
354
+ if (canvasInput) {
355
+ for (const handler of this.#canvasInputHandlers.get(canvasInput.canvasId) ?? [])
356
+ handler(canvasInput.event);
245
357
  }
246
358
  }
247
359
  #requestID(prefix) {
@@ -412,6 +524,15 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
412
524
  const maxTextLength = ${opaqueSurfaceRenderLimits.max_text_length};
413
525
  const maxAttributeValueLength = ${opaqueSurfaceRenderLimits.max_attribute_value_length};
414
526
  const maxFormFields = ${opaqueSurfaceRenderLimits.max_form_fields};
527
+ const maxCanvasCount = ${opaqueSurfaceRenderLimits.max_canvas_count};
528
+ const maxCanvasDimension = ${opaqueSurfaceRenderLimits.max_canvas_dimension};
529
+ const maxCanvasTotalPixels = ${opaqueSurfaceRenderLimits.max_canvas_total_pixels};
530
+ const maxCanvasPointerEventsPerSecond = ${opaqueSurfaceRenderLimits.max_canvas_pointer_events_per_second};
531
+ const maxImageCount = ${opaqueSurfaceRenderLimits.max_image_count};
532
+ const maxImageDimension = ${opaqueSurfaceRenderLimits.max_image_dimension};
533
+ const maxImageTotalPixels = ${opaqueSurfaceRenderLimits.max_image_total_pixels};
534
+ const workerHeartbeatIntervalMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_interval_ms};
535
+ const workerHeartbeatTimeoutMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_timeout_ms};
415
536
  const maxCriticalDocumentBytes = ${8 * 1024 * 1024};
416
537
  const maxPrivateAssetBase64Length = ${Math.ceil((32 * 1024 * 1024) / 3) * 4};
417
538
  const maxLazyAssetCount = ${maxOpaqueSurfaceLazyAssets};
@@ -458,6 +579,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
458
579
  catch { return false; }
459
580
  };
460
581
  const validIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
582
+ const validResourceIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
461
583
  const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
462
584
  const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
463
585
  const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
@@ -467,8 +589,11 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
467
589
  if (!globalAttributes.has(lower) && !lower.startsWith("aria-") && !(tagAttributes[tag] && tagAttributes[tag].has(lower))) return false;
468
590
  if (!["string", "number", "boolean"].includes(typeof value)) return false;
469
591
  if (lower === "data-redevplugin-action" && !validIdentifier(String(value))) return false;
592
+ if (lower === "data-redevplugin-escape-action" && !validIdentifier(String(value))) return false;
470
593
  if (lower === "data-redevplugin-asset-binding" && !validOpaqueHandle(String(value), "asset")) return false;
471
594
  if (lower === "data-redevplugin-asset-attr" && !["src", "poster"].includes(String(value))) return false;
595
+ if (lower === "data-redevplugin-canvas" && (tag !== "canvas" || !validResourceIdentifier(String(value)))) return false;
596
+ if (tag === "canvas" && (lower === "width" || lower === "height") && (!/^[1-9][0-9]{0,4}$/.test(String(value)) || Number(value) > maxCanvasDimension)) return false;
472
597
  if (tag === "input" && lower === "type" && !safeInputTypes.has(String(value).trim().toLowerCase() || "text")) return false;
473
598
  return String(value).length <= maxAttributeValueLength;
474
599
  };
@@ -492,8 +617,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
492
617
  };
493
618
  const validStyle = (value) => exactKeys(value, ["path", "sha256", "content"]) && validPath(value.path) && validDigest(value.sha256) && typeof value.content === "string" && value.content.length <= 2097152;
494
619
  const validWorker = (value) => exactKeys(value, ["path", "sha256", "type", "content"]) && validPath(value.path) && validDigest(value.sha256) && value.type === "classic" && typeof value.content === "string" && value.content.length <= 4194304;
495
- const validAsset = (value) => exactKeys(value, ["binding_id", "path", "sha256", "size", "content_type"]) && validOpaqueHandle(value.binding_id, "asset") && validPath(value.path) && validDigest(value.sha256) && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= maxLazyAssetBytes && typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256;
496
- const validDocument = (value) => isRecord(value) && Object.keys(value).every((key) => ["schema_version", "entry_path", "entry_sha256", "title", "language", "direction", "body_html", "styles", "worker", "assets", "critical_bytes"].includes(key)) && value.schema_version === documentSchema && validPath(value.entry_path) && validDigest(value.entry_sha256) && (value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) && (value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) && (value.direction === undefined || ["ltr", "rtl", "auto"].includes(value.direction)) && typeof value.body_html === "string" && value.body_html.length <= 4194304 && Array.isArray(value.styles) && value.styles.every(validStyle) && validWorker(value.worker) && Array.isArray(value.assets) && value.assets.length <= maxLazyAssetCount && value.assets.every(validAsset) && new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length && new Set(value.assets.map((asset) => asset.path)).size === value.assets.length && value.assets.reduce((total, asset) => total + asset.size, 0) <= maxLazyAssetBytes && Number.isSafeInteger(value.critical_bytes) && value.critical_bytes >= 0 && value.critical_bytes <= maxCriticalDocumentBytes;
620
+ const validAsset = (value) => exactKeys(value, ["binding_id", "logical_ids", "path", "sha256", "size", "content_type"]) && validOpaqueHandle(value.binding_id, "asset") && Array.isArray(value.logical_ids) && value.logical_ids.length > 0 && value.logical_ids.length <= 16 && value.logical_ids.every(validResourceIdentifier) && new Set(value.logical_ids).size === value.logical_ids.length && validPath(value.path) && validDigest(value.sha256) && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= maxLazyAssetBytes && typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256;
621
+ const validDocument = (value) => isRecord(value) && Object.keys(value).every((key) => ["schema_version", "entry_path", "entry_sha256", "title", "language", "direction", "body_html", "styles", "worker", "assets", "critical_bytes"].includes(key)) && value.schema_version === documentSchema && validPath(value.entry_path) && validDigest(value.entry_sha256) && (value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) && (value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) && (value.direction === undefined || ["ltr", "rtl", "auto"].includes(value.direction)) && typeof value.body_html === "string" && value.body_html.length <= 4194304 && Array.isArray(value.styles) && value.styles.every(validStyle) && validWorker(value.worker) && Array.isArray(value.assets) && value.assets.length <= maxLazyAssetCount && value.assets.every(validAsset) && new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length && new Set(value.assets.map((asset) => asset.path)).size === value.assets.length && new Set(value.assets.flatMap((asset) => asset.logical_ids)).size === value.assets.reduce((total, asset) => total + asset.logical_ids.length, 0) && value.assets.reduce((total, asset) => total + asset.size, 0) <= maxLazyAssetBytes && Number.isSafeInteger(value.critical_bytes) && value.critical_bytes >= 0 && value.critical_bytes <= maxCriticalDocumentBytes;
497
622
  let accepted = false;
498
623
  let initialized = false;
499
624
  let parentPort;
@@ -504,15 +629,31 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
504
629
  let surfaceHandle = "";
505
630
  let currentDocument;
506
631
  let workerReady = false;
632
+ let workerHeartbeatSequence = 0;
633
+ let workerHeartbeatPendingID;
634
+ let workerHeartbeatTimer;
635
+ let workerHeartbeatTimeout;
636
+ let pendingQuiesceID;
507
637
  const pendingWorkerRequests = new Set();
508
- const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0 };
638
+ const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
509
639
  let renderWindowStartedAt = 0;
510
640
  let renderCount = 0;
641
+ let lastAutofocusIdentity = "";
511
642
  const pendingAssets = new Map();
512
643
  const queuedAssets = [];
513
644
  let activeAssetReads = 0;
514
645
  let assetSequence = 0;
515
646
  const blobURLs = new Set();
647
+ const assetByLogicalID = new Map();
648
+ const verifiedAssetBytes = new Map();
649
+ const imageAssetDimensions = new Map();
650
+ const imageAssetTypes = new Map();
651
+ const pendingImageRequests = new Map();
652
+ const activeImageDecodes = new Set();
653
+ const imageReservations = new Map();
654
+ let retainedImageCount = 0;
655
+ let retainedImagePixels = 0;
656
+ const canvasRuntimes = new Map();
516
657
 
517
658
  const sendParent = (message) => {
518
659
  if (parentPort && withinLimit(message)) parentPort.postMessage(message);
@@ -520,6 +661,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
520
661
  const sendWorker = (message) => {
521
662
  if (workerPort && withinLimit(message)) workerPort.postMessage(message);
522
663
  };
664
+ const sendWorkerTransfer = (message, transfer) => {
665
+ if (workerPort) workerPort.postMessage(message, transfer);
666
+ };
523
667
  const fail = (message) => {
524
668
  document.documentElement.lang = "en";
525
669
  document.body.replaceChildren();
@@ -531,6 +675,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
531
675
  disposeRuntime();
532
676
  };
533
677
  const disposeRuntime = () => {
678
+ if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
679
+ if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
680
+ workerHeartbeatTimer = undefined;
681
+ workerHeartbeatTimeout = undefined;
682
+ workerHeartbeatPendingID = undefined;
683
+ workerReady = false;
684
+ for (const runtime of canvasRuntimes.values()) runtime.dispose();
685
+ canvasRuntimes.clear();
534
686
  if (workerControlPort) {
535
687
  workerControlPort.close();
536
688
  workerControlPort = undefined;
@@ -550,14 +702,44 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
550
702
  pendingAssets.clear();
551
703
  queuedAssets.length = 0;
552
704
  activeAssetReads = 0;
705
+ pendingImageRequests.clear();
706
+ activeImageDecodes.clear();
707
+ imageReservations.clear();
708
+ imageAssetDimensions.clear();
709
+ imageAssetTypes.clear();
710
+ retainedImageCount = 0;
711
+ retainedImagePixels = 0;
712
+ verifiedAssetBytes.clear();
713
+ assetByLogicalID.clear();
714
+ };
715
+ const validateCanvasIdentifiers = (root) => {
716
+ const identifiers = new Set();
717
+ let totalPixels = 0;
718
+ for (const canvas of root.querySelectorAll("canvas[data-redevplugin-canvas]")) {
719
+ const identifier = canvas.getAttribute("data-redevplugin-canvas");
720
+ if (!validResourceIdentifier(identifier) || identifiers.has(identifier)) throw new Error("plugin canvas identifiers are invalid or ambiguous");
721
+ identifiers.add(identifier);
722
+ if (identifiers.size > maxCanvasCount) throw new Error("plugin document exceeds the canvas count limit");
723
+ const width = Number(canvas.getAttribute("width") || 300);
724
+ const height = Number(canvas.getAttribute("height") || 150);
725
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > maxCanvasDimension || height > maxCanvasDimension) {
726
+ throw new Error("plugin canvas dimensions are invalid");
727
+ }
728
+ totalPixels += width * height;
729
+ if (totalPixels > maxCanvasTotalPixels) throw new Error("plugin document exceeds the canvas pixel budget");
730
+ }
553
731
  };
554
732
  const applyStaticDocument = (surfaceDocument) => {
733
+ for (const asset of surfaceDocument.assets) {
734
+ for (const logicalID of asset.logical_ids) assetByLogicalID.set(logicalID, asset);
735
+ }
555
736
  document.title = typeof surfaceDocument.title === "string" ? surfaceDocument.title.slice(0, 256) : "Plugin";
556
737
  document.documentElement.lang = typeof surfaceDocument.language === "string" ? surfaceDocument.language.slice(0, 64) : "en";
557
738
  if (["ltr", "rtl", "auto"].includes(surfaceDocument.direction)) document.documentElement.dir = surfaceDocument.direction;
558
739
  const template = document.createElement("template");
559
740
  template.innerHTML = surfaceDocument.body_html;
560
741
  if (!validateDOMTree(template.content)) throw new Error("static plugin document failed renderer validation");
742
+ validateCanvasIdentifiers(template.content);
561
743
  document.body.replaceChildren(template.content.cloneNode(true));
562
744
  for (const styleAsset of surfaceDocument.styles) {
563
745
  const style = document.createElement("style");
@@ -582,6 +764,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
582
764
  if (!isRecord(value.attributes) || Object.keys(value.attributes).length > maxAttributesPerElement) throw new Error("plugin render attributes are invalid");
583
765
  for (const [name, attributeValue] of Object.entries(value.attributes)) {
584
766
  if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
767
+ if (name.toLowerCase() === "autofocus") {
768
+ if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
769
+ continue;
770
+ }
585
771
  if (typeof attributeValue === "boolean") {
586
772
  if (attributeValue) element.setAttribute(name, "");
587
773
  } else {
@@ -595,22 +781,88 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
595
781
  }
596
782
  return element;
597
783
  };
784
+ const renderElementIdentity = (element) => ({
785
+ tag: element.tagName,
786
+ id: element.getAttribute("id") || "",
787
+ name: element.getAttribute("name") || "",
788
+ action: element.getAttribute("data-redevplugin-action") || "",
789
+ escapeAction: element.getAttribute("data-redevplugin-escape-action") || "",
790
+ ariaLabel: element.getAttribute("aria-label") || "",
791
+ value: element.tagName === "BUTTON" || element.tagName === "OPTION" ? element.getAttribute("value") || "" : "",
792
+ });
793
+ const sameRenderElementIdentity = (left, right) => left.tag === right.tag && left.id === right.id && left.name === right.name && left.action === right.action && left.escapeAction === right.escapeAction && left.ariaLabel === right.ariaLabel && left.value === right.value;
794
+ const captureRenderState = () => {
795
+ const active = document.activeElement instanceof Element && document.body.contains(document.activeElement) ? document.activeElement : undefined;
796
+ let selection;
797
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
798
+ selection = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
799
+ }
800
+ return {
801
+ activeIdentity: active ? renderElementIdentity(active) : undefined,
802
+ selection,
803
+ activeScrollTop: active instanceof HTMLElement ? active.scrollTop : 0,
804
+ activeScrollLeft: active instanceof HTMLElement ? active.scrollLeft : 0,
805
+ documentScrollTop: document.documentElement.scrollTop,
806
+ documentScrollLeft: document.documentElement.scrollLeft,
807
+ bodyScrollTop: document.body.scrollTop,
808
+ bodyScrollLeft: document.body.scrollLeft,
809
+ };
810
+ };
811
+ const restoreRenderState = (snapshot) => {
812
+ const autofocus = document.body.querySelector("[data-redevplugin-renderer-autofocus]");
813
+ const autofocusIdentity = autofocus instanceof Element ? JSON.stringify(renderElementIdentity(autofocus)) : "";
814
+ if (autofocus instanceof Element) autofocus.removeAttribute("data-redevplugin-renderer-autofocus");
815
+ let target;
816
+ if (autofocus instanceof HTMLElement && !autofocus.hasAttribute("disabled") && autofocusIdentity !== lastAutofocusIdentity) {
817
+ target = autofocus;
818
+ lastAutofocusIdentity = autofocusIdentity;
819
+ } else {
820
+ if (!autofocusIdentity) lastAutofocusIdentity = "";
821
+ if (snapshot.activeIdentity) {
822
+ for (const candidate of document.body.querySelectorAll(snapshot.activeIdentity.tag.toLowerCase())) {
823
+ if (sameRenderElementIdentity(renderElementIdentity(candidate), snapshot.activeIdentity)) {
824
+ target = candidate;
825
+ break;
826
+ }
827
+ }
828
+ }
829
+ }
830
+ if (target instanceof HTMLElement && !target.hasAttribute("disabled")) {
831
+ try { target.focus({ preventScroll: true }); } catch { target.focus(); }
832
+ target.scrollTop = snapshot.activeScrollTop;
833
+ target.scrollLeft = snapshot.activeScrollLeft;
834
+ if (snapshot.selection && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
835
+ const length = target.value.length;
836
+ const start = Math.min(snapshot.selection.start ?? length, length);
837
+ const end = Math.min(snapshot.selection.end ?? start, length);
838
+ try { target.setSelectionRange(start, end, snapshot.selection.direction || "none"); } catch {}
839
+ }
840
+ }
841
+ document.documentElement.scrollTop = snapshot.documentScrollTop;
842
+ document.documentElement.scrollLeft = snapshot.documentScrollLeft;
843
+ document.body.scrollTop = snapshot.bodyScrollTop;
844
+ document.body.scrollLeft = snapshot.bodyScrollLeft;
845
+ };
598
846
  const applyWorkerRender = (tree) => {
847
+ if (canvasRuntimes.size > 0) throw new Error("plugin render cannot replace an active transferred canvas");
599
848
  const values = Array.isArray(tree) ? tree : [tree];
600
849
  const fragment = document.createDocumentFragment();
601
850
  const state = { nodes: 0 };
602
851
  for (const value of values) fragment.append(buildWorkerNode(value, state, 1));
852
+ validateCanvasIdentifiers(fragment);
853
+ const renderState = captureRenderState();
603
854
  document.body.replaceChildren(fragment);
855
+ restoreRenderState(renderState);
604
856
  };
605
- const actionPayload = (event, element) => {
606
- const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event: event.type };
857
+ const actionPayload = (event, element, eventType = event.type) => {
858
+ const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event: eventType };
607
859
  const target = event.target;
608
860
  if (target && typeof target.value === "string") payload.value = target.value.slice(0, maxTextLength);
609
861
  if (target && typeof target.checked === "boolean") payload.checked = target.checked;
610
- if (event.type === "submit" && event.target instanceof HTMLFormElement) {
862
+ if (eventType === "submit" && element.tagName === "FORM") {
611
863
  const values = {};
612
864
  let count = 0;
613
- for (const [name, value] of new FormData(event.target)) {
865
+ for (const [name, value] of new FormData(element)) {
614
866
  if (typeof value !== "string" || !validIdentifier(name) || count >= maxFormFields) continue;
615
867
  values[name] = value.slice(0, maxTextLength);
616
868
  count += 1;
@@ -621,16 +873,34 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
621
873
  };
622
874
  const handleAction = (event) => {
623
875
  if (!["click", "input", "change", "submit"].includes(event.type)) return;
624
- if (event.type === "submit") event.preventDefault();
625
876
  const origin = event.target instanceof Element ? event.target : null;
626
877
  const element = origin ? origin.closest("[data-redevplugin-action]") : null;
627
878
  if (!element || !document.body.contains(element)) return;
628
- event.preventDefault();
879
+ if (element.tagName === "FORM" && event.type !== "submit") {
880
+ const submitButton = origin ? origin.closest("button") : null;
881
+ const buttonType = submitButton && element.contains(submitButton) ? (submitButton.getAttribute("type") || "submit").toLowerCase() : "";
882
+ if (event.type === "click" && buttonType === "submit") {
883
+ event.preventDefault();
884
+ sendWorker(actionPayload(event, element, "submit"));
885
+ }
886
+ return;
887
+ }
888
+ if (event.type === "submit" || (event.type === "click" && element.tagName === "BUTTON")) event.preventDefault();
629
889
  const action = element.getAttribute("data-redevplugin-action");
630
890
  if (!validIdentifier(action)) return;
631
891
  sendWorker(actionPayload(event, element));
632
892
  };
633
893
  for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
894
+ document.addEventListener("keydown", (event) => {
895
+ if (event.key !== "Escape" || event.defaultPrevented || event.repeat) return;
896
+ const origin = event.target instanceof Element ? event.target : document.activeElement instanceof Element ? document.activeElement : null;
897
+ const owner = origin ? origin.closest("[data-redevplugin-escape-action]") : null;
898
+ const action = owner?.getAttribute("data-redevplugin-escape-action");
899
+ if (!owner || !document.body.contains(owner) || !validIdentifier(action)) return;
900
+ event.preventDefault();
901
+ event.stopPropagation();
902
+ sendWorker({ type: "redevplugin.ui.action", action, event: "escape" });
903
+ }, true);
634
904
  const pumpAssets = () => {
635
905
  while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
636
906
  const asset = queuedAssets.shift();
@@ -644,6 +914,129 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
644
914
  queuedAssets.push(...currentDocument.assets);
645
915
  pumpAssets();
646
916
  };
917
+ const failTransferRequest = (id, error) => {
918
+ pendingImageRequests.delete(id);
919
+ activeImageDecodes.delete(id);
920
+ const reservedPixels = imageReservations.get(id);
921
+ if (reservedPixels !== undefined) {
922
+ imageReservations.delete(id);
923
+ retainedImageCount = Math.max(0, retainedImageCount - 1);
924
+ retainedImagePixels = Math.max(0, retainedImagePixels - reservedPixels);
925
+ }
926
+ completeWorkerRequest(id);
927
+ sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_CONTRACT_MISMATCH", error: String(error).slice(0, 512) });
928
+ };
929
+ const imageType = (contentType) => String(contentType).split(";", 1)[0].trim().toLowerCase();
930
+ const bytesMatch = (bytes, offset, values) => values.every((value, index) => bytes[offset + index] === value);
931
+ const asciiMatches = (bytes, offset, value) => Array.from(value).every((character, index) => bytes[offset + index] === character.charCodeAt(0));
932
+ const detectRasterImageType = (bytes) => {
933
+ if (bytes.length >= 8 && bytesMatch(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
934
+ if (bytes.length >= 2 && bytesMatch(bytes, 0, [255, 216])) return "image/jpeg";
935
+ if (bytes.length >= 6 && (asciiMatches(bytes, 0, "GIF87a") || asciiMatches(bytes, 0, "GIF89a"))) return "image/gif";
936
+ if (bytes.length >= 12 && asciiMatches(bytes, 0, "RIFF") && asciiMatches(bytes, 8, "WEBP")) return "image/webp";
937
+ return "";
938
+ };
939
+ const uint16BE = (bytes, offset) => (bytes[offset] << 8) | bytes[offset + 1];
940
+ const uint16LE = (bytes, offset) => bytes[offset] | (bytes[offset + 1] << 8);
941
+ const uint24LE = (bytes, offset) => bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
942
+ const uint32BE = (bytes, offset) => ((bytes[offset] * 0x1000000) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3]) >>> 0;
943
+ const readImageDimensions = (bytes, contentType) => {
944
+ const type = imageType(contentType);
945
+ let width = 0;
946
+ let height = 0;
947
+ if (type === "image/png") {
948
+ if (bytes.length < 24 || !bytesMatch(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10]) || !asciiMatches(bytes, 12, "IHDR")) throw new Error("PNG dimensions are invalid");
949
+ width = uint32BE(bytes, 16);
950
+ height = uint32BE(bytes, 20);
951
+ } else if (type === "image/gif") {
952
+ if (bytes.length < 10 || (!asciiMatches(bytes, 0, "GIF87a") && !asciiMatches(bytes, 0, "GIF89a"))) throw new Error("GIF dimensions are invalid");
953
+ width = uint16LE(bytes, 6);
954
+ height = uint16LE(bytes, 8);
955
+ } else if (type === "image/jpeg") {
956
+ if (bytes.length < 4 || !bytesMatch(bytes, 0, [255, 216])) throw new Error("JPEG dimensions are invalid");
957
+ let offset = 2;
958
+ const startOfFrame = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
959
+ while (offset + 4 <= bytes.length) {
960
+ if (bytes[offset] !== 255) throw new Error("JPEG marker sequence is invalid");
961
+ while (offset < bytes.length && bytes[offset] === 255) offset += 1;
962
+ const marker = bytes[offset++];
963
+ if (marker === 217 || marker === 218) break;
964
+ if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
965
+ if (offset + 2 > bytes.length) break;
966
+ const length = uint16BE(bytes, offset);
967
+ if (length < 2 || offset + length > bytes.length) throw new Error("JPEG segment length is invalid");
968
+ if (startOfFrame.has(marker)) {
969
+ if (length < 7) throw new Error("JPEG frame dimensions are invalid");
970
+ height = uint16BE(bytes, offset + 3);
971
+ width = uint16BE(bytes, offset + 5);
972
+ break;
973
+ }
974
+ offset += length;
975
+ }
976
+ } else if (type === "image/webp") {
977
+ if (bytes.length < 30 || !asciiMatches(bytes, 0, "RIFF") || !asciiMatches(bytes, 8, "WEBP")) throw new Error("WebP dimensions are invalid");
978
+ if (asciiMatches(bytes, 12, "VP8X")) {
979
+ width = uint24LE(bytes, 24) + 1;
980
+ height = uint24LE(bytes, 27) + 1;
981
+ } else if (asciiMatches(bytes, 12, "VP8L") && bytes[20] === 47) {
982
+ width = 1 + bytes[21] + ((bytes[22] & 63) << 8);
983
+ height = 1 + (bytes[22] >> 6) + (bytes[23] << 2) + ((bytes[24] & 15) << 10);
984
+ } else if (asciiMatches(bytes, 12, "VP8 ") && bytesMatch(bytes, 23, [157, 1, 42])) {
985
+ width = uint16LE(bytes, 26) & 0x3fff;
986
+ height = uint16LE(bytes, 28) & 0x3fff;
987
+ }
988
+ } else {
989
+ throw new Error("plugin raster image type is unsupported");
990
+ }
991
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > maxImageDimension || height > maxImageDimension) {
992
+ throw new Error("decoded image dimensions exceed the renderer budget");
993
+ }
994
+ return { width, height, pixels: width * height };
995
+ };
996
+ const retainImagePixels = (pixels) => {
997
+ if (!Number.isSafeInteger(pixels) || pixels <= 0 || retainedImageCount >= maxImageCount || retainedImagePixels + pixels > maxImageTotalPixels) {
998
+ throw new Error("decoded image assets exceed the renderer pixel budget");
999
+ }
1000
+ retainedImageCount += 1;
1001
+ retainedImagePixels += pixels;
1002
+ };
1003
+ const deliverImageAsset = async (id, assetID, asset) => {
1004
+ if (!pendingImageRequests.has(id)) pendingImageRequests.set(id, { assetID, asset });
1005
+ const bytes = verifiedAssetBytes.get(asset.binding_id);
1006
+ if (!bytes) {
1007
+ return;
1008
+ }
1009
+ if (activeImageDecodes.has(id)) return;
1010
+ activeImageDecodes.add(id);
1011
+ let image;
1012
+ try {
1013
+ const rasterType = imageAssetTypes.get(asset.binding_id) || imageType(asset.content_type);
1014
+ const dimensions = imageAssetDimensions.get(asset.binding_id) || readImageDimensions(bytes, rasterType);
1015
+ retainImagePixels(dimensions.pixels);
1016
+ imageReservations.set(id, dimensions.pixels);
1017
+ image = await createImageBitmap(new Blob([bytes], { type: rasterType }));
1018
+ if (!pendingWorkerRequests.has(id) || !pendingImageRequests.has(id)) {
1019
+ image.close();
1020
+ const reservedPixels = imageReservations.get(id);
1021
+ if (reservedPixels !== undefined) {
1022
+ imageReservations.delete(id);
1023
+ retainedImageCount = Math.max(0, retainedImageCount - 1);
1024
+ retainedImagePixels = Math.max(0, retainedImagePixels - reservedPixels);
1025
+ }
1026
+ activeImageDecodes.delete(id);
1027
+ return;
1028
+ }
1029
+ if (!image || image.width !== dimensions.width || image.height !== dimensions.height) throw new Error("decoded image dimensions changed after validation");
1030
+ pendingImageRequests.delete(id);
1031
+ activeImageDecodes.delete(id);
1032
+ sendWorkerTransfer({ type: "redevplugin.ui.asset.image.ready", id, asset_id: assetID, image, width: image.width, height: image.height }, [image]);
1033
+ imageReservations.delete(id);
1034
+ completeWorkerRequest(id);
1035
+ } catch (error) {
1036
+ try { image && image.close(); } catch {}
1037
+ failTransferRequest(id, error && error.message || "plugin image asset could not be decoded");
1038
+ }
1039
+ };
647
1040
  const applyAsset = async (asset, contentBase64) => {
648
1041
  if (contentBase64.length !== Math.ceil(asset.size / 3) * 4) throw new Error("plugin asset encoded size mismatch");
649
1042
  const binary = atob(contentBase64);
@@ -653,7 +1046,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
653
1046
  const digestBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
654
1047
  const actualDigest = "sha256:" + Array.from(digestBytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
655
1048
  if (actualDigest !== asset.sha256) throw new Error("plugin asset bytes failed SHA-256 verification");
656
- const url = URL.createObjectURL(new Blob([bytes], { type: asset.content_type }));
1049
+ const declaredType = imageType(asset.content_type);
1050
+ const rasterType = detectRasterImageType(bytes);
1051
+ if (rasterType || (declaredType.startsWith("image/") && declaredType !== "image/svg+xml")) {
1052
+ const validatedType = rasterType || declaredType;
1053
+ const dimensions = readImageDimensions(bytes, validatedType);
1054
+ retainImagePixels(dimensions.pixels);
1055
+ imageAssetDimensions.set(asset.binding_id, dimensions);
1056
+ imageAssetTypes.set(asset.binding_id, validatedType);
1057
+ }
1058
+ verifiedAssetBytes.set(asset.binding_id, bytes);
1059
+ const url = URL.createObjectURL(new Blob([bytes], { type: rasterType || asset.content_type }));
657
1060
  blobURLs.add(url);
658
1061
  document.documentElement.style.setProperty("--redevplugin-asset-" + asset.binding_id, 'url("' + url.replaceAll('"', '%22') + '")');
659
1062
  for (const element of document.querySelectorAll('[data-redevplugin-asset-binding="' + asset.binding_id + '"]')) {
@@ -662,6 +1065,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
662
1065
  element.removeAttribute("data-redevplugin-asset-binding");
663
1066
  element.removeAttribute("data-redevplugin-asset-attr");
664
1067
  }
1068
+ for (const [id, pending] of pendingImageRequests) {
1069
+ if (pending.asset.binding_id === asset.binding_id) void deliverImageAsset(id, pending.assetID, pending.asset);
1070
+ }
665
1071
  };
666
1072
  const workerRuntime = () => [
667
1073
  '{',
@@ -677,10 +1083,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
677
1083
  'const __rpControlPost = __rpControlPort.postMessage.bind(__rpControlPort);',
678
1084
  'const __rpControlStart = __rpControlPort.start.bind(__rpControlPort);',
679
1085
  'const __rpControlClose = __rpControlPort.close.bind(__rpControlPort);',
1086
+ 'const __rpControlAddEventListener = __rpControlPort.addEventListener.bind(__rpControlPort);',
680
1087
  'const __rpGetPrototypeOf = Object.getPrototypeOf;',
681
1088
  'const __rpGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;',
682
1089
  'const __rpDefineProperty = Object.defineProperty;',
683
1090
  'const __rpHasOwn = Object.prototype.hasOwnProperty;',
1091
+ 'const __rpObjectKeys = Object.keys;',
1092
+ 'const __rpApply = Reflect.apply;',
1093
+ 'const __rpNumberIsSafeInteger = Number.isSafeInteger.bind(Number);',
1094
+ 'const __rpOffscreenCanvas = globalThis.OffscreenCanvas;',
1095
+ 'const __rpCanvasPrototype = __rpOffscreenCanvas && __rpOffscreenCanvas.prototype;',
1096
+ 'const __rpTrackedCanvases = [];',
684
1097
  'const __rpBlocked = () => { throw new TypeError("API is unavailable in the ReDevPlugin worker sandbox"); };',
685
1098
  'const __rpSealDescriptor = (owner, name, value) => {',
686
1099
  ' const descriptor = __rpGetOwnPropertyDescriptor(owner, name);',
@@ -711,9 +1124,42 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
711
1124
  ' }',
712
1125
  ' if (!found) throw new TypeError("MessagePort method is unavailable: " + name);',
713
1126
  '};',
1127
+ 'const __rpTrackCanvas = (canvas) => {',
1128
+ ' for (let index = 0; index < __rpTrackedCanvases.length; index += 1) if (__rpTrackedCanvases[index] === canvas) return;',
1129
+ ' if (__rpTrackedCanvases.length >= ' + JSON.stringify(maxCanvasCount) + ') throw new RangeError("plugin canvas count exceeds the worker budget");',
1130
+ ' __rpTrackedCanvases.push(canvas);',
1131
+ '};',
1132
+ 'const __rpInstallCanvasBudget = () => {',
1133
+ ' if (!__rpCanvasPrototype) return;',
1134
+ ' const widthDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "width");',
1135
+ ' const heightDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "height");',
1136
+ ' const contextDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "getContext");',
1137
+ ' if (!widthDescriptor || !heightDescriptor || typeof widthDescriptor.get !== "function" || typeof widthDescriptor.set !== "function" || typeof heightDescriptor.get !== "function" || typeof heightDescriptor.set !== "function" || !contextDescriptor || typeof contextDescriptor.value !== "function") throw new TypeError("OffscreenCanvas descriptors are unavailable");',
1138
+ ' const readWidth = (canvas) => __rpApply(widthDescriptor.get, canvas, []);',
1139
+ ' const readHeight = (canvas) => __rpApply(heightDescriptor.get, canvas, []);',
1140
+ ' const validateResize = (canvas, axis, value) => {',
1141
+ ' if (!__rpNumberIsSafeInteger(value) || value <= 0 || value > ' + JSON.stringify(maxCanvasDimension) + ') throw new RangeError("plugin canvas resize exceeds the worker dimension budget");',
1142
+ ' __rpTrackCanvas(canvas);',
1143
+ ' let pixels = 0;',
1144
+ ' for (let index = 0; index < __rpTrackedCanvases.length; index += 1) {',
1145
+ ' const current = __rpTrackedCanvases[index];',
1146
+ ' const width = current === canvas && axis === "width" ? value : readWidth(current);',
1147
+ ' const height = current === canvas && axis === "height" ? value : readHeight(current);',
1148
+ ' pixels += width * height;',
1149
+ ' if (pixels > ' + JSON.stringify(maxCanvasTotalPixels) + ') throw new RangeError("plugin canvas resize exceeds the worker pixel budget");',
1150
+ ' }',
1151
+ ' };',
1152
+ ' __rpDefineProperty(__rpCanvasPrototype, "width", { configurable: false, enumerable: Boolean(widthDescriptor.enumerable), get() { return readWidth(this); }, set(value) { validateResize(this, "width", value); __rpApply(widthDescriptor.set, this, [value]); } });',
1153
+ ' __rpDefineProperty(__rpCanvasPrototype, "height", { configurable: false, enumerable: Boolean(heightDescriptor.enumerable), get() { return readHeight(this); }, set(value) { validateResize(this, "height", value); __rpApply(heightDescriptor.set, this, [value]); } });',
1154
+ ' __rpDefineProperty(__rpCanvasPrototype, "getContext", { configurable: false, enumerable: Boolean(contextDescriptor.enumerable), writable: false, value(type, ...options) { if (type !== "2d") throw new TypeError("only 2d canvas contexts are available"); __rpTrackCanvas(this); return __rpApply(contextDescriptor.value, this, [type, ...options]); } });',
1155
+ ' __rpSealDescriptor(__rpCanvasPrototype, "constructor", undefined);',
1156
+ ' __rpSealDescriptor(__rpCanvasPrototype, "convertToBlob", __rpBlocked);',
1157
+ ' __rpSealDescriptor(__rpCanvasPrototype, "transferToImageBitmap", __rpBlocked);',
1158
+ '};',
714
1159
  'try {',
715
1160
  ' for (const name of ["postMessage", "start", "close", "addEventListener", "removeEventListener"]) __rpSealMessagePortMethod(name);',
716
- ' for (const [name, value] of Object.entries({fetch:__rpBlocked,XMLHttpRequest:undefined,WebSocket:undefined,EventSource:undefined,WebTransport:undefined,Worker:undefined,SharedWorker:undefined,indexedDB:undefined,caches:undefined,RTCPeerConnection:undefined,webkitRTCPeerConnection:undefined,BroadcastChannel:undefined,importScripts:undefined,postMessage:undefined,eval:undefined,Function:undefined,Blob:undefined,File:undefined,FileReader:undefined,FileReaderSync:undefined})) __rpSealChain(globalThis, name, value);',
1161
+ ' __rpInstallCanvasBudget();',
1162
+ ' for (const [name, value] of Object.entries({fetch:__rpBlocked,XMLHttpRequest:undefined,WebSocket:undefined,EventSource:undefined,WebTransport:undefined,Worker:undefined,SharedWorker:undefined,indexedDB:undefined,caches:undefined,RTCPeerConnection:undefined,webkitRTCPeerConnection:undefined,BroadcastChannel:undefined,importScripts:undefined,postMessage:undefined,eval:undefined,Function:undefined,Blob:undefined,File:undefined,FileReader:undefined,FileReaderSync:undefined,OffscreenCanvas:undefined,ImageBitmap:undefined,createImageBitmap:undefined})) __rpSealChain(globalThis, name, value);',
717
1163
  ' for (const [name, value] of Object.entries({storage:undefined,sendBeacon:undefined,serviceWorker:undefined})) __rpSealChain(navigator, name, value);',
718
1164
  ' const __rpFunctionPrototypes = [__rpGetPrototypeOf(function() {}), __rpGetPrototypeOf(async function() {}), __rpGetPrototypeOf(function*() {}), __rpGetPrototypeOf(async function*() {})];',
719
1165
  ' for (const prototype of __rpFunctionPrototypes) __rpSealDescriptor(prototype, "constructor", undefined);',
@@ -738,6 +1184,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
738
1184
  'Object.defineProperty(globalThis, ' + JSON.stringify(workerGlobalKey) + ', { configurable: false, enumerable: false, writable: false, value: __rpBridge });',
739
1185
  '__rpControlStart();',
740
1186
  '__rpPort.start();',
1187
+ 'const __rpControlListener = (event) => {',
1188
+ ' const message = event.data;',
1189
+ ' if (!message || typeof message !== "object" || Array.isArray(message)) return;',
1190
+ ' const keys = __rpObjectKeys(message).sort();',
1191
+ ' if (keys.length !== 2 || keys[0] !== "ping_id" || keys[1] !== "type" || message.type !== "redevplugin.worker.ping" || typeof message.ping_id !== "string" || !/^ping_[1-9][0-9]{0,15}$/.test(message.ping_id)) return;',
1192
+ ' __rpControlPost({ type: "redevplugin.worker.pong", ping_id: message.ping_id });',
1193
+ '};',
1194
+ '__rpControlAddEventListener("message", __rpControlListener);',
741
1195
  'let __rpFailed = false;',
742
1196
  'const __rpReportFailure = (error) => { if (__rpFailed) return; __rpFailed = true; __rpControlPost({ type: "redevplugin.worker.error", error: String(error && error.message || error).slice(0, 512) }); };',
743
1197
  'addEventListener("error", (event) => __rpReportFailure(event.error || event.message || "plugin worker failed"), { capture: true });',
@@ -775,7 +1229,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
775
1229
  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));
776
1230
  const requestID = (value, expectedKind) => {
777
1231
  if (typeof value !== "string") return undefined;
778
- const match = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/.exec(value);
1232
+ const match = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
779
1233
  if (!match || match[1] !== expectedKind) return undefined;
780
1234
  const sequence = Number(match[2]);
781
1235
  return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
@@ -796,6 +1250,175 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
796
1250
  return true;
797
1251
  };
798
1252
  const rejectWorkerRequest = (id, error) => sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_INVALID_REQUEST", error });
1253
+ const findCanvas = (canvasID) => {
1254
+ for (const element of document.querySelectorAll("canvas[data-redevplugin-canvas]")) {
1255
+ if (element.getAttribute("data-redevplugin-canvas") === canvasID) return element;
1256
+ }
1257
+ return undefined;
1258
+ };
1259
+ const updateCanvasAccessibility = (id, canvasID, label, description) => {
1260
+ const runtime = canvasRuntimes.get(canvasID);
1261
+ if (!runtime || typeof label !== "string" || label.length < 1 || label.length > 256 || typeof description !== "string" || description.length > 1024) {
1262
+ completeWorkerRequest(id);
1263
+ return rejectWorkerRequest(id, "plugin canvas accessibility state is invalid");
1264
+ }
1265
+ if (!runtime.description) {
1266
+ const status = document.createElement("span");
1267
+ status.id = "redevplugin-canvas-description-" + canvasID.replace(/[^A-Za-z0-9_-]/g, "-");
1268
+ status.style.position = "absolute";
1269
+ status.style.width = "1px";
1270
+ status.style.height = "1px";
1271
+ status.style.padding = "0";
1272
+ status.style.margin = "-1px";
1273
+ status.style.overflow = "hidden";
1274
+ status.style.clip = "rect(0, 0, 0, 0)";
1275
+ status.style.whiteSpace = "nowrap";
1276
+ status.style.border = "0";
1277
+ runtime.canvas.insertAdjacentElement("afterend", status);
1278
+ runtime.canvas.setAttribute("aria-describedby", status.id);
1279
+ runtime.description = status;
1280
+ }
1281
+ runtime.canvas.setAttribute("aria-label", label);
1282
+ runtime.description.textContent = description;
1283
+ completeWorkerRequest(id);
1284
+ sendWorker({ type: "redevplugin.bridge.response", id, ok: true });
1285
+ };
1286
+ const canvasMetrics = (canvas) => {
1287
+ const rect = canvas.getBoundingClientRect();
1288
+ const cssWidth = Math.ceil(rect.width || canvas.width || 1);
1289
+ const cssHeight = Math.ceil(rect.height || canvas.height || 1);
1290
+ const devicePixelRatio = Math.min(4, Math.max(0.5, Number(globalThis.devicePixelRatio) || 1));
1291
+ if (!Number.isSafeInteger(cssWidth) || !Number.isSafeInteger(cssHeight) || cssWidth <= 0 || cssHeight <= 0 || cssWidth > maxCanvasDimension || cssHeight > maxCanvasDimension) {
1292
+ throw new Error("plugin canvas dimensions exceed the renderer budget");
1293
+ }
1294
+ const pixelCount = Math.ceil(cssWidth * devicePixelRatio) * Math.ceil(cssHeight * devicePixelRatio);
1295
+ return { cssWidth, cssHeight, devicePixelRatio, pixelCount };
1296
+ };
1297
+ const openCanvas = (id, canvasID) => {
1298
+ const canvas = findCanvas(canvasID);
1299
+ if (!canvas || canvasRuntimes.has(canvasID) || typeof canvas.transferControlToOffscreen !== "function" || typeof ResizeObserver !== "function") {
1300
+ completeWorkerRequest(id);
1301
+ return rejectWorkerRequest(id, "plugin canvas is missing, already transferred, or unsupported");
1302
+ }
1303
+ if (canvas.tabIndex < 0) canvas.tabIndex = 0;
1304
+ let pointerWindowStartedAt = 0;
1305
+ let pointerEvents = 0;
1306
+ const listeners = [];
1307
+ const listen = (type, handler) => {
1308
+ canvas.addEventListener(type, handler, { passive: false });
1309
+ listeners.push([type, handler]);
1310
+ };
1311
+ const sendInput = (event) => sendWorker({ type: "redevplugin.ui.canvas.input", canvas_id: canvasID, event });
1312
+ const sendResize = () => {
1313
+ const metrics = canvasMetrics(canvas);
1314
+ const runtime = canvasRuntimes.get(canvasID);
1315
+ let totalPixels = metrics.pixelCount;
1316
+ for (const [identifier, active] of canvasRuntimes) {
1317
+ if (identifier !== canvasID) totalPixels += active.pixelCount;
1318
+ }
1319
+ if (totalPixels > maxCanvasTotalPixels) throw new Error("plugin canvases exceed the renderer pixel budget");
1320
+ if (runtime) runtime.pixelCount = metrics.pixelCount;
1321
+ sendInput({ type: "resize", css_width: metrics.cssWidth, css_height: metrics.cssHeight, device_pixel_ratio: metrics.devicePixelRatio });
1322
+ return metrics;
1323
+ };
1324
+ const handlePointer = (event) => {
1325
+ const now = performance.now();
1326
+ if (now - pointerWindowStartedAt >= 1000) { pointerWindowStartedAt = now; pointerEvents = 0; }
1327
+ if (event.type === "pointermove") {
1328
+ if (pointerEvents >= maxCanvasPointerEventsPerSecond) return;
1329
+ pointerEvents += 1;
1330
+ }
1331
+ event.preventDefault();
1332
+ if (event.type === "pointerdown") {
1333
+ try { canvas.focus({ preventScroll: true }); } catch { canvas.focus(); }
1334
+ try { canvas.setPointerCapture(event.pointerId); } catch {}
1335
+ }
1336
+ if (event.type === "pointerup" || event.type === "pointercancel") {
1337
+ try { canvas.releasePointerCapture(event.pointerId); } catch {}
1338
+ }
1339
+ const rect = canvas.getBoundingClientRect();
1340
+ sendInput({
1341
+ type: "pointer",
1342
+ event: event.type,
1343
+ pointer_id: Math.max(0, Number.isSafeInteger(event.pointerId) ? event.pointerId : 0),
1344
+ pointer_type: ["mouse", "pen", "touch"].includes(event.pointerType) ? event.pointerType : "unknown",
1345
+ buttons: Math.min(31, Math.max(0, Number.isSafeInteger(event.buttons) ? event.buttons : 0)),
1346
+ button: Math.min(4, Math.max(-1, Number.isSafeInteger(event.button) ? event.button : -1)),
1347
+ x: Math.min(32768, Math.max(-16384, event.clientX - rect.left)),
1348
+ y: Math.min(32768, Math.max(-16384, event.clientY - rect.top)),
1349
+ pressure: Math.min(1, Math.max(0, Number.isFinite(event.pressure) ? event.pressure : 0)),
1350
+ });
1351
+ };
1352
+ const handleKey = (event) => {
1353
+ event.preventDefault();
1354
+ sendInput({
1355
+ type: "key",
1356
+ event: event.type,
1357
+ code: String(event.code || "").slice(0, 64),
1358
+ key: String(event.key || "").slice(0, 64),
1359
+ repeat: Boolean(event.repeat),
1360
+ alt_key: Boolean(event.altKey),
1361
+ ctrl_key: Boolean(event.ctrlKey),
1362
+ meta_key: Boolean(event.metaKey),
1363
+ shift_key: Boolean(event.shiftKey),
1364
+ });
1365
+ };
1366
+ for (const type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) listen(type, handlePointer);
1367
+ for (const type of ["keydown", "keyup"]) listen(type, handleKey);
1368
+ listen("focus", () => sendInput({ type: "focus" }));
1369
+ listen("blur", () => sendInput({ type: "blur" }));
1370
+ const observer = new ResizeObserver(() => {
1371
+ try { sendResize(); }
1372
+ catch (error) { fail(error && error.message || "plugin canvas resize exceeded renderer limits"); }
1373
+ });
1374
+ observer.observe(canvas);
1375
+ const runtime = { canvas, description: undefined, dispose: undefined, pixelCount: 0 };
1376
+ const dispose = () => {
1377
+ observer.disconnect();
1378
+ for (const [type, handler] of listeners) canvas.removeEventListener(type, handler);
1379
+ runtime.description?.remove();
1380
+ };
1381
+ runtime.dispose = dispose;
1382
+ try {
1383
+ const offscreen = canvas.transferControlToOffscreen();
1384
+ const metrics = canvasMetrics(canvas);
1385
+ let totalPixels = metrics.pixelCount;
1386
+ for (const runtime of canvasRuntimes.values()) totalPixels += runtime.pixelCount;
1387
+ if (canvasRuntimes.size >= maxCanvasCount || totalPixels > maxCanvasTotalPixels) throw new Error("plugin canvas resources exceed renderer limits");
1388
+ runtime.pixelCount = metrics.pixelCount;
1389
+ canvasRuntimes.set(canvasID, runtime);
1390
+ completeWorkerRequest(id);
1391
+ sendWorkerTransfer({ type: "redevplugin.ui.canvas.ready", id, canvas_id: canvasID, canvas: offscreen, css_width: metrics.cssWidth, css_height: metrics.cssHeight, device_pixel_ratio: metrics.devicePixelRatio }, [offscreen]);
1392
+ sendResize();
1393
+ } catch (error) {
1394
+ dispose();
1395
+ completeWorkerRequest(id);
1396
+ rejectWorkerRequest(id, String(error && error.message || "plugin canvas transfer failed").slice(0, 512));
1397
+ }
1398
+ };
1399
+ const scheduleWorkerHeartbeat = () => {
1400
+ if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
1401
+ workerHeartbeatTimer = undefined;
1402
+ if (!workerReady || !workerControlPort || workerHeartbeatPendingID) return;
1403
+ workerHeartbeatTimer = setTimeout(() => {
1404
+ workerHeartbeatTimer = undefined;
1405
+ if (!workerReady || !workerControlPort || workerHeartbeatPendingID) return;
1406
+ const pingID = "ping_" + (++workerHeartbeatSequence);
1407
+ workerHeartbeatPendingID = pingID;
1408
+ try {
1409
+ workerControlPort.postMessage({ type: "redevplugin.worker.ping", ping_id: pingID });
1410
+ } catch {
1411
+ fail("plugin worker heartbeat could not be sent");
1412
+ return;
1413
+ }
1414
+ workerHeartbeatTimeout = setTimeout(() => {
1415
+ workerHeartbeatTimeout = undefined;
1416
+ if (workerHeartbeatPendingID !== pingID) return;
1417
+ workerHeartbeatPendingID = undefined;
1418
+ fail("plugin worker heartbeat timed out");
1419
+ }, workerHeartbeatTimeoutMs);
1420
+ }, workerHeartbeatIntervalMs);
1421
+ };
799
1422
  const onWorkerControlMessage = (event) => {
800
1423
  if (!withinLimit(event.data)) return;
801
1424
  const message = event.data;
@@ -803,6 +1426,14 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
803
1426
  if (workerReady) return;
804
1427
  workerReady = true;
805
1428
  sendParent({ type: "redevplugin.surface.worker_ready" });
1429
+ scheduleWorkerHeartbeat();
1430
+ return;
1431
+ }
1432
+ if (exactKeys(message, ["type", "ping_id"]) && message.type === "redevplugin.worker.pong" && message.ping_id === workerHeartbeatPendingID) {
1433
+ if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
1434
+ workerHeartbeatTimeout = undefined;
1435
+ workerHeartbeatPendingID = undefined;
1436
+ scheduleWorkerHeartbeat();
806
1437
  return;
807
1438
  }
808
1439
  if (exactKeys(message, ["type", "error"]) && message.type === "redevplugin.worker.error" && typeof message.error === "string") {
@@ -813,6 +1444,12 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
813
1444
  const onWorkerMessage = (event) => {
814
1445
  if (!withinLimit(event.data)) return;
815
1446
  const message = event.data;
1447
+ if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
1448
+ pendingQuiesceID = undefined;
1449
+ sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
1450
+ disposeRuntime();
1451
+ return;
1452
+ }
816
1453
  if (validCall(message)) {
817
1454
  if (!acceptWorkerRequest(message.request.id, "rpc")) return rejectWorkerRequest(message.request.id, "duplicate, replayed, or excessive plugin request");
818
1455
  sendParent(message);
@@ -830,6 +1467,26 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
830
1467
  sendParent(message);
831
1468
  return;
832
1469
  }
1470
+ if (exactKeys(message, ["type", "id", "canvas_id"]) && message.type === "redevplugin.ui.canvas.open" && typeof message.id === "string" && validResourceIdentifier(message.canvas_id)) {
1471
+ if (!acceptWorkerRequest(message.id, "canvas")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
1472
+ openCanvas(message.id, message.canvas_id);
1473
+ return;
1474
+ }
1475
+ if (exactKeys(message, ["type", "id", "canvas_id", "label", "description"]) && message.type === "redevplugin.ui.canvas.accessibility" && typeof message.id === "string" && validResourceIdentifier(message.canvas_id)) {
1476
+ if (!acceptWorkerRequest(message.id, "canvas")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
1477
+ updateCanvasAccessibility(message.id, message.canvas_id, message.label, message.description);
1478
+ return;
1479
+ }
1480
+ if (exactKeys(message, ["type", "id", "asset_id"]) && message.type === "redevplugin.ui.asset.image.open" && typeof message.id === "string" && validResourceIdentifier(message.asset_id)) {
1481
+ if (!acceptWorkerRequest(message.id, "asset")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
1482
+ const asset = assetByLogicalID.get(message.asset_id);
1483
+ if (!asset || !String(asset.content_type).toLowerCase().startsWith("image/") || String(asset.content_type).toLowerCase() === "image/svg+xml") {
1484
+ completeWorkerRequest(message.id);
1485
+ return rejectWorkerRequest(message.id, "plugin image asset is not declared");
1486
+ }
1487
+ void deliverImageAsset(message.id, message.asset_id, asset);
1488
+ return;
1489
+ }
833
1490
  if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.render" && typeof message.id === "string") {
834
1491
  if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
835
1492
  if (!renderRateAllowed()) {
@@ -848,6 +1505,11 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
848
1505
  }
849
1506
  if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
850
1507
  if (!pendingWorkerRequests.has(message.id)) return rejectWorkerRequest(message.id, "plugin request is not pending");
1508
+ if (pendingImageRequests.has(message.id)) {
1509
+ pendingImageRequests.delete(message.id);
1510
+ completeWorkerRequest(message.id);
1511
+ return;
1512
+ }
851
1513
  completeWorkerRequest(message.id);
852
1514
  sendParent(message);
853
1515
  }
@@ -872,9 +1534,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
872
1534
  sendWorker(message);
873
1535
  return;
874
1536
  }
875
- if (message && message.type === "redevplugin.bridge.lifecycle" && isRecord(message.event) && ["ready", "visible", "hidden", "dispose"].includes(message.event.type)) {
1537
+ if (message && message.type === "redevplugin.bridge.lifecycle" && isRecord(message.event) && ["ready", "visible", "hidden", "dispose"].includes(message.event.type) && Object.keys(message).every((key) => ["type", "event", "quiesce_id"].includes(key)) && (message.quiesce_id === undefined || (message.event.type === "dispose" && validOpaqueHandle(message.quiesce_id, "quiesce")))) {
1538
+ pendingQuiesceID = message.quiesce_id;
876
1539
  sendWorker(message);
877
- if (message.event.type === "dispose") disposeRuntime();
1540
+ if (message.event.type === "dispose" && !message.quiesce_id) disposeRuntime();
878
1541
  return;
879
1542
  }
880
1543
  if (exactKeys(message, ["type", "request_id", "binding_id", "ok", "path", "sha256", "content_type", "content_base64"]) && message.type === "redevplugin.surface.asset.response" && typeof message.content_base64 === "string" && message.content_base64.length <= maxPrivateAssetBase64Length) {
@@ -944,8 +1607,10 @@ export class PluginSurfaceHost {
944
1607
  #transportIdle;
945
1608
  #port;
946
1609
  #openSignals;
1610
+ #quiesce;
947
1611
  #initialFrameLoad;
948
1612
  #frameLoaded = false;
1613
+ #closePromise;
949
1614
  #revokePromise;
950
1615
  #unregisterSurfaceScope;
951
1616
  #opened = false;
@@ -1085,7 +1750,22 @@ export class PluginSurfaceHost {
1085
1750
  this.#assertReady();
1086
1751
  this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
1087
1752
  }
1088
- async close() {
1753
+ close() {
1754
+ if (this.#disposed)
1755
+ return Promise.resolve();
1756
+ if (this.#closePromise)
1757
+ return this.#closePromise;
1758
+ this.#closePromise = this.#closeSurface();
1759
+ return this.#closePromise;
1760
+ }
1761
+ dispose() {
1762
+ if (this.#disposed)
1763
+ return;
1764
+ void this.#bestEffortRevokeSurface(true);
1765
+ this.#disposeLocal();
1766
+ }
1767
+ async #closeSurface() {
1768
+ await this.#quiesceSurface();
1089
1769
  if (this.#disposed)
1090
1770
  return;
1091
1771
  const revoke = this.#revokeSurface(false);
@@ -1097,12 +1777,6 @@ export class PluginSurfaceHost {
1097
1777
  throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
1098
1778
  }
1099
1779
  }
1100
- dispose() {
1101
- if (this.#disposed)
1102
- return;
1103
- void this.#bestEffortRevokeSurface(true);
1104
- this.#disposeLocal();
1105
- }
1106
1780
  #disposeLocal() {
1107
1781
  if (this.#disposed)
1108
1782
  return;
@@ -1123,6 +1797,8 @@ export class PluginSurfaceHost {
1123
1797
  this.#openSignals?.workerReady.reject(disposedError);
1124
1798
  this.#openSignals?.portAcknowledged.reject(disposedError);
1125
1799
  this.#initialFrameLoad?.reject(disposedError);
1800
+ this.#quiesce?.acknowledged.reject(disposedError);
1801
+ this.#quiesce = undefined;
1126
1802
  this.#gatewayToken = undefined;
1127
1803
  this.#leaseExpiresAtMs = 0;
1128
1804
  this.#assetSession = undefined;
@@ -1161,6 +1837,10 @@ export class PluginSurfaceHost {
1161
1837
  this.#openSignals?.workerReady.resolve();
1162
1838
  return;
1163
1839
  }
1840
+ if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
1841
+ this.#quiesce.acknowledged.resolve();
1842
+ return;
1843
+ }
1164
1844
  if (hasExactKeys(data, ["type", "error"]) && data.type === "redevplugin.surface.error" && typeof data.error === "string") {
1165
1845
  const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", data.error);
1166
1846
  await this.#failSurface(error);
@@ -1612,6 +2292,31 @@ export class PluginSurfaceHost {
1612
2292
  // The server may already have revoked the surface after a failed prepare.
1613
2293
  }
1614
2294
  }
2295
+ async #quiesceSurface() {
2296
+ if (!this.#ready || !this.#port || this.#quiesce)
2297
+ return;
2298
+ const quiesce = {
2299
+ id: randomOpaqueHandle("quiesce"),
2300
+ acknowledged: deferred(),
2301
+ };
2302
+ void quiesce.acknowledged.promise.catch(() => undefined);
2303
+ this.#quiesce = quiesce;
2304
+ try {
2305
+ this.#postToRenderer({
2306
+ type: "redevplugin.bridge.lifecycle",
2307
+ event: { type: "dispose" },
2308
+ quiesce_id: quiesce.id,
2309
+ });
2310
+ await withTimeout(quiesce.acknowledged.promise, Math.min(this.#requestTimeoutMs, maxSurfaceQuiesceMs), "Plugin surface quiesce timed out");
2311
+ }
2312
+ catch {
2313
+ // A non-responsive plugin cannot block session revocation and iframe teardown.
2314
+ }
2315
+ finally {
2316
+ if (this.#quiesce === quiesce)
2317
+ this.#quiesce = undefined;
2318
+ }
2319
+ }
1615
2320
  async #failSurface(error) {
1616
2321
  if (this.#disposed)
1617
2322
  return;
@@ -1624,7 +2329,12 @@ export class PluginSurfaceHost {
1624
2329
  await revoke;
1625
2330
  }
1626
2331
  #postResponse(id, data) {
1627
- this.#postToRenderer({ type: "redevplugin.bridge.response", id, ok: true, data });
2332
+ const response = removeUndefined({ type: "redevplugin.bridge.response", id, ok: true, data });
2333
+ if (!messageWithinLimit(response)) {
2334
+ this.#postError(id, "PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin response exceeds the bridge message limit");
2335
+ return;
2336
+ }
2337
+ this.#postToRenderer(response);
1628
2338
  }
1629
2339
  #postError(id, errorCode, error, details) {
1630
2340
  const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
@@ -1832,13 +2542,18 @@ function isOpaqueSurfaceDocument(value) {
1832
2542
  if (!Array.isArray(value.assets) || value.assets.length > maxOpaqueSurfaceLazyAssets)
1833
2543
  return false;
1834
2544
  let lazyBytes = 0;
2545
+ const logicalIDs = new Set();
1835
2546
  for (const asset of value.assets) {
1836
- if (!hasExactKeys(asset, ["binding_id", "path", "sha256", "size", "content_type"]) ||
2547
+ if (!hasExactKeys(asset, ["binding_id", "logical_ids", "path", "sha256", "size", "content_type"]) ||
1837
2548
  !validOpaqueHandle(asset.binding_id, "asset") || !validPackagePath(asset.path) || !validSHA256(asset.sha256) ||
2549
+ !Array.isArray(asset.logical_ids) || asset.logical_ids.length < 1 || asset.logical_ids.length > 16 ||
2550
+ asset.logical_ids.some((logicalID) => !validUIIdentifier(logicalID) || logicalIDs.has(logicalID)) ||
1838
2551
  !Number.isSafeInteger(asset.size) || Number(asset.size) < 0 || Number(asset.size) > maxOpaqueSurfaceLazyBytes ||
1839
2552
  typeof asset.content_type !== "string" || asset.content_type.length < 1 || asset.content_type.length > 256) {
1840
2553
  return false;
1841
2554
  }
2555
+ for (const logicalID of asset.logical_ids)
2556
+ logicalIDs.add(logicalID);
1842
2557
  lazyBytes += Number(asset.size);
1843
2558
  if (!Number.isSafeInteger(lazyBytes) || lazyBytes > maxOpaqueSurfaceLazyBytes)
1844
2559
  return false;
@@ -1905,6 +2620,8 @@ function isBridgeResponse(value) {
1905
2620
  }
1906
2621
  if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
1907
2622
  return isCapabilityBusinessErrorDetails(value.error_details);
2623
+ if (value.error_code === "PLUGIN_WORKER_ERROR")
2624
+ return isWorkerErrorDetails(value.error_details);
1908
2625
  if (value.error_details === undefined)
1909
2626
  return true;
1910
2627
  try {
@@ -1914,6 +2631,17 @@ function isBridgeResponse(value) {
1914
2631
  return false;
1915
2632
  }
1916
2633
  }
2634
+ function validCanvasAccessibilityState(value) {
2635
+ return hasExactKeys(value, ["label", "description"]) &&
2636
+ typeof value.label === "string" && value.label.length > 0 && value.label.length <= 256 &&
2637
+ typeof value.description === "string" && value.description.length <= 1024;
2638
+ }
2639
+ function isWorkerErrorDetails(value) {
2640
+ return hasExactKeys(value, ["worker_error_code", "worker_error_message", "worker_error_origin"]) &&
2641
+ typeof value.worker_error_code === "string" && businessErrorCodePattern.test(value.worker_error_code) &&
2642
+ typeof value.worker_error_message === "string" && value.worker_error_message.length > 0 && value.worker_error_message.length <= 4096 &&
2643
+ (value.worker_error_origin === "runtime" || value.worker_error_origin === "hostcall" || value.worker_error_origin === "plugin");
2644
+ }
1917
2645
  function isCapabilityBusinessErrorDetails(value) {
1918
2646
  if (!hasAllowedKeys(value, [
1919
2647
  "capability_id",
@@ -1940,20 +2668,117 @@ function isCapabilityBusinessErrorDetails(value) {
1940
2668
  }
1941
2669
  }
1942
2670
  function isLifecycleMessage(value) {
1943
- return hasExactKeys(value, ["type", "event"]) &&
2671
+ return hasAllowedKeys(value, ["type", "event", "quiesce_id"]) &&
1944
2672
  value.type === "redevplugin.bridge.lifecycle" &&
1945
2673
  hasExactKeys(value.event, ["type"]) &&
1946
- (value.event.type === "ready" || value.event.type === "visible" || value.event.type === "hidden" || value.event.type === "dispose");
2674
+ (value.event.type === "ready" || value.event.type === "visible" || value.event.type === "hidden" || value.event.type === "dispose") &&
2675
+ (value.quiesce_id === undefined || (value.event.type === "dispose" && validOpaqueHandle(value.quiesce_id, "quiesce")));
1947
2676
  }
1948
2677
  function isActionMessage(value) {
1949
2678
  return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
1950
2679
  value.type === "redevplugin.ui.action" &&
1951
2680
  validActionID(value.action) &&
1952
- (value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit") &&
2681
+ (value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit" || value.event === "escape") &&
1953
2682
  (value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
1954
2683
  (value.checked == null || typeof value.checked === "boolean") &&
1955
2684
  (value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
1956
2685
  }
2686
+ function isCanvasReadyCandidate(value) {
2687
+ return isRecord(value) && value.type === "redevplugin.ui.canvas.ready" && typeof value.id === "string" && typeof value.canvas_id === "string";
2688
+ }
2689
+ function isCanvasReadyMessage(value) {
2690
+ return hasExactKeys(value, ["type", "id", "canvas_id", "canvas", "css_width", "css_height", "device_pixel_ratio"]) &&
2691
+ value.type === "redevplugin.ui.canvas.ready" && validBridgeRequestID(value.id, "canvas") && validUIIdentifier(value.canvas_id) &&
2692
+ isOffscreenCanvasLike(value.canvas) && validSurfaceDimension(value.css_width) && validSurfaceDimension(value.css_height) &&
2693
+ validDevicePixelRatio(value.device_pixel_ratio);
2694
+ }
2695
+ function isImageReadyCandidate(value) {
2696
+ return isRecord(value) && value.type === "redevplugin.ui.asset.image.ready" && typeof value.id === "string" && typeof value.asset_id === "string";
2697
+ }
2698
+ function isImageReadyMessage(value) {
2699
+ return hasExactKeys(value, ["type", "id", "asset_id", "image", "width", "height"]) &&
2700
+ value.type === "redevplugin.ui.asset.image.ready" && validBridgeRequestID(value.id, "asset") && validUIIdentifier(value.asset_id) &&
2701
+ isImageBitmapLike(value.image) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_image_dimension &&
2702
+ Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_image_dimension &&
2703
+ value.image.width === value.width && value.image.height === value.height;
2704
+ }
2705
+ function publicCanvasInputMessage(value) {
2706
+ if (!hasExactKeys(value, ["type", "canvas_id", "event"]) || value.type !== "redevplugin.ui.canvas.input" ||
2707
+ !validUIIdentifier(value.canvas_id) || !isRecord(value.event))
2708
+ return undefined;
2709
+ const event = value.event;
2710
+ if (hasExactKeys(event, ["type"]) && (event.type === "focus" || event.type === "blur")) {
2711
+ return { canvasId: value.canvas_id, event: { type: event.type } };
2712
+ }
2713
+ if (hasExactKeys(event, ["type", "css_width", "css_height", "device_pixel_ratio"]) && event.type === "resize" &&
2714
+ validSurfaceDimension(event.css_width) && validSurfaceDimension(event.css_height) && validDevicePixelRatio(event.device_pixel_ratio)) {
2715
+ return {
2716
+ canvasId: value.canvas_id,
2717
+ event: { type: "resize", cssWidth: event.css_width, cssHeight: event.css_height, devicePixelRatio: event.device_pixel_ratio },
2718
+ };
2719
+ }
2720
+ if (hasExactKeys(event, ["type", "event", "code", "key", "repeat", "alt_key", "ctrl_key", "meta_key", "shift_key"]) &&
2721
+ event.type === "key" && (event.event === "keydown" || event.event === "keyup") &&
2722
+ typeof event.code === "string" && event.code.length <= 64 && typeof event.key === "string" && event.key.length <= 64 &&
2723
+ typeof event.repeat === "boolean" && typeof event.alt_key === "boolean" && typeof event.ctrl_key === "boolean" &&
2724
+ typeof event.meta_key === "boolean" && typeof event.shift_key === "boolean") {
2725
+ return {
2726
+ canvasId: value.canvas_id,
2727
+ event: {
2728
+ type: "key",
2729
+ event: event.event,
2730
+ code: event.code,
2731
+ key: event.key,
2732
+ repeat: event.repeat,
2733
+ altKey: event.alt_key,
2734
+ ctrlKey: event.ctrl_key,
2735
+ metaKey: event.meta_key,
2736
+ shiftKey: event.shift_key,
2737
+ },
2738
+ };
2739
+ }
2740
+ if (hasExactKeys(event, ["type", "event", "pointer_id", "pointer_type", "buttons", "button", "x", "y", "pressure"]) &&
2741
+ event.type === "pointer" && ["pointerdown", "pointermove", "pointerup", "pointercancel"].includes(String(event.event)) &&
2742
+ Number.isInteger(event.pointer_id) && Number(event.pointer_id) >= 0 &&
2743
+ ["mouse", "pen", "touch", "unknown"].includes(String(event.pointer_type)) &&
2744
+ Number.isInteger(event.buttons) && Number(event.buttons) >= 0 && Number(event.buttons) <= 31 &&
2745
+ Number.isInteger(event.button) && Number(event.button) >= -1 && Number(event.button) <= 4 &&
2746
+ validCanvasCoordinate(event.x) && validCanvasCoordinate(event.y) &&
2747
+ typeof event.pressure === "number" && Number.isFinite(event.pressure) && event.pressure >= 0 && event.pressure <= 1) {
2748
+ return {
2749
+ canvasId: value.canvas_id,
2750
+ event: {
2751
+ type: "pointer",
2752
+ event: event.event,
2753
+ pointerId: Number(event.pointer_id),
2754
+ pointerType: event.pointer_type,
2755
+ buttons: Number(event.buttons),
2756
+ button: Number(event.button),
2757
+ x: event.x,
2758
+ y: event.y,
2759
+ pressure: event.pressure,
2760
+ },
2761
+ };
2762
+ }
2763
+ return undefined;
2764
+ }
2765
+ function isOffscreenCanvasLike(value) {
2766
+ return isRecord(value) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_canvas_dimension &&
2767
+ Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_canvas_dimension && typeof value.getContext === "function";
2768
+ }
2769
+ function isImageBitmapLike(value) {
2770
+ return isRecord(value) && Number.isInteger(value.width) && Number(value.width) > 0 && Number(value.width) <= opaqueSurfaceRenderLimits.max_image_dimension &&
2771
+ Number.isInteger(value.height) && Number(value.height) > 0 && Number(value.height) <= opaqueSurfaceRenderLimits.max_image_dimension && typeof value.close === "function";
2772
+ }
2773
+ function validSurfaceDimension(value) {
2774
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= opaqueSurfaceRenderLimits.max_canvas_dimension;
2775
+ }
2776
+ function validDevicePixelRatio(value) {
2777
+ return typeof value === "number" && Number.isFinite(value) && value >= 0.5 && value <= 4;
2778
+ }
2779
+ function validCanvasCoordinate(value) {
2780
+ return typeof value === "number" && Number.isFinite(value) && value >= -16384 && value <= 32768;
2781
+ }
1957
2782
  function isMessagePortLike(value) {
1958
2783
  return isRecord(value) &&
1959
2784
  typeof value.postMessage === "function" &&
@@ -2033,8 +2858,9 @@ function validRPCParams(value) {
2033
2858
  }
2034
2859
  const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
2035
2860
  const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
2861
+ const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
2036
2862
  const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
2037
- const bridgeRequestIDPattern = /^(rpc|stream|render|operation)_([1-9][0-9]{0,15})$/;
2863
+ const bridgeRequestIDPattern = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
2038
2864
  function validBridgeRequestID(value, expectedKind) {
2039
2865
  if (typeof value !== "string")
2040
2866
  return false;
@@ -2049,6 +2875,9 @@ function validMethod(value) {
2049
2875
  function validActionID(value) {
2050
2876
  return typeof value === "string" && pluginActionPattern.test(value);
2051
2877
  }
2878
+ function validUIIdentifier(value) {
2879
+ return typeof value === "string" && pluginUIIdentifierPattern.test(value);
2880
+ }
2052
2881
  function validOpaqueHandle(value, prefix) {
2053
2882
  return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
2054
2883
  }