@floegence/redevplugin-ui 0.6.7 → 0.6.10

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 PluginSurfaceOpeningProgress, type PluginSurfaceReloadLimiter, type PluginSurfaceSlot, type PluginTrustedMethodResult, type ReDevPluginSurfaceTransport } from "./surface.js";
3
+ import { type PluginConfirmationHandler, type PluginSurfaceHost, type PluginSurfaceOpeningProgress, type PluginSurfaceInteractionEvent, type PluginSurfaceReloadLimiter, type PluginSurfaceSlot, type PluginTrustedMethodResult, type ReDevPluginSurfaceTransport } from "./surface.js";
4
4
  import { type PluginSurfaceScope } from "./surface-scope.js";
5
5
  export type PluginPlatformClientOptions = {
6
6
  fetch?: FetchLike;
@@ -31,6 +31,12 @@ export type PluginPackageHashSet = PlatformSchemas["PackageHashSet"];
31
31
  export type PluginReleaseRef = PlatformSchemas["PluginReleaseRef"];
32
32
  export type PluginInstallReleaseRefRequest = PlatformSchemas["InstallReleaseRefRequest"];
33
33
  export type PluginUpdateReleaseRefRequest = PlatformSchemas["UpdateReleaseRefRequest"];
34
+ export type PluginInspectExternalPackageRequest = PlatformSchemas["InspectExternalPackageRequest"];
35
+ export type PluginUploadedExternalPackageIntent = PlatformSchemas["ExternalPackageIntentRequest"];
36
+ export type PluginExternalPackageInspection = PlatformSchemas["ExternalPackageInspection"];
37
+ export type PluginCommitExternalPackageRequest = PlatformSchemas["CommitExternalPackageRequest"];
38
+ export type PluginQueryExternalPackageCommitRequest = PlatformSchemas["QueryExternalPackageCommitRequest"];
39
+ export type PluginExternalPackageCommitResult = PlatformSchemas["ExternalPackageCommitResult"];
34
40
  export type PluginDowngradeRequest = PlatformSchemas["DowngradeRequest"];
35
41
  export type PluginEnableRequest = PlatformSchemas["EnableRequest"];
36
42
  export type PluginDisableRequest = PlatformSchemas["DisableRequest"];
@@ -44,6 +50,7 @@ export type PluginOpenSurfaceInSlotOptions = PluginRequestOptions & {
44
50
  reloadLimiter?: PluginSurfaceReloadLimiter;
45
51
  confirm?: PluginConfirmationHandler;
46
52
  onOpeningProgress?: (progress: PluginSurfaceOpeningProgress) => void;
53
+ onInteraction?: (event: PluginSurfaceInteractionEvent) => void;
47
54
  onError?: (error: import("./errors.js").PluginBridgeError) => void;
48
55
  };
49
56
  export type PluginSessionScopeRevokeCounts = Readonly<PlatformSchemas["SessionScopeRevokeCounts"]>;
@@ -83,6 +90,8 @@ export type PluginPermissionGrant = PlatformSchemas["PluginPermissionGrant"];
83
90
  export type PluginPermissionMutationResult = PlatformSchemas["PluginPermissionMutationResult"];
84
91
  export type PluginPermissionList = PlatformSchemas["PluginPermissionList"];
85
92
  export type PluginPermissionListOptions = PlatformSchemas["ListPermissionsQueryRequest"];
93
+ export type PluginPermissionRequirements = PlatformSchemas["PluginPermissionRequirements"];
94
+ export type PluginPermissionRequirementsRequest = PlatformSchemas["PermissionRequirementsQueryRequest"];
86
95
  export type PluginPermissionGrantRequest = PlatformSchemas["GrantPermissionRequest"];
87
96
  export type PluginPermissionRevokeRequest = PlatformSchemas["RevokePermissionRequest"];
88
97
  export type PluginSecurityPolicy = PlatformSchemas["PluginSecurityPolicy"];
@@ -119,6 +128,10 @@ export declare class PluginPlatformClient {
119
128
  features(options?: PluginRequestOptions): Promise<PluginFeatures>;
120
129
  getCompatibility(options?: PluginRequestOptions): Promise<PluginCompatibilityManifest>;
121
130
  installReleaseRef(request: PluginInstallReleaseRefRequest, options?: PluginRequestOptions): Promise<PluginRecord>;
131
+ inspectExternalPackage(request: PluginInspectExternalPackageRequest, options?: PluginRequestOptions): Promise<PluginExternalPackageInspection>;
132
+ inspectUploadedExternalPackage(intent: PluginUploadedExternalPackageIntent, packageBlob: Blob, options?: PluginRequestOptions): Promise<PluginExternalPackageInspection>;
133
+ commitExternalPackage(request: PluginCommitExternalPackageRequest, options?: PluginRequestOptions): Promise<PluginExternalPackageCommitResult>;
134
+ queryExternalPackageCommit(request: PluginQueryExternalPackageCommitRequest, options?: PluginRequestOptions): Promise<PluginExternalPackageCommitResult>;
122
135
  updateReleaseRef(request: PluginUpdateReleaseRefRequest, options?: PluginRequestOptions): Promise<PluginRecord>;
123
136
  downgradePlugin(request: PluginDowngradeRequest, options?: PluginRequestOptions): Promise<PluginRecord>;
124
137
  enablePlugin(request: PluginEnableRequest, options?: PluginRequestOptions): Promise<PluginRecord>;
@@ -146,6 +159,7 @@ export declare class PluginPlatformClient {
146
159
  bindRetainedData(request: PluginRetainedDataBindRequest, options?: PluginRequestOptions): Promise<PluginDataBinding>;
147
160
  cleanupExpiredRetainedData(request?: PluginRetainedDataCleanupRequest, options?: PluginRequestOptions): Promise<PluginRetainedDataCleanupResult>;
148
161
  listPermissions(options?: PluginPermissionListOptions, requestOptions?: PluginRequestOptions): Promise<PluginPermissionList>;
162
+ getPermissionRequirements(request: PluginPermissionRequirementsRequest, options?: PluginRequestOptions): Promise<PluginPermissionRequirements>;
149
163
  grantPermission(request: PluginPermissionGrantRequest, options?: PluginRequestOptions): Promise<PluginPermissionMutationResult>;
150
164
  revokePermission(request: PluginPermissionRevokeRequest, options?: PluginRequestOptions): Promise<PluginPermissionMutationResult>;
151
165
  listSecurityPolicies(options?: PluginRequestOptions): Promise<PluginSecurityPolicyList>;
package/dist/platform.js CHANGED
@@ -26,6 +26,7 @@ export class PluginPlatformClient {
26
26
  #surfaceScope;
27
27
  #surfaceTransport;
28
28
  #onMutationOutcomeUnknown;
29
+ #externalInspectionTargets = new Map();
29
30
  constructor(options = {}) {
30
31
  this.#fetch = options.fetch ?? defaultFetch();
31
32
  this.#apiBaseURL = trimTrailingSlash(options.apiBaseURL ?? "");
@@ -39,6 +40,56 @@ export class PluginPlatformClient {
39
40
  installReleaseRef(request, options = {}) {
40
41
  return this.#requestMutation("POST", "/_redevplugin/api/plugins/install-release-ref", request, options);
41
42
  }
43
+ async inspectExternalPackage(request, options = {}) {
44
+ const inspection = await this.#requestMutation("POST", "/_redevplugin/api/plugins/external-packages/inspect", request, options);
45
+ this.#externalInspectionTargets.set(inspection.inspection_id, inspection.intent.plugin_instance_id ?? "");
46
+ return inspection;
47
+ }
48
+ async inspectUploadedExternalPackage(intent, packageBlob, options = {}) {
49
+ const canonicalIntent = canonicalUploadedExternalPackageIntent(intent);
50
+ if (!(packageBlob instanceof Blob)) {
51
+ throw new TypeError("external package upload must be a Blob");
52
+ }
53
+ const update = canonicalIntent.action === "update";
54
+ const path = update
55
+ ? `/_redevplugin/api/plugins/${encodeURIComponent(canonicalIntent.plugin_instance_id)}/external-packages/upload/inspect`
56
+ : "/_redevplugin/api/plugins/external-packages/upload/inspect";
57
+ const inspection = await this.#requestPackageUploadInspection(update ? "PUT" : "POST", path, packageBlob, options, update
58
+ ? { "X-ReDevPlugin-Expected-Management-Revision": String(canonicalIntent.expected_management_revision) }
59
+ : {});
60
+ this.#externalInspectionTargets.set(inspection.inspection_id, update ? canonicalIntent.plugin_instance_id : "");
61
+ return inspection;
62
+ }
63
+ async commitExternalPackage(request, options = {}) {
64
+ const target = this.#externalInspectionTargets.get(request.inspection_id);
65
+ let result;
66
+ try {
67
+ result = await this.#requestMutation("POST", "/_redevplugin/api/plugins/external-packages/commit", request, options);
68
+ }
69
+ catch (error) {
70
+ await this.#handleMutationFailure(error, target || undefined, "External package commit and local surface teardown failed");
71
+ throw error;
72
+ }
73
+ if (result.status === "committed") {
74
+ await disposePluginSurfaceScope(this.#surfaceScope, result.plugin.plugin_instance_id);
75
+ this.#externalInspectionTargets.delete(request.inspection_id);
76
+ }
77
+ else if (result.status === "failed") {
78
+ this.#externalInspectionTargets.delete(request.inspection_id);
79
+ }
80
+ return result;
81
+ }
82
+ async queryExternalPackageCommit(request, options = {}) {
83
+ const result = await this.#requestQuery("/_redevplugin/api/plugins/external-packages/commit/query", request, options);
84
+ if (result.status === "committed") {
85
+ await disposePluginSurfaceScope(this.#surfaceScope, result.plugin.plugin_instance_id);
86
+ this.#externalInspectionTargets.delete(request.inspection_id);
87
+ }
88
+ else if (result.status === "failed") {
89
+ this.#externalInspectionTargets.delete(request.inspection_id);
90
+ }
91
+ return result;
92
+ }
42
93
  updateReleaseRef(request, options = {}) {
43
94
  return this.#mutatePlugin("/_redevplugin/api/plugins/update-release-ref", request, options);
44
95
  }
@@ -163,6 +214,9 @@ export class PluginPlatformClient {
163
214
  listPermissions(options = {}, requestOptions = {}) {
164
215
  return this.#requestQuery("/_redevplugin/api/plugins/permissions/query", options, requestOptions);
165
216
  }
217
+ getPermissionRequirements(request, options = {}) {
218
+ return this.#requestQuery("/_redevplugin/api/plugins/permissions/requirements/query", request, options);
219
+ }
166
220
  grantPermission(request, options = {}) {
167
221
  return this.#mutatePlugin("/_redevplugin/api/plugins/permissions/grant", request, options);
168
222
  }
@@ -296,6 +350,58 @@ export class PluginPlatformClient {
296
350
  }, operation);
297
351
  return readMutationPlatformResponse(response);
298
352
  }
353
+ async #requestPackageUploadInspection(method, path, body, options, metadataHeaders) {
354
+ const operation = `${method} ${path}`;
355
+ assertMutationDispatchable(options.signal, operation);
356
+ const response = await dispatchMutationRequest(this.#fetch, this.#apiBaseURL + path, {
357
+ method,
358
+ headers: {
359
+ "Accept": "application/json",
360
+ "Content-Type": "application/vnd.redevplugin.package+zip",
361
+ ...metadataHeaders,
362
+ },
363
+ body,
364
+ credentials: "same-origin",
365
+ signal: options.signal,
366
+ }, operation);
367
+ return readMutationPlatformResponse(response);
368
+ }
369
+ }
370
+ function canonicalUploadedExternalPackageIntent(intent) {
371
+ if (typeof intent !== "object" || intent === null || Array.isArray(intent)) {
372
+ throw new TypeError("uploaded external package intent must be a closed install or update object");
373
+ }
374
+ const value = intent;
375
+ const keys = Object.keys(value).sort();
376
+ if (value.action === "install") {
377
+ if (keys.length !== 1 || keys[0] !== "action") {
378
+ throw new TypeError("uploaded external package install intent accepts only action");
379
+ }
380
+ return { action: "install" };
381
+ }
382
+ if (value.action !== "update") {
383
+ throw new TypeError("uploaded external package intent action must be install or update");
384
+ }
385
+ const updateKeys = ["action", "expected_management_revision", "plugin_instance_id"];
386
+ if (keys.length !== updateKeys.length || keys.some((key, index) => key !== updateKeys[index])) {
387
+ throw new TypeError("uploaded external package update intent requires only action, plugin_instance_id, and expected_management_revision");
388
+ }
389
+ if (typeof value.plugin_instance_id !== "string" ||
390
+ value.plugin_instance_id.length === 0 ||
391
+ value.plugin_instance_id.trim() !== value.plugin_instance_id ||
392
+ value.plugin_instance_id.startsWith(".") ||
393
+ value.plugin_instance_id.includes("/")) {
394
+ throw new TypeError("uploaded external package update plugin_instance_id must be a canonical non-empty string");
395
+ }
396
+ if (!Number.isSafeInteger(value.expected_management_revision) ||
397
+ value.expected_management_revision <= 0) {
398
+ throw new TypeError("uploaded external package update expected_management_revision must be a positive safe integer");
399
+ }
400
+ return {
401
+ action: "update",
402
+ plugin_instance_id: value.plugin_instance_id,
403
+ expected_management_revision: value.expected_management_revision,
404
+ };
299
405
  }
300
406
  const sessionScopeCountKeys = [
301
407
  "surfaces",
package/dist/surface.d.ts CHANGED
@@ -349,6 +349,15 @@ export type PluginSurfaceOpeningProgress = {
349
349
  phase: "opening";
350
350
  elapsedMs: number;
351
351
  };
352
+ export type PluginSurfaceInteractionKind = "activation" | "focus" | "wheel" | "text-selection" | "action";
353
+ export type PluginSurfaceInteractionEvent = {
354
+ kind: PluginSurfaceInteractionKind;
355
+ sequence: number;
356
+ targetKey?: string;
357
+ action?: string;
358
+ localScroll: boolean;
359
+ selectionActive: boolean;
360
+ };
352
361
  export type PluginSurfaceHost = {
353
362
  readonly element: HTMLIFrameElement;
354
363
  readonly surfaceInstanceId: string;
@@ -376,6 +385,7 @@ export type PluginSurfaceHostOptions = {
376
385
  reloadLimiter?: PluginSurfaceReloadLimiter;
377
386
  confirm?: PluginConfirmationHandler;
378
387
  onOpeningProgress?: (progress: PluginSurfaceOpeningProgress) => void;
388
+ onInteraction?: (event: PluginSurfaceInteractionEvent) => void;
379
389
  onError?: (error: PluginBridgeError) => void;
380
390
  };
381
391
  type PluginSurfaceOpeningRequest = {
@@ -403,9 +413,16 @@ export type PluginSurfaceQuiesceResult = {
403
413
  };
404
414
  export type PluginSurfaceCloseResult = {
405
415
  quiesce: PluginSurfaceQuiesceResult;
416
+ readonly revocation?: PluginSurfaceRevocationReconciliation;
406
417
  revokeDurationMs: number;
407
418
  totalDurationMs: number;
408
419
  };
420
+ export type PluginSurfaceRevocationReconciliation = {
421
+ disposed: true;
422
+ state: "closed" | "absent";
423
+ previous_state: "active" | "closed" | "absent";
424
+ revoked: boolean;
425
+ };
409
426
  export declare function createOpaquePluginBootstrapHTML(options?: OpaquePluginBootstrapHTMLOptions): string;
410
427
  export declare function createPreparedPluginSurfaceHost(options: PluginSurfaceHostOptions): PreparedPluginSurfaceHost;
411
428
  export type PluginSurfaceSlotState = "empty" | "opening" | "ready" | "error" | "disposed";
package/dist/surface.js CHANGED
@@ -34,6 +34,7 @@ const maxOpaqueSurfaceLazyAssets = 128;
34
34
  const maxOpaqueSurfaceLazyBytes = 32 * 1024 * 1024;
35
35
  const maxConcurrentAssetReads = 4;
36
36
  const maxSurfaceQuiesceMs = 1500;
37
+ const maxSurfaceInteractionEventsPerSecond = 120;
37
38
  const pluginBridgeErrorCodeSet = new Set(pluginBridgeErrorCodes);
38
39
  const hostCapabilityIDPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._-]*$");
39
40
  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-]+)*)?$");
@@ -688,7 +689,11 @@ async function revokeSurfaceBootstrap(transport, bootstrap, timeoutMs, keepalive
688
689
  }),
689
690
  timeout,
690
691
  ]);
