@floegence/redevplugin-ui 0.6.16 → 0.6.18

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.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { PluginBridgeError, type PluginMutationOutcome } from "./errors.js";
2
- import { pluginUIProtocolVersion } from "./contracts.gen.js";
3
2
  import { type FetchLike } from "./http.js";
4
3
  import { type PluginSurfaceScope } from "./surface-scope.js";
5
4
  import { type PluginUIVNode } from "./ui-reconciler.js";
@@ -10,6 +9,7 @@ export type PluginJSONValue = null | boolean | number | string | PluginJSONValue
10
9
  export type PluginJSONObject = {
11
10
  [key: string]: PluginJSONValue;
12
11
  };
12
+ export type PluginUIProtocolVersion = "plugin-ui-v5" | "plugin-ui-v6";
13
13
  export type BridgeLifecycleEvent = {
14
14
  type: "ready";
15
15
  } | {
@@ -29,7 +29,7 @@ export type TrustedParentBridgeHandshake = {
29
29
  asset_session_nonce: string;
30
30
  management_revision: number;
31
31
  revoke_epoch: number;
32
- ui_protocol_version: typeof pluginUIProtocolVersion;
32
+ ui_protocol_version: PluginUIProtocolVersion;
33
33
  };
34
34
  export type TrustedParentBridgeTokenRequest = {
35
35
  bridge_channel_id: string;
@@ -154,6 +154,35 @@ export type PluginStreamReadResult = {
154
154
  terminal_status: PluginStreamTerminalStatus;
155
155
  retry_after_ms: 0;
156
156
  };
157
+ export type PluginExecutionFailureCode = "adapter_failed" | "contract_invalid" | "platform_failed" | "quota_exceeded" | "runtime_failed";
158
+ type PluginOperationSnapshotActive = {
159
+ operation_id: string;
160
+ status: "running" | "cancel_requested";
161
+ cancelable: boolean;
162
+ created_at: string;
163
+ updated_at: string;
164
+ retry_after_ms: number;
165
+ };
166
+ type PluginOperationSnapshotTerminal = {
167
+ operation_id: string;
168
+ status: "completed" | "canceled" | "orphaned_after_disable" | "orphaned_after_uninstall";
169
+ cancelable: boolean;
170
+ created_at: string;
171
+ updated_at: string;
172
+ retry_after_ms: number;
173
+ terminal_at: string;
174
+ };
175
+ type PluginOperationSnapshotFailed = {
176
+ operation_id: string;
177
+ status: "failed";
178
+ cancelable: boolean;
179
+ created_at: string;
180
+ updated_at: string;
181
+ retry_after_ms: number;
182
+ terminal_at: string;
183
+ failure_code: PluginExecutionFailureCode;
184
+ };
185
+ export type PluginOperationSnapshot = PluginOperationSnapshotActive | PluginOperationSnapshotTerminal | PluginOperationSnapshotFailed;
157
186
  export type PluginRiskSeverity = "info" | "low" | "medium" | "high" | "critical";
158
187
  export type PluginRiskEffect = "read" | "write" | "execute" | "delete" | "admin";
159
188
  export type PluginRiskFlag = {
@@ -237,6 +266,7 @@ export declare class PluginBridgeClient {
237
266
  call<T = unknown>(method: string, params?: PluginJSONObject, options?: PluginBridgeRequestOptions): Promise<T>;
238
267
  readStream(streamHandle: string, options?: PluginBridgeRequestOptions): Promise<PluginStreamReadResult>;
239
268
  cancelOperation(operationID: string, reason?: string, options?: PluginBridgeRequestOptions): Promise<void>;
269
+ operationSnapshot(operationID: string, options?: PluginBridgeRequestOptions): Promise<PluginOperationSnapshot>;
240
270
  render(tree: PluginUIVNode): Promise<void>;
241
271
  openCanvas(canvasId: string): Promise<PluginCanvasSurface>;
242
272
  updateCanvasAccessibility(canvasId: string, state: PluginCanvasAccessibilityState): Promise<void>;
@@ -333,6 +363,7 @@ export type PluginSurfaceHostBootstrap = {
333
363
  pluginId: string;
334
364
  pluginInstanceId: string;
335
365
  pluginVersion: string;
366
+ uiProtocolVersion: PluginUIProtocolVersion;
336
367
  surfaceId: string;
337
368
  surfaceInstanceId: string;
338
369
  activeFingerprint: string;
@@ -406,6 +437,7 @@ export type ReDevPluginSurfaceTransportOptions = {
406
437
  export declare function createReDevPluginSurfaceTransport(options?: ReDevPluginSurfaceTransportOptions): ReDevPluginSurfaceTransport;
407
438
  export type OpaquePluginBootstrapHTMLOptions = {
408
439
  scriptNonce?: string;
440
+ uiProtocolVersion?: PluginUIProtocolVersion;
409
441
  };
410
442
  export type PluginSurfaceQuiesceResult = {
411
443
  outcome: "acknowledged" | "not_ready" | "timed_out";
package/dist/surface.js CHANGED
@@ -171,6 +171,23 @@ export class PluginBridgeClient {
171
171
  reason,
172
172
  }), { mutation: true, signal: options.signal });
173
173
  }
174
+ operationSnapshot(operationID, options = {}) {
175
+ this.#assertActive();
176
+ if (!validOpaqueHandle(operationID, "operation")) {
177
+ throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin operation handle is invalid");
178
+ }
179
+ const id = this.#requestID("operation");
180
+ return this.#request(id, {
181
+ type: "redevplugin.bridge.operation.snapshot",
182
+ id,
183
+ operation_id: operationID,
184
+ }, { signal: options.signal }).then((snapshot) => {
185
+ if (!isPluginOperationSnapshot(snapshot) || snapshot.operation_id !== operationID) {
186
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin operation snapshot is invalid");
187
+ }
188
+ return snapshot;
189
+ });
190
+ }
174
191
  render(tree) {
175
192
  this.#assertActive();
176
193
  let validated;
@@ -714,6 +731,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
714
731
  if (!/^[A-Za-z0-9_-]{8,128}$/.test(scriptNonce)) {
715
732
  throw new Error("scriptNonce must contain 8-128 URL-safe characters");
716
733
  }
734
+ const uiProtocolVersion = options.uiProtocolVersion ?? pluginUIProtocolVersion;
735
+ if (uiProtocolVersion !== "plugin-ui-v5" && uiProtocolVersion !== "plugin-ui-v6") {
736
+ throw new Error("uiProtocolVersion must be plugin-ui-v5 or plugin-ui-v6");
737
+ }
717
738
  const csp = [
718
739
  "default-src 'none'",
719
740
  `script-src 'nonce-${scriptNonce}'`,
@@ -732,7 +753,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
732
753
  ].join("; ");
733
754
  const bootstrapScript = `(() => {
734
755
  "use strict";
735
- const protocolVersion = ${JSON.stringify(pluginUIProtocolVersion)};
756
+ const protocolVersion = ${JSON.stringify(uiProtocolVersion)};
736
757
  const documentSchema = ${JSON.stringify(opaqueSurfaceDocumentSchemaVersion)};
737
758
  const workerGlobalKey = ${JSON.stringify(opaquePluginBridgeGlobalKey)};
738
759
  const scriptNonce = ${JSON.stringify(scriptNonce)};
@@ -859,6 +880,13 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
859
880
  let pendingQuiesceID;
860
881
  const pendingWorkerRequests = new Set();
861
882
  const requestSequence = { rpc: 0, stream: 0, stream_ack: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
883
+ let operationSnapshotSurfaceTokens = 8;
884
+ let operationSnapshotSurfaceRefillAt = performance.now();
885
+ const operationSnapshotStateIdleMS = 120000;
886
+ let operationSnapshotNextPruneAt = performance.now() + 30000;
887
+ const maxOperationSnapshotStates = 1024;
888
+ const operationSnapshotStates = new Map();
889
+ const operationSnapshotRequests = new Map();
862
890
  let renderWindowStartedAt = 0;
863
891
  let renderCount = 0;
864
892
  let uiRevision = 0;
@@ -968,6 +996,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
968
996
  for (const url of blobURLs) URL.revokeObjectURL(url);
969
997
  blobURLs.clear();
970
998
  pendingWorkerRequests.clear();
999
+ operationSnapshotStates.clear();
1000
+ operationSnapshotRequests.clear();
971
1001
  pendingAssets.clear();
972
1002
  queuedAssets.length = 0;
973
1003
  activeAssetReads = 0;
@@ -1950,6 +1980,48 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1950
1980
  return true;
1951
1981
  };
1952
1982
  const completeWorkerRequest = (id) => pendingWorkerRequests.delete(id);
1983
+ const completeOperationSnapshotRequest = (id) => {
1984
+ const operationID = operationSnapshotRequests.get(id);
1985
+ operationSnapshotRequests.delete(id);
1986
+ const state = operationSnapshotStates.get(operationID);
1987
+ if (state) { state.inFlight = false; state.lastSeen = performance.now(); }
1988
+ completeWorkerRequest(id);
1989
+ };
1990
+ const refillSnapshotTokens = (tokens, refillAt, rate, burst, now) => ({
1991
+ tokens: Math.min(burst, tokens + Math.max(0, now - refillAt) * rate / 1000),
1992
+ refillAt: Math.max(refillAt, now),
1993
+ });
1994
+ const operationSnapshotRateLimit = (operationID, requestID) => {
1995
+ const now = performance.now();
1996
+ const surface = refillSnapshotTokens(operationSnapshotSurfaceTokens, operationSnapshotSurfaceRefillAt, 8, 8, now);
1997
+ operationSnapshotSurfaceTokens = surface.tokens;
1998
+ operationSnapshotSurfaceRefillAt = surface.refillAt;
1999
+ if (operationSnapshotSurfaceTokens < 1) return Math.max(500, Math.min(10000, Math.ceil((1 - operationSnapshotSurfaceTokens) / 8 * 1000)));
2000
+ operationSnapshotSurfaceTokens -= 1;
2001
+ let existing = operationSnapshotStates.get(operationID);
2002
+ if (!existing) {
2003
+ if (now >= operationSnapshotNextPruneAt) {
2004
+ for (const [retainedOperationID, state] of operationSnapshotStates) {
2005
+ if (!state.inFlight && now - state.lastSeen >= operationSnapshotStateIdleMS) operationSnapshotStates.delete(retainedOperationID);
2006
+ }
2007
+ operationSnapshotNextPruneAt = now + 30000;
2008
+ }
2009
+ if (operationSnapshotStates.size >= maxOperationSnapshotStates) return 10000;
2010
+ existing = { tokens: 2, refillAt: now, lastSeen: now, inFlight: false };
2011
+ operationSnapshotStates.set(operationID, existing);
2012
+ }
2013
+ const operation = refillSnapshotTokens(existing.tokens, existing.refillAt, 2, 2, now);
2014
+ existing.tokens = operation.tokens;
2015
+ existing.refillAt = operation.refillAt;
2016
+ existing.lastSeen = now;
2017
+ let retryAfterMS = existing.inFlight ? 500 : 0;
2018
+ if (existing.tokens < 1) retryAfterMS = Math.max(retryAfterMS, Math.ceil((1 - existing.tokens) / 2 * 1000));
2019
+ if (retryAfterMS > 0) return Math.max(500, Math.min(10000, retryAfterMS));
2020
+ existing.tokens -= 1;
2021
+ existing.inFlight = true;
2022
+ operationSnapshotRequests.set(requestID, operationID);
2023
+ return 0;
2024
+ };
1953
2025
  const renderRateAllowed = () => {
1954
2026
  const now = performance.now();
1955
2027
  if (now - renderWindowStartedAt >= 1000) { renderWindowStartedAt = now; renderCount = 0; }
@@ -2181,6 +2253,18 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2181
2253
  sendParent(message);
2182
2254
  return;
2183
2255
  }
2256
+ if (exactKeys(message, ["type", "id", "operation_id"]) && message.type === "redevplugin.bridge.operation.snapshot" &&
2257
+ typeof message.id === "string" && validOpaqueHandle(message.operation_id, "operation")) {
2258
+ if (protocolVersion !== "plugin-ui-v6") return rejectWorkerRequest(message.id, "plugin operation observation requires plugin-ui-v6");
2259
+ if (!acceptWorkerRequest(message.id, "operation")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
2260
+ const retryAfterMS = operationSnapshotRateLimit(message.operation_id, message.id);
2261
+ if (retryAfterMS > 0) {
2262
+ completeWorkerRequest(message.id);
2263
+ return sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: false, error_code: "PLUGIN_OPERATION_RATE_LIMITED", error: "plugin operation snapshot rate limited", error_details: { retry_after_ms: retryAfterMS } });
2264
+ }
2265
+ sendParent(message);
2266
+ return;
2267
+ }
2184
2268
  if (exactKeys(message, ["type", "id", "canvas_id"]) && message.type === "redevplugin.ui.canvas.open" && typeof message.id === "string" && validResourceIdentifier(message.canvas_id)) {
2185
2269
  if (!acceptWorkerRequest(message.id, "canvas")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
2186
2270
  openCanvas(message.id, message.canvas_id);
@@ -2234,8 +2318,10 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2234
2318
  completeWorkerRequest(message.id);
2235
2319
  return;
2236
2320
  }
2237
- completeWorkerRequest(message.id);
2321
+ if (operationSnapshotRequests.has(message.id)) completeOperationSnapshotRequest(message.id);
2322
+ else completeWorkerRequest(message.id);
2238
2323
  sendParent(message);
2324
+ return;
2239
2325
  }
2240
2326
  };
2241
2327
  const onParentMessage = async (event) => {
@@ -2254,7 +2340,8 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
2254
2340
  return;
2255
2341
  }
2256
2342
  if (message && message.type === "redevplugin.bridge.response" && typeof message.id === "string" && pendingWorkerRequests.has(message.id) && withinLimit(message)) {
2257
- completeWorkerRequest(message.id);
2343
+ if (operationSnapshotRequests.has(message.id)) completeOperationSnapshotRequest(message.id);
2344
+ else completeWorkerRequest(message.id);
2258
2345
  sendWorker(message);
2259
2346
  return;
2260
2347
  }
@@ -2414,7 +2501,7 @@ class PluginSurfaceHostImplementation {
2414
2501
  this.#initialFrameLoad = initialFrameLoad;
2415
2502
  this.#iframe.addEventListener("load", this.#onFrameLoad);
2416
2503
  hardenPluginSurfaceFrame(this.#iframe);
2417
- this.#iframe.srcdoc = createOpaquePluginBootstrapHTML({ scriptNonce });
2504
+ this.#iframe.srcdoc = createOpaquePluginBootstrapHTML({ scriptNonce, uiProtocolVersion: this.bootstrap.uiProtocolVersion });
2418
2505
  try {
2419
2506
  await withTimeout(this.#completeOpening(signals, initialFrameLoad.promise), this.#loadTimeoutMs, "Plugin surface opening timed out");
2420
2507
  }
@@ -2453,7 +2540,7 @@ class PluginSurfaceHostImplementation {
2453
2540
  iframeWindow.postMessage({
2454
2541
  type: "redevplugin.surface.port",
2455
2542
  frame_generation_id: this.frameGenerationId,
2456
- ui_protocol_version: pluginUIProtocolVersion,
2543
+ ui_protocol_version: this.bootstrap.uiProtocolVersion,
2457
2544
  }, "*", [channel.port2]);
2458
2545
  await signals.portAcknowledged.promise;
2459
2546
  this.#assertActive();
@@ -2645,6 +2732,10 @@ class PluginSurfaceHostImplementation {
2645
2732
  await this.#handleOperationCancel(data);
2646
2733
  return;
2647
2734
  }
2735
+ if (isOperationSnapshotMessage(data)) {
2736
+ await this.#handleOperationSnapshot(data);
2737
+ return;
2738
+ }
2648
2739
  if (isAssetReadMessage(data)) {
2649
2740
  await this.#handleAssetRead(data);
2650
2741
  }
@@ -2872,6 +2963,30 @@ class PluginSurfaceHostImplementation {
2872
2963
  this.#pendingRequestControllers.delete(message.id);
2873
2964
  }
2874
2965
  }
2966
+ async #handleOperationSnapshot(message) {
2967
+ if (this.bootstrap.uiProtocolVersion !== "plugin-ui-v6") {
2968
+ this.#postError(message.id, "PLUGIN_UI_PROTOCOL_UNSUPPORTED", "Plugin operation observation requires plugin-ui-v6");
2969
+ return;
2970
+ }
2971
+ const controller = this.#registerPendingRequest(message.id);
2972
+ try {
2973
+ const snapshot = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/operations/query`, { operation_id: message.operation_id, bridge_channel_id: this.bridgeChannelId }, controller.signal);
2974
+ if (!isPluginOperationSnapshot(snapshot) || snapshot.operation_id !== message.operation_id) {
2975
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin operation snapshot endpoint returned an invalid response");
2976
+ }
2977
+ if (!controller.signal.aborted && !this.#disposed)
2978
+ this.#postResponse(message.id, snapshot);
2979
+ }
2980
+ catch (error) {
2981
+ if (controller.signal.aborted || this.#disposed)
2982
+ return;
2983
+ const bridgeError = toBridgeError(error, "PLUGIN_OPERATION_NOT_FOUND");
2984
+ this.#postError(message.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
2985
+ }
2986
+ finally {
2987
+ this.#pendingRequestControllers.delete(message.id);
2988
+ }
2989
+ }
2875
2990
  async #handleAssetRead(message) {
2876
2991
  if (this.#activeAssetReads >= maxConcurrentAssetReads) {
2877
2992
  throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin asset reads exceed the concurrency limit");
@@ -3017,7 +3132,7 @@ class PluginSurfaceHostImplementation {
3017
3132
  asset_session_nonce: this.bootstrap.assetSessionNonce,
3018
3133
  management_revision: this.bootstrap.managementRevision,
3019
3134
  revoke_epoch: this.bootstrap.revokeEpoch,
3020
- ui_protocol_version: pluginUIProtocolVersion,
3135
+ ui_protocol_version: this.bootstrap.uiProtocolVersion,
3021
3136
  };
3022
3137
  }
3023
3138
  async #postJSON(path, body, signal) {
@@ -3830,6 +3945,7 @@ function validateHostBootstrap(bootstrap) {
3830
3945
  bootstrap.pluginId,
3831
3946
  bootstrap.pluginInstanceId,
3832
3947
  bootstrap.pluginVersion,
3948
+ bootstrap.uiProtocolVersion,
3833
3949
  bootstrap.surfaceId,
3834
3950
  bootstrap.surfaceInstanceId,
3835
3951
  bootstrap.activeFingerprint,
@@ -3844,6 +3960,9 @@ function validateHostBootstrap(bootstrap) {
3844
3960
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap is incomplete");
3845
3961
  }
3846
3962
  }
3963
+ if (bootstrap.uiProtocolVersion !== "plugin-ui-v5" && bootstrap.uiProtocolVersion !== "plugin-ui-v6") {
3964
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface UI protocol is unsupported");
3965
+ }
3847
3966
  if (!Number.isSafeInteger(bootstrap.managementRevision) || bootstrap.managementRevision < 1 ||
3848
3967
  !Number.isSafeInteger(bootstrap.revokeEpoch) || bootstrap.revokeEpoch < 1) {
3849
3968
  throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revision is invalid");
@@ -3965,6 +4084,29 @@ function isOperationCancelMessage(value) {
3965
4084
  validOpaqueHandle(value.operation_id, "operation") &&
3966
4085
  (value.reason === undefined || (typeof value.reason === "string" && value.reason.length <= 256));
3967
4086
  }
4087
+ function isOperationSnapshotMessage(value) {
4088
+ return hasExactKeys(value, ["type", "id", "operation_id"]) &&
4089
+ value.type === "redevplugin.bridge.operation.snapshot" &&
4090
+ validBridgeRequestID(value.id, "operation") &&
4091
+ validOpaqueHandle(value.operation_id, "operation");
4092
+ }
4093
+ function isPluginOperationSnapshot(value) {
4094
+ if (!isRecord(value) || !validOpaqueHandle(value.operation_id, "operation") || typeof value.cancelable !== "boolean" ||
4095
+ !Number.isInteger(value.retry_after_ms) || Number(value.retry_after_ms) < 500 || Number(value.retry_after_ms) > 10000 ||
4096
+ !validDateTime(value.created_at) || !validDateTime(value.updated_at))
4097
+ return false;
4098
+ const common = ["operation_id", "status", "cancelable", "created_at", "updated_at", "retry_after_ms"];
4099
+ if (value.status === "running" || value.status === "cancel_requested")
4100
+ return hasExactKeys(value, common);
4101
+ if (["completed", "canceled", "orphaned_after_disable", "orphaned_after_uninstall"].includes(String(value.status))) {
4102
+ return hasExactKeys(value, [...common, "terminal_at"]) && validDateTime(value.terminal_at);
4103
+ }
4104
+ return value.status === "failed" && hasExactKeys(value, [...common, "terminal_at", "failure_code"]) &&
4105
+ validDateTime(value.terminal_at) && ["adapter_failed", "contract_invalid", "platform_failed", "quota_exceeded", "runtime_failed"].includes(String(value.failure_code));
4106
+ }
4107
+ function validDateTime(value) {
4108
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
4109
+ }
3968
4110
  function isBridgeCancelMessage(value) {
3969
4111
  return hasExactKeys(value, ["type", "id"]) &&
3970
4112
  value.type === "redevplugin.bridge.cancel" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/redevplugin-ui",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@floegence/redevplugin-contracts": "0.6.16"
35
+ "@floegence/redevplugin-contracts": "0.6.18"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"