@floegence/redevplugin-ui 3.0.24 → 3.0.25

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.
@@ -1,6 +1,6 @@
1
1
  import { type FetchLike } from "./http.js";
2
2
  import type { components } from "./openapi.gen.js";
3
- import { type PluginConfirmationHandler, type PluginSurfaceHost, type PluginSurfaceContext, type PluginSurfaceOpeningProgress, type PluginSurfaceInteractionEvent, type PluginSurfaceReloadLimiter, type PluginSurfaceSlot, type PluginMethodResult, type ReDevPluginSurfaceTransport } from "./surface.js";
3
+ import { type PluginConfirmationHandler, type PluginSurfaceHost, type PluginSurfaceContext, type PluginSurfaceOpeningProgress, type PluginSurfaceInteractionEvent, type PluginSurfaceFileExportHandler, type PluginSurfaceReloadLimiter, type PluginSurfaceSlot, type PluginMethodResult, type ReDevPluginSurfaceTransport } from "./surface.js";
4
4
  import { type PluginSurfaceScope } from "./surface-scope.js";
5
5
  export type PluginPlatformClientOptions = {
6
6
  fetch?: FetchLike;
@@ -46,6 +46,7 @@ export type PluginOpenSurfaceInSlotOptions = PluginRequestOptions & {
46
46
  confirm?: PluginConfirmationHandler;
47
47
  onOpeningProgress?: (progress: PluginSurfaceOpeningProgress) => void;
48
48
  onInteraction?: (event: PluginSurfaceInteractionEvent) => void;
49
+ onFileExport?: PluginSurfaceFileExportHandler;
49
50
  onError?: (error: import("./errors.js").PluginBridgeError) => void;
50
51
  surfaceContext?: PluginSurfaceContext;
51
52
  };
package/dist/plugin.d.ts CHANGED
@@ -2,5 +2,5 @@ export { PluginBridgeClient } from "./surface.js";
2
2
  export { PluginBridgeError } from "./errors.js";
3
3
  export { pluginAPIMajor } from "./contracts.gen.js";
4
4
  export { callCapabilityOperation, callCapabilityStream, callCapabilitySync, isCapabilityBusinessError, } from "./capability-client.js";
5
- export type { BridgeLifecycleEvent, MessagePortLike, PluginBridgeClientOptions, PluginBridgeRequestOptions, PluginCanvasAccessibilityState, PluginCanvasInputEvent, PluginCanvasKeyEvent, PluginCanvasPointerEvent, PluginCanvasWheelEvent, PluginCanvasResizeEvent, PluginCanvasSurface, PluginSurfaceKeyboardBinding, PluginSurfaceKeyboardEvent, PluginSurfaceKeyboardTargetKind, PluginJSONObject, PluginJSONValue, PluginMethodResult, PluginExecutionFailureCode, PluginExecutionSnapshot, PluginExecutionProgress, PluginExecutionEvent, PluginExecutionEventList, PluginSurfaceContext, PluginUIActionEvent, PluginUIAttributeValue, PluginUIElementVNode, PluginUIPatchOperation, PluginUIVNode, } from "./surface.js";
5
+ export type { BridgeLifecycleEvent, MessagePortLike, PluginBridgeClientOptions, PluginBridgeRequestOptions, PluginCanvasAccessibilityState, PluginCanvasInputEvent, PluginCanvasKeyEvent, PluginCanvasPointerEvent, PluginCanvasWheelEvent, PluginCanvasResizeEvent, PluginCanvasSurface, PluginFileExportRequest, PluginSurfaceKeyboardBinding, PluginSurfaceKeyboardEvent, PluginSurfaceKeyboardTargetKind, PluginJSONObject, PluginJSONValue, PluginMethodResult, PluginExecutionFailureCode, PluginExecutionSnapshot, PluginExecutionProgress, PluginExecutionEvent, PluginExecutionEventList, PluginSurfaceContext, PluginSurfaceFileExportMediaType, PluginUIActionEvent, PluginUIAttributeValue, PluginUIElementVNode, PluginUIPatchOperation, PluginUIVNode, } from "./surface.js";
6
6
  export type { PluginCapabilitySchema, PluginCapabilityEffect, PluginCapabilityOperationContract, PluginCapabilityStreamContract, PluginCapabilitySyncContract, PluginCapabilityStreamEvent, PluginCapabilityStreamReadResult, PluginExecution, PluginExecutionTerminalStatus, PluginExecutionWaitOptions, PluginStream, } from "./capability-client.js";
package/dist/surface.d.ts CHANGED
@@ -101,6 +101,19 @@ export type PluginCanvasSurface = {
101
101
  cssHeight: number;
102
102
  devicePixelRatio: number;
103
103
  };
104
+ export declare const pluginSurfaceFileExportMaxBytes: number;
105
+ export declare const pluginSurfaceFileExportActionWindowMs = 30000;
106
+ export declare const pluginSurfaceFileExportMediaTypes: readonly ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "text/plain"];
107
+ export type PluginSurfaceFileExportMediaType = (typeof pluginSurfaceFileExportMediaTypes)[number];
108
+ export type PluginFileExportRequest = {
109
+ fileName: string;
110
+ mediaType: PluginSurfaceFileExportMediaType;
111
+ bytes: Uint8Array;
112
+ };
113
+ export type PluginSurfaceFileExport = Readonly<PluginFileExportRequest & {
114
+ signal: AbortSignal;
115
+ }>;
116
+ export type PluginSurfaceFileExportHandler = (request: PluginSurfaceFileExport) => Promise<void> | void;
104
117
  export type PluginCanvasAccessibilityState = {
105
118
  label: string;
106
119
  description: string;
@@ -336,6 +349,7 @@ export declare class PluginBridgeClient {
336
349
  setKeyboardBindings(bindings: readonly PluginSurfaceKeyboardBinding[]): Promise<void>;
337
350
  onKeyboardInput(handler: (event: PluginSurfaceKeyboardEvent) => void): () => void;
338
351
  loadImageAsset(assetId: string): Promise<ImageBitmap>;
352
+ exportFile(request: PluginFileExportRequest, options?: PluginBridgeRequestOptions): Promise<void>;
339
353
  onAction(action: string, handler: (event: PluginUIActionEvent) => void): () => void;
340
354
  onCanvasInput(canvasId: string, handler: (event: PluginCanvasInputEvent) => void): () => void;
341
355
  onLifecycle(handler: (event: BridgeLifecycleEvent) => Promise<void> | void): () => void;
@@ -483,6 +497,7 @@ export type PluginSurfaceHostOptions = {
483
497
  confirm?: PluginConfirmationHandler;
484
498
  onOpeningProgress?: (progress: PluginSurfaceOpeningProgress) => void;
485
499
  onInteraction?: (event: PluginSurfaceInteractionEvent) => void;
500
+ onFileExport?: PluginSurfaceFileExportHandler;
486
501
  onError?: (error: PluginBridgeError) => void;
487
502
  surfaceContext?: PluginSurfaceContext;
488
503
  };
package/dist/surface.js CHANGED
@@ -7,6 +7,15 @@ import { PluginUIReconcileError, reconcileValidatedPluginUITrees, validatePlugin
7
7
  export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v3";
8
8
  export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
9
9
  export const pluginSurfaceContextSchemaVersion = "redevplugin.surface_context.v1";
10
+ export const pluginSurfaceFileExportMaxBytes = 16 * 1024 * 1024;
11
+ export const pluginSurfaceFileExportActionWindowMs = 30_000;
12
+ export const pluginSurfaceFileExportMediaTypes = [
13
+ "image/png",
14
+ "image/jpeg",
15
+ "image/webp",
16
+ "image/svg+xml",
17
+ "text/plain",
18
+ ];
10
19
  const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridge";
11
20
  const maxPendingPluginBridgeRequests = 256;
12
21
  const maxPluginBridgeMessageBytes = opaqueSurfaceRenderLimits.max_message_bytes;
@@ -238,6 +247,23 @@ export class PluginBridgeClient {
238
247
  asset_id: assetId,
239
248
  }, { kind: "asset", identifier: assetId });
240
249
  }
250
+ exportFile(request, options = {}) {
251
+ this.#assertActive();
252
+ if (!validFileExportName(request?.fileName) || !validFileExportMediaType(request?.mediaType) ||
253
+ !(request?.bytes instanceof Uint8Array) || request.bytes.byteLength === 0 ||
254
+ request.bytes.byteLength > pluginSurfaceFileExportMaxBytes) {
255
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin file export request is invalid");
256
+ }
257
+ const id = this.#requestID("file");
258
+ const content = Uint8Array.from(request.bytes).buffer;
259
+ return this.#dispatchRequest(id, {
260
+ type: "redevplugin.ui.file.export",
261
+ id,
262
+ file_name: request.fileName,
263
+ media_type: request.mediaType,
264
+ content,
265
+ }, { mutation: true, signal: options.signal }, [content]);
266
+ }
241
267
  onAction(action, handler) {
242
268
  this.#assertActive();
243
269
  if (!validActionID(action) || typeof handler !== "function") {
@@ -377,6 +403,9 @@ export class PluginBridgeClient {
377
403
  if (new TextEncoder().encode(JSON.stringify(normalizedMessage)).byteLength > maxPluginBridgeMessageBytes) {
378
404
  throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge request exceeds the message size limit");
379
405
  }
406
+ return this.#dispatchRequest(id, normalizedMessage, options);
407
+ }
408
+ #dispatchRequest(id, message, options, transfer) {
380
409
  if (this.#pending.size >= maxPendingPluginBridgeRequests) {
381
410
  throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge has too many pending requests");
382
411
  }
@@ -425,7 +454,7 @@ export class PluginBridgeClient {
425
454
  if (!this.#pending.has(id))
426
455
  return result;
427
456
  try {
428
- this.#port.postMessage(normalizedMessage);
457
+ this.#port.postMessage(message, transfer);
429
458
  posted = true;
430
459
  }
431
460
  catch {
@@ -789,6 +818,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
789
818
  const maxImageCount = ${opaqueSurfaceRenderLimits.max_image_count};
790
819
  const maxImageDimension = ${opaqueSurfaceRenderLimits.max_image_dimension};
791
820
  const maxImageTotalPixels = ${opaqueSurfaceRenderLimits.max_image_total_pixels};
821
+ const maxFileExportBytes = ${pluginSurfaceFileExportMaxBytes};
822
+ const fileExportActionWindowMs = ${pluginSurfaceFileExportActionWindowMs};
823
+ const fileExportMediaTypes = new Set(${JSON.stringify(pluginSurfaceFileExportMediaTypes)});
792
824
  const workerHeartbeatIntervalMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_interval_ms};
793
825
  const workerHeartbeatTimeoutMs = ${opaqueSurfaceRenderLimits.worker_heartbeat_timeout_ms};
794
826
  const rendererCommitFallbackMs = 100;
@@ -842,6 +874,18 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
842
874
  const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
843
875
  const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
844
876
  const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
877
+ const validFileExportName = (value) => {
878
+ if (typeof value !== "string" || value.length === 0 || value.length > 128 || value.trim() !== value || value === "." || value === ".." || new TextEncoder().encode(value).byteLength > 255) return false;
879
+ for (let index = 0; index < value.length; index += 1) {
880
+ const code = value.charCodeAt(index);
881
+ if (code < 32 || code === 47 || code === 92 || code === 127) return false;
882
+ }
883
+ return true;
884
+ };
885
+ const validWorkerFileExport = (value) => exactKeys(value, ["type", "id", "file_name", "media_type", "content"]) &&
886
+ value.type === "redevplugin.ui.file.export" && typeof value.id === "string" && validFileExportName(value.file_name) &&
887
+ fileExportMediaTypes.has(value.media_type) && value.content instanceof ArrayBuffer &&
888
+ value.content.byteLength > 0 && value.content.byteLength <= maxFileExportBytes;
845
889
  const validAttribute = (tag, name, value) => {
846
890
  const lower = name.toLowerCase();
847
891
  if (lower.startsWith("on") || lower === "style" || lower === "src" || lower === "srcset" || lower === "href" || lower === "srcdoc" || lower === "action" || lower === "formaction") return false;
@@ -904,10 +948,11 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
904
948
  let workerHeartbeatTimer;
905
949
  let workerHeartbeatTimeout;
906
950
  let pendingQuiesceID;
951
+ let pendingFileExportAction;
907
952
  let runtimeDisposed = false;
908
953
  const pendingWorkerRequests = new Set();
909
954
  const pendingRendererCommits = new Set();
910
- const requestSequence = { rpc: 0, execution: 0, render: 0, canvas: 0, asset: 0, keyboard: 0 };
955
+ const requestSequence = { rpc: 0, execution: 0, render: 0, canvas: 0, asset: 0, keyboard: 0, file: 0 };
911
956
  let executionQuerySurfaceTokens = 8;
912
957
  let executionQuerySurfaceRefillAt = performance.now();
913
958
  const executionQueryStateIdleMS = 120000;
@@ -991,6 +1036,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
991
1036
  const sendParent = (message) => {
992
1037
  if (parentPort && withinLimit(message)) parentPort.postMessage(message);
993
1038
  };
1039
+ const sendParentTransfer = (message, transfer) => {
1040
+ if (parentPort) parentPort.postMessage(message, transfer);
1041
+ };
994
1042
  let interactionSequence = 0;
995
1043
  const interactionTargetKey = (target) => {
996
1044
  const element = target instanceof Element ? target.closest("[data-redevplugin-key]") : null;
@@ -1011,7 +1059,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1011
1059
  return false;
1012
1060
  };
1013
1061
  const sendInteraction = (kind, target, options = {}) => {
1014
- if (!initialized || pendingQuiesceID) return;
1062
+ if (!initialized || pendingQuiesceID) return 0;
1015
1063
  interactionSequence += 1;
1016
1064
  sendParent({
1017
1065
  type: "redevplugin.surface.interaction",
@@ -1024,6 +1072,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1024
1072
  local_scroll: options.localScroll === true,
1025
1073
  selection_active: options.selectionActive === true,
1026
1074
  });
1075
+ return interactionSequence;
1027
1076
  };
1028
1077
  document.addEventListener("pointerdown", (event) => sendInteraction("activation", event.target), true);
1029
1078
  document.addEventListener("focusin", (event) => sendInteraction("focus", event.target), true);
@@ -1701,6 +1750,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1701
1750
  const buttonType = submitButton && element.contains(submitButton) ? (submitButton.getAttribute("type") || "submit").toLowerCase() : "";
1702
1751
  if (event.type === "click" && buttonType === "submit") {
1703
1752
  event.preventDefault();
1753
+ const sequence = sendInteraction("action", event.target, { action: element.getAttribute("data-redevplugin-action") });
1754
+ if (sequence > 0) pendingFileExportAction = { sequence, expires_at: performance.now() + fileExportActionWindowMs };
1704
1755
  sendWorker(actionPayload(event, element, "submit"));
1705
1756
  }
1706
1757
  return;
@@ -1708,7 +1759,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1708
1759
  if (event.type === "submit" || (event.type === "click" && element.tagName === "BUTTON")) event.preventDefault();
1709
1760
  const action = element.getAttribute("data-redevplugin-action");
1710
1761
  if (!validIdentifier(action)) return;
1711
- sendInteraction("action", event.target, { action });
1762
+ const sequence = sendInteraction("action", event.target, { action });
1763
+ if (sequence > 0 && (event.type === "click" || event.type === "submit")) {
1764
+ pendingFileExportAction = { sequence, expires_at: performance.now() + fileExportActionWindowMs };
1765
+ }
1712
1766
  sendWorker(actionPayload(event, element));
1713
1767
  };
1714
1768
  for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
@@ -1994,7 +2048,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1994
2048
  ' const widthDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "width");',
1995
2049
  ' const heightDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "height");',
1996
2050
  ' const contextDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "getContext");',
1997
- ' 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");',
2051
+ ' const convertDescriptor = __rpGetOwnPropertyDescriptor(__rpCanvasPrototype, "convertToBlob");',
2052
+ ' if (!widthDescriptor || !heightDescriptor || typeof widthDescriptor.get !== "function" || typeof widthDescriptor.set !== "function" || typeof heightDescriptor.get !== "function" || typeof heightDescriptor.set !== "function" || !contextDescriptor || typeof contextDescriptor.value !== "function" || !convertDescriptor || typeof convertDescriptor.value !== "function") throw new TypeError("OffscreenCanvas descriptors are unavailable");',
1998
2053
  ' const readWidth = (canvas) => __rpApply(widthDescriptor.get, canvas, []);',
1999
2054
  ' const readHeight = (canvas) => __rpApply(heightDescriptor.get, canvas, []);',
2000
2055
  ' const validateResize = (canvas, axis, value) => {',
@@ -2013,7 +2068,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2013
2068
  ' __rpDefineProperty(__rpCanvasPrototype, "height", { configurable: false, enumerable: Boolean(heightDescriptor.enumerable), get() { return readHeight(this); }, set(value) { validateResize(this, "height", value); __rpApply(heightDescriptor.set, this, [value]); } });',
2014
2069
  ' __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]); } });',
2015
2070
  ' __rpSealDescriptor(__rpCanvasPrototype, "constructor", undefined);',
2016
- ' __rpSealDescriptor(__rpCanvasPrototype, "convertToBlob", __rpBlocked);',
2071
+ ' __rpSealDescriptor(__rpCanvasPrototype, "convertToBlob", convertDescriptor.value);',
2017
2072
  ' __rpSealDescriptor(__rpCanvasPrototype, "transferToImageBitmap", __rpBlocked);',
2018
2073
  '};',
2019
2074
  'try {',
@@ -2080,7 +2135,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2080
2135
  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));
2081
2136
  const requestID = (value, expectedKind) => {
2082
2137
  if (typeof value !== "string") return undefined;
2083
- const match = /^(rpc|execution|render|canvas|asset|keyboard)_([1-9][0-9]{0,15})$/.exec(value);
2138
+ const match = /^(rpc|execution|render|canvas|asset|keyboard|file)_([1-9][0-9]{0,15})$/.exec(value);
2084
2139
  if (!match || match[1] !== expectedKind) return undefined;
2085
2140
  const sequence = Number(match[2]);
2086
2141
  return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
@@ -2162,7 +2217,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2162
2217
  renderCount += 1;
2163
2218
  return true;
2164
2219
  };
2165
- const rejectWorkerRequest = (id, error) => sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_INVALID_REQUEST", error });
2220
+ const rejectWorkerRequest = (id, error, errorCode = "PLUGIN_INVALID_REQUEST") => sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: errorCode, error });
2166
2221
  const findCanvas = (canvasID) => {
2167
2222
  for (const element of document.querySelectorAll("canvas[data-redevplugin-canvas]")) {
2168
2223
  if (element.getAttribute("data-redevplugin-canvas") === canvasID) return element;
@@ -2372,8 +2427,30 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2372
2427
  }
2373
2428
  };