691
- await readMutationPlatformResponse(response);
692
+ const result = await readMutationPlatformResponse(response);
693
+ if (!isSurfaceRevocationReconciliation(result)) {
694
+ throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revocation reconciliation returned an invalid result", undefined, undefined, "unknown");
695
+ }
696
+ return result;
692
697
  }
693
698
  catch (error) {
694
699
  if (timedOut) {
@@ -880,6 +885,48 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
880
885
  const sendParent = (message) => {
881
886
  if (parentPort && withinLimit(message)) parentPort.postMessage(message);
882
887
  };
888
+ let interactionSequence = 0;
889
+ const interactionTargetKey = (target) => {
890
+ const element = target instanceof Element ? target.closest("[data-redevplugin-key]") : null;
891
+ const key = element?.getAttribute("data-redevplugin-key");
892
+ return validResourceIdentifier(key) ? key : null;
893
+ };
894
+ const ownsLocalScroll = (event) => {
895
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target];
896
+ for (const candidate of path) {
897
+ if (!(candidate instanceof Element)) continue;
898
+ if (candidate === document.scrollingElement &&
899
+ (candidate.scrollHeight > candidate.clientHeight || candidate.scrollWidth > candidate.clientWidth)) return true;
900
+ const style = getComputedStyle(candidate);
901
+ const vertical = /(auto|scroll|overlay)/.test(style.overflowY) && candidate.scrollHeight > candidate.clientHeight;
902
+ const horizontal = /(auto|scroll|overlay)/.test(style.overflowX) && candidate.scrollWidth > candidate.clientWidth;
903
+ if (vertical || horizontal) return true;
904
+ }
905
+ return false;
906
+ };
907
+ const sendInteraction = (kind, target, options = {}) => {
908
+ if (!initialized || pendingQuiesceID) return;
909
+ interactionSequence += 1;
910
+ sendParent({
911
+ type: "redevplugin.surface.interaction",
912
+ frame_generation_id: frameGenerationID,
913
+ surface_handle: surfaceHandle,
914
+ sequence: interactionSequence,
915
+ kind,
916
+ target_key: interactionTargetKey(target),
917
+ action: options.action || null,
918
+ local_scroll: options.localScroll === true,
919
+ selection_active: options.selectionActive === true,
920
+ });
921
+ };
922
+ document.addEventListener("pointerdown", (event) => sendInteraction("activation", event.target), true);
923
+ document.addEventListener("focusin", (event) => sendInteraction("focus", event.target), true);
924
+ document.addEventListener("wheel", (event) => sendInteraction("wheel", event.target, { localScroll: ownsLocalScroll(event) }), { capture: true, passive: true });
925
+ document.addEventListener("selectionchange", () => {
926
+ const selection = document.getSelection();
927
+ const target = selection?.anchorNode instanceof Element ? selection.anchorNode : selection?.anchorNode?.parentElement;
928
+ sendInteraction("text-selection", target, { selectionActive: Boolean(selection && !selection.isCollapsed) });
929
+ });
883
930
  const sendWorker = (message) => {
884
931
  if (workerPort && withinLimit(message)) workerPort.postMessage(message);
885
932
  };
@@ -1513,6 +1560,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1513
1560
  if (event.type === "submit" || (event.type === "click" && element.tagName === "BUTTON")) event.preventDefault();
1514
1561
  const action = element.getAttribute("data-redevplugin-action");
1515
1562
  if (!validIdentifier(action)) return;
1563
+ sendInteraction("action", event.target, { action });
1516
1564
  sendWorker(actionPayload(event, element));
1517
1565
  };
