@ethisyscore/extension-runtime 1.9.2 → 1.11.0

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.
@@ -0,0 +1,102 @@
1
+ import { d as BridgePushThemeEnvelope, c as BridgePushLocaleEnvelope, b as BridgePushDensityEnvelope, a as BridgePushA11yEnvelope, B as BridgeNavPushEnvelope, e as BridgeSessionTokenPushEnvelope } from './bridge-envelopes-BRKGSiSC.cjs';
2
+
3
+ /**
4
+ * Transport contract used by the plugin-side React hooks
5
+ * (`useMcpResource`, `useMcpTool`) to talk to the host.
6
+ *
7
+ * The hooks are deliberately transport-agnostic: the concrete implementation
8
+ * (postMessage bridge, in-process direct call, fetch-based, …) is injected
9
+ * via {@link ExtensionRuntimeProvider} or the per-hook `transport` option.
10
+ * This keeps the React surface stable across Contract A (host-rendered) and
11
+ * Contract B (worker remote-runtime) execution modes.
12
+ *
13
+ * Implementations must honour the supplied {@link AbortSignal} for
14
+ * cancellation — hooks rely on it to tear down in-flight calls on unmount
15
+ * or argument changes.
16
+ */
17
+ interface McpTransport {
18
+ /**
19
+ * Fetch a resource by URI. Implementations should reject with an `Error`
20
+ * when the host returns a failure, and should observe `signal` to abort
21
+ * any in-flight work.
22
+ */
23
+ getResource<T>(uri: string, signal?: AbortSignal): Promise<{
24
+ uri: string;
25
+ data: T;
26
+ }>;
27
+ /**
28
+ * Invoke a tool by name. The request object is opaque to the transport.
29
+ * Implementations should observe `signal` to abort in-flight work.
30
+ */
31
+ invokeTool<TReq, TRes>(name: string, args: TReq, signal?: AbortSignal): Promise<TRes>;
32
+ }
33
+
34
+ /**
35
+ * Plugin-side bridge client for the host ↔ plugin platform contract
36
+ * (WI 4858 sub-plan 2).
37
+ *
38
+ * `createPortBridgeClient` wraps a MessagePort (or an in-realm shim) and
39
+ * exposes typed subscription callbacks for each host push (theme, locale,
40
+ * density, a11y, nav, session token) and typed fire-and-wait calls for
41
+ * chrome requests and a11y live-region announcements.
42
+ *
43
+ * For in-realm (Tier T) plugins, use `InMemoryBridgeTransport` from the
44
+ * mock-host package instead — it calls callbacks synchronously without
45
+ * any serialisation.
46
+ */
47
+
48
+ type ThemePayload = Omit<BridgePushThemeEnvelope, "type">;
49
+ type LocalePayload = Omit<BridgePushLocaleEnvelope, "type">;
50
+ type DensityPayload = Omit<BridgePushDensityEnvelope, "type">;
51
+ type A11yPayload = Omit<BridgePushA11yEnvelope, "type">;
52
+ type NavPayload = Omit<BridgeNavPushEnvelope, "type">;
53
+ type SessionTokenPayload = Omit<BridgeSessionTokenPushEnvelope, "type">;
54
+ /**
55
+ * Minimal port surface `createPortBridgeClient` actually uses. Matches
56
+ * `PortShim` in `createPortMcpTransport` — same test-injection pattern.
57
+ */
58
+ interface BridgePortShim {
59
+ onmessage: ((ev: {
60
+ data: unknown;
61
+ }) => void) | null;
62
+ postMessage(msg: unknown): void;
63
+ }
64
+ interface PortBridgeClient {
65
+ /** Replace the theme subscriber. Only the latest subscriber is called. */
66
+ onTheme(cb: (payload: ThemePayload) => void): void;
67
+ /** Replace the locale subscriber. */
68
+ onLocale(cb: (payload: LocalePayload) => void): void;
69
+ /** Replace the density subscriber. */
70
+ onDensity(cb: (payload: DensityPayload) => void): void;
71
+ /** Replace the a11y-preferences subscriber. */
72
+ onA11y(cb: (payload: A11yPayload) => void): void;
73
+ /** Replace the nav-state subscriber. */
74
+ onNav(cb: (payload: NavPayload) => void): void;
75
+ /** Replace the session-token subscriber. */
76
+ onSessionToken(cb: (payload: SessionTokenPayload) => void): void;
77
+ /**
78
+ * Request a host chrome action. Returns the host's result or rejects with
79
+ * the host's error string. Callers should catch and surface gracefully.
80
+ *
81
+ * @param action "toast" | "confirm" | "openUrl"
82
+ * @param payload Action-specific payload.
83
+ */
84
+ requestChrome(action: string, payload: Record<string, unknown>): Promise<unknown>;
85
+ /**
86
+ * Ask the host to make a live-region announcement on behalf of the plugin.
87
+ * Prefers this over writing directly to the DOM so the host can manage
88
+ * ARIA live regions centrally.
89
+ */
90
+ announceA11y(message: string, politeness: "polite" | "assertive"): void;
91
+ }
92
+ /**
93
+ * Construct a bridge client backed by a `MessagePort` (or shim).
94
+ *
95
+ * Registers a single `onmessage` handler on the port that dispatches each
96
+ * bridge push to the appropriate subscriber. Unknown message types are
97
+ * silently ignored — the port carries MCP traffic on the same channel and
98
+ * those messages must not trigger an error here.
99
+ */
100
+ declare function createPortBridgeClient(port: BridgePortShim): PortBridgeClient;
101
+
102
+ export { type A11yPayload as A, type BridgePortShim as B, type DensityPayload as D, type LocalePayload as L, type McpTransport as M, type NavPayload as N, type PortBridgeClient as P, type SessionTokenPayload as S, type ThemePayload as T, createPortBridgeClient as c };
@@ -0,0 +1,102 @@
1
+ import { d as BridgePushThemeEnvelope, c as BridgePushLocaleEnvelope, b as BridgePushDensityEnvelope, a as BridgePushA11yEnvelope, B as BridgeNavPushEnvelope, e as BridgeSessionTokenPushEnvelope } from './bridge-envelopes-BRKGSiSC.js';
2
+
3
+ /**
4
+ * Transport contract used by the plugin-side React hooks
5
+ * (`useMcpResource`, `useMcpTool`) to talk to the host.
6
+ *
7
+ * The hooks are deliberately transport-agnostic: the concrete implementation
8
+ * (postMessage bridge, in-process direct call, fetch-based, …) is injected
9
+ * via {@link ExtensionRuntimeProvider} or the per-hook `transport` option.
10
+ * This keeps the React surface stable across Contract A (host-rendered) and
11
+ * Contract B (worker remote-runtime) execution modes.
12
+ *
13
+ * Implementations must honour the supplied {@link AbortSignal} for
14
+ * cancellation — hooks rely on it to tear down in-flight calls on unmount
15
+ * or argument changes.
16
+ */
17
+ interface McpTransport {
18
+ /**
19
+ * Fetch a resource by URI. Implementations should reject with an `Error`
20
+ * when the host returns a failure, and should observe `signal` to abort
21
+ * any in-flight work.
22
+ */
23
+ getResource<T>(uri: string, signal?: AbortSignal): Promise<{
24
+ uri: string;
25
+ data: T;
26
+ }>;
27
+ /**
28
+ * Invoke a tool by name. The request object is opaque to the transport.
29
+ * Implementations should observe `signal` to abort in-flight work.
30
+ */
31
+ invokeTool<TReq, TRes>(name: string, args: TReq, signal?: AbortSignal): Promise<TRes>;
32
+ }
33
+
34
+ /**
35
+ * Plugin-side bridge client for the host ↔ plugin platform contract
36
+ * (WI 4858 sub-plan 2).
37
+ *
38
+ * `createPortBridgeClient` wraps a MessagePort (or an in-realm shim) and
39
+ * exposes typed subscription callbacks for each host push (theme, locale,
40
+ * density, a11y, nav, session token) and typed fire-and-wait calls for
41
+ * chrome requests and a11y live-region announcements.
42
+ *
43
+ * For in-realm (Tier T) plugins, use `InMemoryBridgeTransport` from the
44
+ * mock-host package instead — it calls callbacks synchronously without
45
+ * any serialisation.
46
+ */
47
+
48
+ type ThemePayload = Omit<BridgePushThemeEnvelope, "type">;
49
+ type LocalePayload = Omit<BridgePushLocaleEnvelope, "type">;
50
+ type DensityPayload = Omit<BridgePushDensityEnvelope, "type">;
51
+ type A11yPayload = Omit<BridgePushA11yEnvelope, "type">;
52
+ type NavPayload = Omit<BridgeNavPushEnvelope, "type">;
53
+ type SessionTokenPayload = Omit<BridgeSessionTokenPushEnvelope, "type">;
54
+ /**
55
+ * Minimal port surface `createPortBridgeClient` actually uses. Matches
56
+ * `PortShim` in `createPortMcpTransport` — same test-injection pattern.
57
+ */
58
+ interface BridgePortShim {
59
+ onmessage: ((ev: {
60
+ data: unknown;
61
+ }) => void) | null;
62
+ postMessage(msg: unknown): void;
63
+ }
64
+ interface PortBridgeClient {
65
+ /** Replace the theme subscriber. Only the latest subscriber is called. */
66
+ onTheme(cb: (payload: ThemePayload) => void): void;
67
+ /** Replace the locale subscriber. */
68
+ onLocale(cb: (payload: LocalePayload) => void): void;
69
+ /** Replace the density subscriber. */
70
+ onDensity(cb: (payload: DensityPayload) => void): void;
71
+ /** Replace the a11y-preferences subscriber. */
72
+ onA11y(cb: (payload: A11yPayload) => void): void;
73
+ /** Replace the nav-state subscriber. */
74
+ onNav(cb: (payload: NavPayload) => void): void;
75
+ /** Replace the session-token subscriber. */
76
+ onSessionToken(cb: (payload: SessionTokenPayload) => void): void;
77
+ /**
78
+ * Request a host chrome action. Returns the host's result or rejects with
79
+ * the host's error string. Callers should catch and surface gracefully.
80
+ *
81
+ * @param action "toast" | "confirm" | "openUrl"
82
+ * @param payload Action-specific payload.
83
+ */
84
+ requestChrome(action: string, payload: Record<string, unknown>): Promise<unknown>;
85
+ /**
86
+ * Ask the host to make a live-region announcement on behalf of the plugin.
87
+ * Prefers this over writing directly to the DOM so the host can manage
88
+ * ARIA live regions centrally.
89
+ */
90
+ announceA11y(message: string, politeness: "polite" | "assertive"): void;
91
+ }
92
+ /**
93
+ * Construct a bridge client backed by a `MessagePort` (or shim).
94
+ *
95
+ * Registers a single `onmessage` handler on the port that dispatches each
96
+ * bridge push to the appropriate subscriber. Unknown message types are
97
+ * silently ignored — the port carries MCP traffic on the same channel and
98
+ * those messages must not trigger an error here.
99
+ */
100
+ declare function createPortBridgeClient(port: BridgePortShim): PortBridgeClient;
101
+
102
+ export { type A11yPayload as A, type BridgePortShim as B, type DensityPayload as D, type LocalePayload as L, type McpTransport as M, type NavPayload as N, type PortBridgeClient as P, type SessionTokenPayload as S, type ThemePayload as T, createPortBridgeClient as c };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Wire-shape constants and type guards for host→plugin bridge push messages
3
+ * (WI 4858 sub-plan 2). These types ride the existing MessagePort channel
4
+ * alongside the `ethisys:mcp:*` and `ethisys:remotedom` envelopes — the
5
+ * `type` discriminant keeps them fully separate at the dispatch site.
6
+ *
7
+ * The host posts bridge pushes through `WorkerRemoteDomTransport.hostPort`;
8
+ * the plugin side reads them in `createPortBridgeClient` (plugin-ui package).
9
+ * In-realm (Tier T) plugins use `InMemoryBridgeTransport` which calls the
10
+ * subscriber callbacks directly — no serialisation needed.
11
+ */
12
+ declare const BRIDGE_PUSH_THEME: "ethisys:bridge:theme";
13
+ declare const BRIDGE_PUSH_LOCALE: "ethisys:bridge:locale";
14
+ declare const BRIDGE_PUSH_DENSITY: "ethisys:bridge:density";
15
+ declare const BRIDGE_PUSH_A11Y: "ethisys:bridge:a11y";
16
+ declare const BRIDGE_NAV_PUSH: "ethisys:bridge:nav";
17
+ declare const BRIDGE_SESSION_TOKEN_PUSH: "ethisys:bridge:token";
18
+ /** Theme push: host → plugin whenever the host theme changes. */
19
+ interface BridgePushThemeEnvelope {
20
+ readonly type: typeof BRIDGE_PUSH_THEME;
21
+ /** "light" | "dark" | "high-contrast" */
22
+ readonly mode: string;
23
+ /** Flat design-token map (CSS-variable-name → value). May be empty. */
24
+ readonly tokens: Readonly<Record<string, string>>;
25
+ }
26
+ /** Locale push: host → plugin whenever the active locale changes. */
27
+ interface BridgePushLocaleEnvelope {
28
+ readonly type: typeof BRIDGE_PUSH_LOCALE;
29
+ /** BCP 47 tag, e.g. "en-GB". */
30
+ readonly locale: string;
31
+ /** "ltr" | "rtl" */
32
+ readonly dir: "ltr" | "rtl";
33
+ }
34
+ /** Density push: host → plugin when the UI density preference changes. */
35
+ interface BridgePushDensityEnvelope {
36
+ readonly type: typeof BRIDGE_PUSH_DENSITY;
37
+ /** "comfortable" | "compact" */
38
+ readonly density: string;
39
+ }
40
+ /** A11y push: host → plugin when accessibility prefs change. */
41
+ interface BridgePushA11yEnvelope {
42
+ readonly type: typeof BRIDGE_PUSH_A11Y;
43
+ readonly reducedMotion: boolean;
44
+ readonly highContrast: boolean;
45
+ }
46
+ /** Nav push: host → plugin when the SPA location changes. */
47
+ interface BridgeNavPushEnvelope {
48
+ readonly type: typeof BRIDGE_NAV_PUSH;
49
+ /** Current SPA path (pathname + search). */
50
+ readonly path: string;
51
+ /** `window.history.length` at the time of push. */
52
+ readonly historyLength: number;
53
+ }
54
+ /** Host → plugin: push a short-lived frontend-session token. */
55
+ interface BridgeSessionTokenPushEnvelope {
56
+ readonly type: typeof BRIDGE_SESSION_TOKEN_PUSH;
57
+ /** Opaque JWT string — audience-restricted to the plugin's own backend. */
58
+ readonly token: string;
59
+ /** Absolute epoch-ms at which the token expires. Refresh fires 30 s before. */
60
+ readonly expiresAtMs: number;
61
+ }
62
+
63
+ export type { BridgeNavPushEnvelope as B, BridgePushA11yEnvelope as a, BridgePushDensityEnvelope as b, BridgePushLocaleEnvelope as c, BridgePushThemeEnvelope as d, BridgeSessionTokenPushEnvelope as e };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Wire-shape constants and type guards for host→plugin bridge push messages
3
+ * (WI 4858 sub-plan 2). These types ride the existing MessagePort channel
4
+ * alongside the `ethisys:mcp:*` and `ethisys:remotedom` envelopes — the
5
+ * `type` discriminant keeps them fully separate at the dispatch site.
6
+ *
7
+ * The host posts bridge pushes through `WorkerRemoteDomTransport.hostPort`;
8
+ * the plugin side reads them in `createPortBridgeClient` (plugin-ui package).
9
+ * In-realm (Tier T) plugins use `InMemoryBridgeTransport` which calls the
10
+ * subscriber callbacks directly — no serialisation needed.
11
+ */
12
+ declare const BRIDGE_PUSH_THEME: "ethisys:bridge:theme";
13
+ declare const BRIDGE_PUSH_LOCALE: "ethisys:bridge:locale";
14
+ declare const BRIDGE_PUSH_DENSITY: "ethisys:bridge:density";
15
+ declare const BRIDGE_PUSH_A11Y: "ethisys:bridge:a11y";
16
+ declare const BRIDGE_NAV_PUSH: "ethisys:bridge:nav";
17
+ declare const BRIDGE_SESSION_TOKEN_PUSH: "ethisys:bridge:token";
18
+ /** Theme push: host → plugin whenever the host theme changes. */
19
+ interface BridgePushThemeEnvelope {
20
+ readonly type: typeof BRIDGE_PUSH_THEME;
21
+ /** "light" | "dark" | "high-contrast" */
22
+ readonly mode: string;
23
+ /** Flat design-token map (CSS-variable-name → value). May be empty. */
24
+ readonly tokens: Readonly<Record<string, string>>;
25
+ }
26
+ /** Locale push: host → plugin whenever the active locale changes. */
27
+ interface BridgePushLocaleEnvelope {
28
+ readonly type: typeof BRIDGE_PUSH_LOCALE;
29
+ /** BCP 47 tag, e.g. "en-GB". */
30
+ readonly locale: string;
31
+ /** "ltr" | "rtl" */
32
+ readonly dir: "ltr" | "rtl";
33
+ }
34
+ /** Density push: host → plugin when the UI density preference changes. */
35
+ interface BridgePushDensityEnvelope {
36
+ readonly type: typeof BRIDGE_PUSH_DENSITY;
37
+ /** "comfortable" | "compact" */
38
+ readonly density: string;
39
+ }
40
+ /** A11y push: host → plugin when accessibility prefs change. */
41
+ interface BridgePushA11yEnvelope {
42
+ readonly type: typeof BRIDGE_PUSH_A11Y;
43
+ readonly reducedMotion: boolean;
44
+ readonly highContrast: boolean;
45
+ }
46
+ /** Nav push: host → plugin when the SPA location changes. */
47
+ interface BridgeNavPushEnvelope {
48
+ readonly type: typeof BRIDGE_NAV_PUSH;
49
+ /** Current SPA path (pathname + search). */
50
+ readonly path: string;
51
+ /** `window.history.length` at the time of push. */
52
+ readonly historyLength: number;
53
+ }
54
+ /** Host → plugin: push a short-lived frontend-session token. */
55
+ interface BridgeSessionTokenPushEnvelope {
56
+ readonly type: typeof BRIDGE_SESSION_TOKEN_PUSH;
57
+ /** Opaque JWT string — audience-restricted to the plugin's own backend. */
58
+ readonly token: string;
59
+ /** Absolute epoch-ms at which the token expires. Refresh fires 30 s before. */
60
+ readonly expiresAtMs: number;
61
+ }
62
+
63
+ export type { BridgeNavPushEnvelope as B, BridgePushA11yEnvelope as a, BridgePushDensityEnvelope as b, BridgePushLocaleEnvelope as c, BridgePushThemeEnvelope as d, BridgeSessionTokenPushEnvelope as e };
@@ -2,6 +2,7 @@
2
2
 