2374
2429
  const onWorkerMessage = (event) => {
2375
- if (!withinLimit(event.data)) return;
2376
2430
  const message = event.data;
2431
+ if (isRecord(message) && message.type === "redevplugin.ui.file.export" && typeof message.id === "string") {
2432
+ if (!validWorkerFileExport(message)) return rejectWorkerRequest(message.id, "plugin file export request is invalid");
2433
+ if (!acceptWorkerRequest(message.id, "file")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
2434
+ const action = pendingFileExportAction;
2435
+ if (!action || performance.now() > action.expires_at) {
2436
+ completeWorkerRequest(message.id);
2437
+ pendingFileExportAction = undefined;
2438
+ return rejectWorkerRequest(message.id, "plugin file export requires a recent user action", "PLUGIN_ACTION_DENIED");
2439
+ }
2440
+ pendingFileExportAction = undefined;
2441
+ sendParentTransfer({
2442
+ type: "redevplugin.surface.file.export",
2443
+ frame_generation_id: frameGenerationID,
2444
+ surface_handle: surfaceHandle,
2445
+ action_sequence: action.sequence,
2446
+ id: message.id,
2447
+ file_name: message.file_name,
2448
+ media_type: message.media_type,
2449
+ content: message.content,
2450
+ }, [message.content]);
2451
+ return;
2452
+ }
2453
+ if (!withinLimit(message)) return;
2377
2454
  if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
2378
2455
  pendingQuiesceID = undefined;
2379
2456
  sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
@@ -2567,8 +2644,10 @@ class PluginSurfaceHostImplementation {
2567
2644
  #confirm;
2568
2645
  #onOpeningProgress;
2569
2646
  #onInteraction;
2647
+ #onFileExport;
2570
2648
  #onError;
2571
2649
  #lastInteractionSequence = 0;
2650
+ #pendingFileExportAction;
2572
2651
  #interactionWindowStartedAt = 0;
2573
2652
  #interactionWindowCount = 0;
2574
2653
  #abortController = new AbortController();
@@ -2631,6 +2710,7 @@ class PluginSurfaceHostImplementation {
2631
2710
  this.#confirm = options.confirm;
2632
2711
  this.#onOpeningProgress = options.onOpeningProgress;
2633
2712
  this.#onInteraction = options.onInteraction;
2713
+ this.#onFileExport = options.onFileExport;
2634
2714
  this.#onError = options.onError;
2635
2715
  this.#surfaceContext = options.surfaceContext === undefined ? undefined : normalizePluginSurfaceContext(options.surfaceContext);
2636
2716
  hardenPluginSurfaceFrame(this.#iframe);
@@ -2835,6 +2915,7 @@ class PluginSurfaceHostImplementation {
2835
2915
  this.#assetSessionID = undefined;
2836
2916
  this.#document = undefined;
2837
2917
  this.#assets.clear();
2918
+ this.#pendingFileExportAction = undefined;
2838
2919
  if (this.#port) {
2839
2920
  try {
2840
2921
  this.#port.postMessage({ type: "redevplugin.bridge.lifecycle", event: { type: "dispose" } });
@@ -2859,10 +2940,16 @@ class PluginSurfaceHostImplementation {
2859
2940
  }
2860
2941
  }
2861
2942
  async #handlePortMessage(event) {
2862
- if (this.#disposed || !messageWithinLimit(event.data))
2943
+ if (this.#disposed)
2863
2944
  return;
2864
2945
  const data = event.data;
2865
2946
  try {
2947
+ if (isSurfaceFileExportCandidate(data)) {
2948
+ await this.#handleFileExport(data);
2949
+ return;
2950
+ }
2951
+ if (!messageWithinLimit(data))
2952
+ return;
2866
2953
  if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.first_paint") {
2867
2954
  this.#openSignals?.firstPaint.resolve();
2868
2955
  return;
@@ -2928,6 +3015,12 @@ class PluginSurfaceHostImplementation {
2928
3015
  return;
2929
3016
  this.#lastInteractionSequence = message.sequence;
2930
3017
  const now = performance.now();
3018
+ if (message.kind === "action") {
3019
+ this.#pendingFileExportAction = {
3020
+ sequence: message.sequence,
3021
+ expiresAt: now + pluginSurfaceFileExportActionWindowMs,
3022
+ };
3023
+ }
2931
3024
  if (now - this.#interactionWindowStartedAt >= 1000) {
2932
3025
  this.#interactionWindowStartedAt = now;
2933
3026
  this.#interactionWindowCount = 0;
@@ -2950,6 +3043,50 @@ class PluginSurfaceHostImplementation {
2950
3043
  // Interaction observers cannot alter the surface lifecycle.
2951
3044
  }
2952
3045
  }
3046
+ async #handleFileExport(message) {
3047
+ if (!validBridgeRequestID(message.id, "file"))
3048
+ return;
3049
+ if (!isSurfaceFileExportMessage(message)) {
3050
+ this.#postError(message.id, "PLUGIN_INVALID_REQUEST", "Plugin file export request is invalid");
3051
+ return;
3052
+ }
3053
+ if (!this.#bridgeReady || !this.#ready) {
3054
+ this.#postError(message.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin file export arrived before the surface became ready");
3055
+ return;
3056
+ }
3057
+ if (message.frame_generation_id !== this.frameGenerationId || message.surface_handle !== this.surfaceHandle)
3058
+ return;
3059
+ const action = this.#pendingFileExportAction;
3060
+ if (!action || action.sequence !== message.action_sequence || performance.now() > action.expiresAt) {
3061
+ this.#postError(message.id, "PLUGIN_ACTION_DENIED", "Plugin file export requires a recent user action");
3062
+ return;
3063
+ }
3064
+ this.#pendingFileExportAction = undefined;
3065
+ if (!this.#onFileExport) {
3066
+ this.#postError(message.id, "PLUGIN_FEATURE_NOT_CONFIGURED", "Plugin file export is not configured by the host");
3067
+ return;
3068
+ }
3069
+ const controller = this.#registerPendingRequest(message.id);
3070
+ try {
3071
+ await this.#onFileExport({
3072
+ fileName: message.file_name,
3073
+ mediaType: message.media_type,
3074
+ bytes: new Uint8Array(message.content),
3075
+ signal: controller.signal,
3076
+ });
3077
+ if (!controller.signal.aborted && !this.#disposed)
3078
+ this.#postResponse(message.id);
3079
+ }
3080
+ catch (error) {
3081
+ if (controller.signal.aborted || this.#disposed)
3082
+ return;
3083
+ const bridgeError = toBridgeError(error, "PLUGIN_ADAPTER_FAILURE");
3084
+ this.#postError(message.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
3085
+ }
3086
+ finally {
3087
+ this.#pendingRequestControllers.delete(message.id);
3088
+ }
3089
+ }
2953
3090
  async #handleCall(request) {
2954
3091
  if ((!this.#bridgeReady && !this.#quiesce) || !this.#gatewayToken) {
2955
3092
  this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
@@ -3878,6 +4015,18 @@ function setSurfacePresentation(element, state) {
3878
4015
  element.style.opacity = state === "opening" ? "0" : "";
3879
4016
  element.style.pointerEvents = ready ? "" : "none";
3880
4017
  }
4018
+ function isSurfaceFileExportCandidate(value) {
4019
+ return isRecord(value) && value.type === "redevplugin.surface.file.export" && typeof value.id === "string";
4020
+ }
4021
+ function isSurfaceFileExportMessage(value) {
4022
+ return hasExactKeys(value, [
4023
+ "type", "frame_generation_id", "surface_handle", "action_sequence", "id", "file_name", "media_type", "content",
4024
+ ]) && validOpaqueHandle(value.frame_generation_id, "frame") && validOpaqueHandle(value.surface_handle, "surface") &&
4025
+ Number.isSafeInteger(value.action_sequence) && Number(value.action_sequence) > 0 &&
4026
+ validBridgeRequestID(value.id, "file") && validFileExportName(value.file_name) &&
4027
+ validFileExportMediaType(value.media_type) && value.content instanceof ArrayBuffer &&
4028
+ value.content.byteLength > 0 && value.content.byteLength <= pluginSurfaceFileExportMaxBytes;
4029
+ }
3881
4030
  function isSurfaceInteractionMessage(value) {
3882
4031
  if (!hasExactKeys(value, [
3883
4032
  "type", "frame_generation_id", "surface_handle", "sequence", "kind", "target_key", "action", "local_scroll", "selection_active",
@@ -4553,7 +4702,7 @@ const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
4553
4702
  const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
4554
4703
  const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
4555
4704
  const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
4556
- const bridgeRequestIDPattern = /^(rpc|execution|render|canvas|asset|keyboard)_([1-9][0-9]{0,15})$/;
4705
+ const bridgeRequestIDPattern = /^(rpc|execution|render|canvas|asset|keyboard|file)_([1-9][0-9]{0,15})$/;
4557
4706
  function validBridgeRequestID(value, expectedKind) {
4558
4707
  if (typeof value !== "string")
4559
4708
  return false;
@@ -4599,6 +4748,20 @@ function validActionID(value) {
4599
4748
  function validUIIdentifier(value) {
4600
4749
  return typeof value === "string" && pluginUIIdentifierPattern.test(value);
4601
4750
  }
4751
+ function validFileExportName(value) {
4752
+ if (typeof value !== "string" || value.length === 0 || value.length > 128 || value.trim() !== value ||
4753
+ value === "." || value === ".." || new TextEncoder().encode(value).byteLength > 255)
4754
+ return false;
4755
+ for (let index = 0; index < value.length; index += 1) {
4756
+ const code = value.charCodeAt(index);
4757
+ if (code < 32 || code === 47 || code === 92 || code === 127)
4758
+ return false;
4759
+ }
4760
+ return true;
4761
+ }
4762
+ function validFileExportMediaType(value) {
4763
+ return pluginSurfaceFileExportMediaTypes.includes(value);
4764
+ }
4602
4765
  function validOpaqueHandle(value, prefix) {
4603
4766
  return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
4604
4767
  }
@@ -2,6 +2,6 @@ export * from "./contracts.gen.js";
2
2
  export * from "./errors.js";
3
3
  export * from "./platform.js";
4
4
  export { PluginSurfaceScope, createPluginSurfaceScope } from "./surface-scope.js";
5
- export { PluginSurfaceReloadLimiter, PluginSurfaceSlot, createReDevPluginSurfaceTransport, defaultPluginSurfaceReloadMax, defaultPluginSurfaceReloadWindowMs, isPluginRiskPlan, opaqueSurfaceDocumentSchemaVersion, pluginSurfaceContextSchemaVersion, pluginRiskPlanSchemaVersion, trustedParentBridgeHandshakeTranscriptSHA256, } from "./surface.js";
6
- export type { BridgeLifecycleEvent, MessageChannelLike, MessageEventLike, MessagePortLike, OpaqueSurfaceAsset, OpaqueSurfaceDocument, OpaqueSurfaceStyle, OpaqueSurfaceWorker, PluginConfirmationDecision, PluginConfirmationHandler, PluginConfirmationIntent, PluginConfirmationPlan, PluginMethodResult, PluginRiskEffect, PluginRiskFlag, PluginRiskPlan, PluginRiskSeverity, PluginExecutionEvent, PluginSurfaceHost, PluginSurfaceCloseResult, PluginSurfaceOpeningProgress, PluginSurfaceInteractionEvent, PluginSurfaceInteractionKind, PluginSurfaceQuiesceResult, PluginSurfaceRevocationReconciliation, PluginSurfacePreparationResult, PluginSurfaceContext, PluginSurfaceReloadDecision, PluginSurfaceReloadLimiterOptions, PluginSurfaceReloadState, PluginSurfaceSlotOptions, PluginSurfaceSlotState, ReDevPluginSurfaceTransport, ReDevPluginSurfaceTransportOptions, TrustedParentBridgeHandshake, TrustedParentBridgeTokenRequest, WindowLike, } from "./surface.js";
5
+ export { PluginSurfaceReloadLimiter, PluginSurfaceSlot, createReDevPluginSurfaceTransport, defaultPluginSurfaceReloadMax, defaultPluginSurfaceReloadWindowMs, isPluginRiskPlan, opaqueSurfaceDocumentSchemaVersion, pluginSurfaceContextSchemaVersion, pluginRiskPlanSchemaVersion, pluginSurfaceFileExportActionWindowMs, pluginSurfaceFileExportMaxBytes, pluginSurfaceFileExportMediaTypes, trustedParentBridgeHandshakeTranscriptSHA256, } from "./surface.js";
6
+ export type { BridgeLifecycleEvent, MessageChannelLike, MessageEventLike, MessagePortLike, OpaqueSurfaceAsset, OpaqueSurfaceDocument, OpaqueSurfaceStyle, OpaqueSurfaceWorker, PluginConfirmationDecision, PluginConfirmationHandler, PluginConfirmationIntent, PluginConfirmationPlan, PluginMethodResult, PluginRiskEffect, PluginRiskFlag, PluginRiskPlan, PluginRiskSeverity, PluginExecutionEvent, PluginSurfaceHost, PluginSurfaceCloseResult, PluginSurfaceOpeningProgress, PluginSurfaceInteractionEvent, PluginSurfaceInteractionKind, PluginSurfaceFileExport, PluginSurfaceFileExportHandler, PluginSurfaceFileExportMediaType, PluginSurfaceQuiesceResult, PluginSurfaceRevocationReconciliation, PluginSurfacePreparationResult, PluginSurfaceContext, PluginSurfaceReloadDecision, PluginSurfaceReloadLimiterOptions, PluginSurfaceReloadState, PluginSurfaceSlotOptions, PluginSurfaceSlotState, ReDevPluginSurfaceTransport, ReDevPluginSurfaceTransportOptions, TrustedParentBridgeHandshake, TrustedParentBridgeTokenRequest, WindowLike, } from "./surface.js";
7
7
  export type { FetchInitLike, FetchLike, FetchResponseLike, PlatformResponse } from "./http.js";
@@ -2,4 +2,4 @@ export * from "./contracts.gen.js";
2
2
  export * from "./errors.js";
3
3
  export * from "./platform.js";
4
4
  export { PluginSurfaceScope, createPluginSurfaceScope } from "./surface-scope.js";
5
- export { PluginSurfaceReloadLimiter, PluginSurfaceSlot, createReDevPluginSurfaceTransport, defaultPluginSurfaceReloadMax, defaultPluginSurfaceReloadWindowMs, isPluginRiskPlan, opaqueSurfaceDocumentSchemaVersion, pluginSurfaceContextSchemaVersion, pluginRiskPlanSchemaVersion, trustedParentBridgeHandshakeTranscriptSHA256, } from "./surface.js";
5
+ export { PluginSurfaceReloadLimiter, PluginSurfaceSlot, createReDevPluginSurfaceTransport, defaultPluginSurfaceReloadMax, defaultPluginSurfaceReloadWindowMs, isPluginRiskPlan, opaqueSurfaceDocumentSchemaVersion, pluginSurfaceContextSchemaVersion, pluginRiskPlanSchemaVersion, pluginSurfaceFileExportActionWindowMs, pluginSurfaceFileExportMaxBytes, pluginSurfaceFileExportMediaTypes, trustedParentBridgeHandshakeTranscriptSHA256, } from "./surface.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/redevplugin-ui",
3
- "version": "3.0.24",
3
+ "version": "3.0.25",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "dist"
41
41
  ],
42
42
  "dependencies": {
43
- "@floegence/redevplugin-contracts": "3.0.24"
43
+ "@floegence/redevplugin-contracts": "3.0.25"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"