1518
1566
  for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
@@ -1551,6 +1599,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
1551
1599
  const targetKey = owner.getAttribute("data-redevplugin-key");
1552
1600
  if (!validResourceIdentifier(targetKey)) return;
1553
1601
  const edit = controlEdits.get(targetKey) || { revision: 0, isComposing: false };
1602
+ sendInteraction("action", origin, { action });
1554
1603
  sendWorker({
1555
1604
  type: "redevplugin.ui.action",
1556
1605
  action,
@@ -2265,7 +2314,11 @@ class PluginSurfaceHostImplementation {
2265
2314
  #reloadLimiter;
2266
2315
  #confirm;
2267
2316
  #onOpeningProgress;
2317
+ #onInteraction;
2268
2318
  #onError;
2319
+ #lastInteractionSequence = 0;
2320
+ #interactionWindowStartedAt = 0;
2321
+ #interactionWindowCount = 0;
2269
2322
  #abortController = new AbortController();
2270
2323
  #gatewayToken;
2271
2324
  #leaseExpiresAtMs = 0;
@@ -2324,6 +2377,7 @@ class PluginSurfaceHostImplementation {
2324
2377
  this.#reloadLimiter = options.reloadLimiter ?? new PluginSurfaceReloadLimiter();
2325
2378
  this.#confirm = options.confirm;
2326
2379
  this.#onOpeningProgress = options.onOpeningProgress;
2380
+ this.#onInteraction = options.onInteraction;
2327
2381
  this.#onError = options.onError;
2328
2382
  hardenPluginSurfaceFrame(this.#iframe);
2329
2383
  this.#unregisterSurfaceScope = registerPluginSurface(options.surfaceScope ?? defaultPluginSurfaceScope, this.bootstrap.pluginInstanceId, () => this.#disposeLocal(), () => this.#invalidateLocal());
@@ -2430,23 +2484,29 @@ class PluginSurfaceHostImplementation {
2430
2484
  if (this.#disposed) {
2431
2485
  const result = {
2432
2486
  quiesce: { outcome: "not_ready", durationMs: 0 },
2487
+ revocation: { disposed: true, state: "closed", previous_state: "closed", revoked: false },
2433
2488
  revokeDurationMs: 0,
2434
2489
  totalDurationMs: 0,
2435
2490
  };
2436
- return this.#revokePromise ? this.#revokePromise.then(() => result) : Promise.resolve(result);
2491
+ return this.#revokeSurface(false).then((revocation) => ({ ...result, revocation }));
2437
2492
  }
2438
2493
  if (this.#closePromise)
2439
2494
  return this.#closePromise;
2440
- this.#closePromise = this.#closeSurface();
2441
- return this.#closePromise;
2495
+ const closing = this.#closeSurface();
2496
+ this.#closePromise = closing;
2497
+ void closing.catch(() => {
2498
+ if (this.#closePromise === closing)
2499
+ this.#closePromise = undefined;
2500
+ });
2501
+ return closing;
2442
2502
  }
2443
2503
  dispose() {
2444
2504
  if (this.#disposed)
2445
- return this.#revokePromise ?? Promise.resolve();
2505
+ return this.#revokeSurface(true).then(() => undefined);
2446
2506
  const revoke = this.#revokeSurface(true);
2447
2507
  void revoke.catch(() => undefined);
2448
2508
  this.#disposeLocal();
2449
- return revoke;
2509
+ return revoke.then(() => undefined);
2450
2510
  }
2451
2511
  observeLocalInvalidation(observer) {
2452
2512
  this.#onLocalInvalidation = observer;
@@ -2457,22 +2517,24 @@ class PluginSurfaceHostImplementation {
2457
2517
  const startedAt = performance.now();
2458
2518
  const quiesce = await this.#quiesceSurface();
2459
2519
  if (this.#disposed) {
2460
- return { quiesce, revokeDurationMs: 0, totalDurationMs: performance.now() - startedAt };
2520
+ const revocation = this.#revokePromise ? await this.#revokePromise : { disposed: true, state: "closed", previous_state: "closed", revoked: false };
2521
+ return { quiesce, revocation, revokeDurationMs: 0, totalDurationMs: performance.now() - startedAt };
2461
2522
  }
2462
2523
  const revokeStartedAt = performance.now();
2463
2524
  const revoke = this.#revokeSurface(false);
2464
2525
  this.#disposeLocal();
2465
2526
  try {
2466
- await revoke;
2527
+ const revocation = await revoke;
2528
+ return {
2529
+ quiesce,
2530
+ revocation,
2531
+ revokeDurationMs: performance.now() - revokeStartedAt,
2532
+ totalDurationMs: performance.now() - startedAt,
2533
+ };
2467
2534
  }
2468
2535
  catch (error) {
2469
2536
  throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
2470
2537
  }
2471
- return {
2472
- quiesce,
2473
- revokeDurationMs: performance.now() - revokeStartedAt,
2474
- totalDurationMs: performance.now() - startedAt,
2475
- };
2476
2538
  }
2477
2539
  #disposeLocal() {
2478
2540
  if (this.#disposed)
@@ -2550,6 +2612,10 @@ class PluginSurfaceHostImplementation {
2550
2612
  this.#openSignals?.firstCommit.resolve();
2551
2613
  return;
2552
2614
  }
2615
+ if (isSurfaceInteractionMessage(data)) {
2616
+ this.#handleInteraction(data);
2617
+ return;
2618
+ }
2553
2619
  if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
2554
2620
  this.#quiesce.acknowledged.resolve();
2555
2621
  return;
@@ -2587,6 +2653,35 @@ class PluginSurfaceHostImplementation {
2587
2653
  await this.#failSurface(toBridgeError(error, "PLUGIN_CONTRACT_MISMATCH"));
2588
2654
  }
2589
2655
  }
2656
+ #handleInteraction(message) {
2657
+ if (!this.#ready || message.frame_generation_id !== this.frameGenerationId || message.surface_handle !== this.surfaceHandle)
2658
+ return;
2659
+ if (message.sequence <= this.#lastInteractionSequence)
2660
+ return;
2661
+ this.#lastInteractionSequence = message.sequence;
2662
+ const now = performance.now();
2663
+ if (now - this.#interactionWindowStartedAt >= 1000) {
2664
+ this.#interactionWindowStartedAt = now;
2665
+ this.#interactionWindowCount = 0;
2666
+ }
2667
+ this.#interactionWindowCount += 1;
2668
+ if (this.#interactionWindowCount > maxSurfaceInteractionEventsPerSecond)
2669
+ return;
2670
+ const interaction = {
2671
+ kind: message.kind,
2672
+ sequence: message.sequence,
2673
+ localScroll: message.local_scroll,
2674
+ selectionActive: message.selection_active,
2675
+ ...(message.target_key === null ? {} : { targetKey: message.target_key }),
2676
+ ...(message.action === null ? {} : { action: message.action }),
2677
+ };
2678
+ try {
2679
+ this.#onInteraction?.(interaction);
2680
+ }
2681
+ catch {
2682
+ // Interaction observers cannot alter the surface lifecycle.
2683
+ }
2684
+ }
2590
2685
  async #handleCall(request) {
2591
2686
  if ((!this.#bridgeReady && !this.#quiesce) || !this.#gatewayToken) {
2592
2687
  this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
@@ -3072,8 +3167,13 @@ class PluginSurfaceHostImplementation {
3072
3167
  #revokeSurface(keepalive) {
3073
3168
  if (this.#revokePromise)
3074
3169
  return this.#revokePromise;
3075
- this.#revokePromise = revokeSurfaceBootstrap(this.#transport, this.bootstrap, this.#requestTimeoutMs, keepalive);
3076
- return this.#revokePromise;
3170
+ const revocation = revokeSurfaceBootstrap(this.#transport, this.bootstrap, this.#requestTimeoutMs, keepalive);
3171
+ this.#revokePromise = revocation;
3172
+ void revocation.catch(() => {
3173
+ if (this.#revokePromise === revocation)
3174
+ this.#revokePromise = undefined;
3175
+ });
3176
+ return revocation;
3077
3177
  }
3078
3178
  async #quiesceSurface() {
3079
3179
  const startedAt = performance.now();
@@ -3309,6 +3409,7 @@ export class PluginSurfaceSlot {
3309
3409
  #tail = Promise.resolve();
3310
3410
  #disposed = false;
3311
3411
  #retired = new WeakMap();
3412
+ #pendingRetirements = new Set();
3312
3413
  #disposePromise;
3313
3414
  static create(options) {
3314
3415
  if (!options.stage || typeof options.stage.append !== "function" || !options.stage.dataset) {
@@ -3449,8 +3550,8 @@ export class PluginSurfaceSlot {
3449
3550
  this.#transitionController?.abort();
3450
3551
  this.#transitionController = undefined;
3451
3552
  this.#setState("empty");
3452
- const closing = this.#tail.then(() => this.#closeCurrentSurface());
3453
- this.#tail = closing.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
3553
+ const closing = this.#tail.then(() => this.#closeCurrentSurface(), () => this.#closeCurrentSurface());
3554
+ this.#tail = closing.then(() => undefined, () => undefined);
3454
3555
  void this.#tail.catch(() => undefined);
3455
3556
  return this.#unwrapCleanupError(closing);
3456
3557
  }
@@ -3461,17 +3562,22 @@ export class PluginSurfaceSlot {
3461
3562
  this.#transitionController?.abort();
3462
3563
  this.#transitionController = undefined;
3463
3564
  this.#setState("disposed");
3464
- const disposal = this.#tail.then(async () => {
3465
- await this.#closeCurrentSurface();
3565
+ const disposal = this.#tail.then(() => this.#closeCurrentSurface().then(() => undefined), () => this.#closeCurrentSurface().then(() => undefined));
3566
+ const disposePromise = this.#unwrapCleanupError(disposal);
3567
+ this.#disposePromise = disposePromise;
3568
+ void disposePromise.catch(() => {
3569
+ if (this.#disposePromise === disposePromise)
3570
+ this.#disposePromise = undefined;
3466
3571
  });
3467
- this.#disposePromise = this.#unwrapCleanupError(disposal);
3468
- void this.#disposePromise.catch(() => undefined);
3469
- this.#tail = disposal.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
3572
+ this.#tail = disposal.then(() => undefined, () => undefined);
3470
3573
  void this.#tail.catch(() => undefined);
3471
- return this.#disposePromise;
3574
+ return disposePromise;
3472
3575
  }
3473
3576
  async #closeCurrentSurface() {
3474
- const hosts = new Set([this.#active, this.#opening].filter((host) => host !== undefined));
3577
+ const hosts = new Set([
3578
+ ...this.#pendingRetirements,
3579
+ ...[this.#active, this.#opening].filter((host) => host !== undefined),
3580
+ ]);
3475
3581
  this.#active = undefined;
3476
3582
  this.#opening = undefined;
3477
3583
  let result;
@@ -3495,6 +3601,7 @@ export class PluginSurfaceSlot {
3495
3601
  const existing = this.#retired.get(host);
3496
3602
  if (existing)
3497
3603
  return existing;
3604
+ this.#pendingRetirements.add(host);
3498
3605
  setSurfaceInteractive(host.element, false);
3499
3606
  const closing = Promise.resolve().then(async () => {
3500
3607
  try {
@@ -3512,6 +3619,14 @@ export class PluginSurfaceSlot {
3512
3619
  }
3513
3620
  });
3514
3621
  this.#retired.set(host, closing);
3622
+ void closing.then(() => {
3623
+ this.#pendingRetirements.delete(host);
3624
+ if (this.#retired.get(host) === closing)
3625
+ this.#retired.delete(host);
3626
+ }, () => {
3627
+ if (this.#retired.get(host) === closing)
3628
+ this.#retired.delete(host);
3629
+ });
3515
3630
  return closing;
3516
3631
  }
3517
3632
  async #awaitRetirement(host) {
@@ -3596,6 +3711,31 @@ function setSurfaceInteractive(element, interactive) {
3596
3711
  element.inert = !interactive;
3597
3712
  element.setAttribute("aria-hidden", interactive ? "false" : "true");
3598
3713
  }
3714
+ function isSurfaceInteractionMessage(value) {
3715
+ if (!hasExactKeys(value, [
3716
+ "type", "frame_generation_id", "surface_handle", "sequence", "kind", "target_key", "action", "local_scroll", "selection_active",
3717
+ ]))
3718
+ return false;
3719
+ return value.type === "redevplugin.surface.interaction" &&
3720
+ validOpaqueHandle(value.frame_generation_id, "frame") &&
3721
+ validOpaqueHandle(value.surface_handle, "surface") &&
3722
+ typeof value.sequence === "number" && Number.isSafeInteger(value.sequence) && value.sequence > 0 &&
3723
+ ["activation", "focus", "wheel", "text-selection", "action"].includes(String(value.kind)) &&
3724
+ (value.target_key === null || validUIIdentifier(value.target_key)) &&
3725
+ (value.action === null || validActionID(value.action)) &&
3726
+ typeof value.local_scroll === "boolean" && typeof value.selection_active === "boolean" &&
3727
+ (value.kind === "action" ? value.action !== null : value.action === null) &&
3728
+ (value.kind === "wheel" || value.local_scroll === false) &&
3729
+ (value.kind === "text-selection" || value.selection_active === false);
3730
+ }
3731
+ function isSurfaceRevocationReconciliation(value) {
3732
+ if (!hasExactKeys(value, ["disposed", "state", "previous_state", "revoked"]))
3733
+ return false;
3734
+ return value.disposed === true && ["closed", "absent"].includes(String(value.state)) &&
3735
+ ["active", "closed", "absent"].includes(String(value.previous_state)) &&
3736
+ typeof value.revoked === "boolean" &&
3737
+ (value.revoked ? value.state === "closed" && value.previous_state === "active" : true);
3738
+ }
3599
3739
  function claimOpaquePluginBridge() {
3600
3740
  const value = globalThis[opaquePluginBridgeGlobalKey];
3601
3741
  if (!isRecord(value) || typeof value.claim !== "function") {
@@ -3,5 +3,5 @@ export * from "./errors.js";
3
3
  export * from "./platform.js";
4
4
  export { PluginSurfaceScope, createPluginSurfaceScope } from "./surface-scope.js";
5
5
  export { PluginSurfaceReloadLimiter, PluginSurfaceSlot, createReDevPluginSurfaceTransport, defaultPluginSurfaceReloadMax, defaultPluginSurfaceReloadWindowMs, isPluginRiskPlan, opaqueSurfaceDocumentSchemaVersion, 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, PluginStreamEvent, PluginSurfaceHost, PluginSurfaceCloseResult, PluginSurfaceOpeningProgress, PluginSurfaceQuiesceResult, PluginSurfacePreparationResult, PluginSurfaceReloadDecision, PluginSurfaceReloadLimiterOptions, PluginSurfaceReloadState, PluginSurfaceSlotOptions, PluginSurfaceSlotState, PluginTrustedMethodResult, ReDevPluginSurfaceTransport, ReDevPluginSurfaceTransportOptions, TrustedParentBridgeHandshake, TrustedParentBridgeTokenRequest, WindowLike, } from "./surface.js";
6
+ export type { BridgeLifecycleEvent, MessageChannelLike, MessageEventLike, MessagePortLike, OpaqueSurfaceAsset, OpaqueSurfaceDocument, OpaqueSurfaceStyle, OpaqueSurfaceWorker, PluginConfirmationDecision, PluginConfirmationHandler, PluginConfirmationIntent, PluginConfirmationPlan, PluginMethodResult, PluginRiskEffect, PluginRiskFlag, PluginRiskPlan, PluginRiskSeverity, PluginStreamEvent, PluginSurfaceHost, PluginSurfaceCloseResult, PluginSurfaceOpeningProgress, PluginSurfaceInteractionEvent, PluginSurfaceInteractionKind, PluginSurfaceQuiesceResult, PluginSurfaceRevocationReconciliation, PluginSurfacePreparationResult, PluginSurfaceReloadDecision, PluginSurfaceReloadLimiterOptions, PluginSurfaceReloadState, PluginSurfaceSlotOptions, PluginSurfaceSlotState, PluginTrustedMethodResult, ReDevPluginSurfaceTransport, ReDevPluginSurfaceTransportOptions, TrustedParentBridgeHandshake, TrustedParentBridgeTokenRequest, WindowLike, } from "./surface.js";
7
7
  export type { FetchInitLike, FetchLike, FetchResponseLike, PlatformResponse } from "./http.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/redevplugin-ui",
3
- "version": "0.6.7",
3
+ "version": "0.6.10",
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.7"
35
+ "@floegence/redevplugin-contracts": "0.6.10"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"