3
3
  var protocol = require('@ethisyscore/protocol');
4
4
  var react = require('react');
5
+ var zod = require('zod');
5
6
  var receivers = require('@remote-dom/core/receivers');
6
7
  var host = require('@remote-dom/react/host');
7
8
 
@@ -286,6 +287,16 @@ function createInputEventCoalescer(sink, options = {}) {
286
287
  };
287
288
  }
288
289
 
290
+ // src/host/worker/bridge-envelopes.ts
291
+ var BRIDGE_PUSH_THEME = "ethisys:bridge:theme";
292
+ var BRIDGE_PUSH_LOCALE = "ethisys:bridge:locale";
293
+ var BRIDGE_PUSH_DENSITY = "ethisys:bridge:density";
294
+ var BRIDGE_PUSH_A11Y = "ethisys:bridge:a11y";
295
+ var BRIDGE_NAV_PUSH = "ethisys:bridge:nav";
296
+ var BRIDGE_CHROME_REQUEST = "ethisys:bridge:chrome:request";
297
+ var BRIDGE_LIFECYCLE_EVENT = "ethisys:bridge:lifecycle";
298
+ var BRIDGE_A11Y_ANNOUNCE = "ethisys:bridge:a11y:announce";
299
+
289
300
  // src/host/worker/transport.ts
290
301
  var WORKER_TRANSPORT_PROTOCOL = "ethisys.worker.remotedom.v1";
291
302
  var DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = 8;
@@ -300,6 +311,7 @@ var WorkerRemoteDomTransport = class {
300
311
  abortController;
301
312
  inFlightMcpRequests = 0;
302
313
  remoteDomConsumer;
314
+ bridgeMessageConsumer;
303
315
  disposed = false;
304
316
  connected = false;
305
317
  constructor(options) {
@@ -436,6 +448,39 @@ var WorkerRemoteDomTransport = class {
436
448
  }
437
449
  };
438
450
  }
451
+ // ─── Bridge push methods (WI 4858 sub-plan 2) ─────────────────────────────
452
+ /**
453
+ * Register a consumer for inbound plugin→host bridge messages
454
+ * (chrome requests, a11y announce, lifecycle events). Only the most recently
455
+ * registered consumer is kept — the host mount wires exactly one.
456
+ */
457
+ onBridgeMessage(consumer) {
458
+ this.bridgeMessageConsumer = consumer;
459
+ }
460
+ /**
461
+ * Push the current host theme to the plugin worker. Safe to call on every
462
+ * host theme-context change — the transport coalesces nothing here; rate
463
+ * limiting at the call site is the host's responsibility.
464
+ */
465
+ pushTheme(payload) {
466
+ this.safePostMessage({ type: BRIDGE_PUSH_THEME, ...payload });
467
+ }
468
+ /** Push the current host locale and text direction to the plugin worker. */
469
+ pushLocale(payload) {
470
+ this.safePostMessage({ type: BRIDGE_PUSH_LOCALE, ...payload });
471
+ }
472
+ /** Push the current UI density preference to the plugin worker. */
473
+ pushDensity(payload) {
474
+ this.safePostMessage({ type: BRIDGE_PUSH_DENSITY, ...payload });
475
+ }
476
+ /** Push updated accessibility preferences to the plugin worker. */
477
+ pushA11y(payload) {
478
+ this.safePostMessage({ type: BRIDGE_PUSH_A11Y, ...payload });
479
+ }
480
+ /** Push the current SPA navigation state to the plugin worker. */
481
+ pushNav(payload) {
482
+ this.safePostMessage({ type: BRIDGE_NAV_PUSH, ...payload });
483
+ }
439
484
  /**
440
485
  * Tear down the worker and port. Idempotent.
441
486
  *
@@ -492,6 +537,11 @@ var WorkerRemoteDomTransport = class {
492
537
  case "ethisys:remotedom":
493
538
  this.remoteDomConsumer?.(message.payload);
494
539
  return;
540
+ case BRIDGE_CHROME_REQUEST:
541
+ case BRIDGE_A11Y_ANNOUNCE:
542
+ case BRIDGE_LIFECYCLE_EVENT:
543
+ this.bridgeMessageConsumer?.(message);
544
+ return;
495
545
  default:
496
546
  return;
497
547
  }
@@ -581,6 +631,253 @@ var WorkerRemoteDomTransport = class {
581
631
  this.safePostMessage({ id, type, ok: false, error: message });
582
632
  }
583
633
  };
634
+ var IFRAME_BRIDGE_PROTOCOL = "ethisys.iframe.bridge.v1";
635
+ var InboundEnvelope = zod.z.discriminatedUnion("type", [
636
+ zod.z.object({ type: zod.z.literal("ethisys:mcp:invokeTool"), id: zod.z.string().min(1), name: zod.z.string().min(1), args: zod.z.unknown(), nonce: zod.z.string() }),
637
+ zod.z.object({ type: zod.z.literal("ethisys:mcp:getResource"), id: zod.z.string().min(1), uri: zod.z.string().min(1), nonce: zod.z.string() }),
638
+ zod.z.object({ type: zod.z.literal("ethisys:event"), name: zod.z.string().min(1), payload: zod.z.unknown(), nonce: zod.z.string() })
639
+ ]);
640
+ function base64url(bytes) {
641
+ let binary = "";
642
+ for (const b of bytes) {
643
+ binary += String.fromCharCode(b);
644
+ }
645
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
646
+ }
647
+ function mintNonce() {
648
+ const bytes = new Uint8Array(32);
649
+ crypto.getRandomValues(bytes);
650
+ return base64url(bytes);
651
+ }
652
+ var IframeBridgeTransport = class {
653
+ frameWindowRef;
654
+ targetOrigin;
655
+ capabilityTokenProvider;
656
+ mcpClient;
657
+ maxConcurrentMcpRequests;
658
+ maxMessagesPerSecond;
659
+ onSecurityEvent;
660
+ abortController = new AbortController();
661
+ hostPort;
662
+ framePort;
663
+ nonce;
664
+ eventConsumer;
665
+ inFlightMcpRequests = 0;
666
+ windowTimestamps = [];
667
+ connected = false;
668
+ disposed = false;
669
+ constructor(options) {
670
+ this.frameWindowRef = { current: options.frameWindow };
671
+ this.targetOrigin = options.targetOrigin;
672
+ this.capabilityTokenProvider = options.capabilityToken;
673
+ this.mcpClient = options.mcpClient;
674
+ this.maxConcurrentMcpRequests = Math.max(1, options.maxConcurrentMcpRequests ?? DEFAULT_MAX_CONCURRENT_MCP_REQUESTS);
675
+ this.maxMessagesPerSecond = Math.max(1, options.maxMessagesPerSecond ?? 64);
676
+ this.onSecurityEvent = options.onSecurityEvent;
677
+ }
678
+ /**
679
+ * Post the handshake to the frame. Idempotent — only the FIRST call mints the
680
+ * nonce, creates the channel, wires the host port, and transfers the frame
681
+ * port. The handshake uses the exact `targetOrigin` (never `"*"`).
682
+ */
683
+ connect() {
684
+ if (this.connected || this.disposed) {
685
+ return;
686
+ }
687
+ this.connected = true;
688
+ this.nonce = mintNonce();
689
+ const channel = new MessageChannel();
690
+ this.hostPort = channel.port1;
691
+ this.framePort = channel.port2;
692
+ this.hostPort.onmessage = (ev) => this.handlePortMessage(ev.data);
693
+ this.hostPort.start();
694
+ const handshake = {
695
+ type: "ethisys:iframe:handshake",
696
+ protocol: IFRAME_BRIDGE_PROTOCOL,
697
+ nonce: this.nonce
698
+ };
699
+ const frameWindow = this.frameWindowRef.current;
700
+ frameWindow?.postMessage(handshake, this.targetOrigin, [this.framePort]);
701
+ }
702
+ /**
703
+ * The per-mount handshake nonce (set after {@link connect}, `undefined` before
704
+ * connect / after dispose). The host mount reads this to validate inbound
705
+ * cross-origin `window`-level messages (which carry no MessagePort identity).
706
+ */
707
+ get connectedNonce() {
708
+ return this.nonce;
709
+ }
710
+ /** Register a consumer for inbound plugin→host `ethisys:event` envelopes. */
711
+ onEvent(consumer) {
712
+ this.eventConsumer = consumer;
713
+ }
714
+ /**
715
+ * Push a host→plugin envelope (theme/nav/chrome). Always uses the exact
716
+ * `targetOrigin` (never `"*"`) and stamps the handshake nonce.
717
+ */
718
+ postOutbound(envelope) {
719
+ if (this.disposed || this.nonce === void 0) {
720
+ return;
721
+ }
722
+ const frameWindow = this.frameWindowRef.current;
723
+ frameWindow?.postMessage({ ...envelope, nonce: this.nonce }, this.targetOrigin);
724
+ }
725
+ /** Tear down. Idempotent. Does NOT remove the iframe (the host mount owns it). */
726
+ dispose() {
727
+ if (this.disposed) {
728
+ return;
729
+ }
730
+ this.disposed = true;
731
+ try {
732
+ this.abortController.abort();
733
+ } catch {
734
+ }
735
+ try {
736
+ this.hostPort?.close();
737
+ } catch {
738
+ }
739
+ this.nonce = void 0;
740
+ this.frameWindowRef.current = null;
741
+ }
742
+ // ─── Inbound port message handling ────────────────────────────────────────
743
+ handlePortMessage(data) {
744
+ if (this.disposed) {
745
+ return;
746
+ }
747
+ const parsed = InboundEnvelope.safeParse(data);
748
+ if (!parsed.success) {
749
+ this.onSecurityEvent?.({ reason: "schema-invalid" });
750
+ return;
751
+ }
752
+ const message = parsed.data;
753
+ if (message.nonce !== this.nonce) {
754
+ this.onSecurityEvent?.({ reason: "nonce-mismatch" });
755
+ return;
756
+ }
757
+ if (this.isRateLimited()) {
758
+ this.onSecurityEvent?.({ reason: "rate-limit-exceeded", type: message.type });
759
+ if (message.type === "ethisys:mcp:invokeTool" || message.type === "ethisys:mcp:getResource") {
760
+ this.safePostMessage({
761
+ id: message.id,
762
+ type: `${message.type}:result`,
763
+ ok: false,
764
+ error: "iframe-bridge rate limit exceeded"
765
+ });
766
+ }
767
+ return;
768
+ }
769
+ switch (message.type) {
770
+ case "ethisys:mcp:invokeTool":
771
+ this.dispatchMcp(message, "ethisys:mcp:invokeTool:result", (m) => this.handleInvokeTool(m));
772
+ return;
773
+ case "ethisys:mcp:getResource":
774
+ this.dispatchMcp(message, "ethisys:mcp:getResource:result", (m) => this.handleGetResource(m));
775
+ return;
776
+ case "ethisys:event":
777
+ this.eventConsumer?.(message.name, message.payload);
778
+ return;
779
+ }
780
+ }
781
+ isRateLimited() {
782
+ const now = Date.now();
783
+ const cutoff = now - 1e3;
784
+ this.windowTimestamps = this.windowTimestamps.filter((t) => t > cutoff);
785
+ if (this.windowTimestamps.length >= this.maxMessagesPerSecond) {
786
+ return true;
787
+ }
788
+ this.windowTimestamps.push(now);
789
+ return false;
790
+ }
791
+ // ─── MCP brokering (structurally identical to the worker transport) ───────
792
+ dispatchMcp(message, resultType, handler) {
793
+ if (this.inFlightMcpRequests >= this.maxConcurrentMcpRequests) {
794
+ this.safePostMessage({
795
+ id: message.id,
796
+ type: resultType,
797
+ ok: false,
798
+ error: `MCP back-pressure: in-flight cap of ${this.maxConcurrentMcpRequests} reached.`
799
+ });
800
+ return;
801
+ }
802
+ this.inFlightMcpRequests++;
803
+ handler(message).finally(() => {
804
+ this.inFlightMcpRequests = Math.max(0, this.inFlightMcpRequests - 1);
805
+ });
806
+ }
807
+ async handleInvokeTool(message) {
808
+ let token;
809
+ try {
810
+ token = await this.capabilityTokenProvider();
811
+ } catch (err) {
812
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
813
+ return;
814
+ }
815
+ if (this.disposed) {
816
+ return;
817
+ }
818
+ try {
819
+ const result = await this.mcpClient.fetch({
820
+ kind: "invokeTool",
821
+ name: message.name,
822
+ args: message.args,
823
+ capabilityToken: token,
824
+ signal: this.abortController.signal
825
+ });
826
+ this.safePostMessage({
827
+ id: message.id,
828
+ type: "ethisys:mcp:invokeTool:result",
829
+ ok: result.ok,
830
+ data: result.data,
831
+ error: result.error
832
+ });
833
+ } catch (err) {
834
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
835
+ }
836
+ }
837
+ async handleGetResource(message) {
838
+ let token;
839
+ try {
840
+ token = await this.capabilityTokenProvider();
841
+ } catch (err) {
842
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
843
+ return;
844
+ }
845
+ if (this.disposed) {
846
+ return;
847
+ }
848
+ try {
849
+ const result = await this.mcpClient.fetch({
850
+ kind: "getResource",
851
+ uri: message.uri,
852
+ capabilityToken: token,
853
+ signal: this.abortController.signal
854
+ });
855
+ this.safePostMessage({
856
+ id: message.id,
857
+ type: "ethisys:mcp:getResource:result",
858
+ ok: result.ok,
859
+ data: result.data,
860
+ error: result.error
861
+ });
862
+ } catch (err) {
863
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
864
+ }
865
+ }
866
+ replyError(id, type, err) {
867
+ const message = err instanceof Error ? err.message : "MCP request failed";
868
+ this.safePostMessage({ id, type, ok: false, error: message });
869
+ }
870
+ /** Best-effort reply over the host port; tolerates post-dispose races. */
871
+ safePostMessage(message) {
872
+ if (this.disposed || this.hostPort === void 0) {
873
+ return;
874
+ }
875
+ try {
876
+ this.hostPort.postMessage(message);
877
+ } catch {
878
+ }
879
+ }
880
+ };
584
881
 
585
882
  Object.defineProperty(exports, "RemoteReceiver", {
586
883
  enumerable: true,
@@ -597,6 +894,8 @@ Object.defineProperty(exports, "createRemoteComponentRenderer", {
597
894
  exports.CONTRACT_B_PRIMITIVES = CONTRACT_B_PRIMITIVES;
598
895
  exports.DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = DEFAULT_MAX_CONCURRENT_MCP_REQUESTS;
599
896
  exports.DEFAULT_OFFSCREEN_COALESCE_MS = DEFAULT_OFFSCREEN_COALESCE_MS;
897
+ exports.IFRAME_BRIDGE_PROTOCOL = IFRAME_BRIDGE_PROTOCOL;
898
+ exports.IframeBridgeTransport = IframeBridgeTransport;
600
899
  exports.SemanticComponentRegistry = SemanticComponentRegistry;
601
900
  exports.WORKER_TRANSPORT_PROTOCOL = WORKER_TRANSPORT_PROTOCOL;
602
901
  exports.WorkerRemoteDomTransport = WorkerRemoteDomTransport;