@ethisyscore/extension-runtime 1.9.0 → 1.10.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.
@@ -1,5 +1,6 @@
1
1
  import { KNOWN_OPERATORS } from '@ethisyscore/protocol';
2
2
  import { createElement } from 'react';
3
+ import { z } from 'zod';
3
4
  export { RemoteReceiver } from '@remote-dom/core/receivers';
4
5
  export { RemoteRootRenderer, createRemoteComponentRenderer } from '@remote-dom/react/host';
5
6
 
@@ -284,6 +285,16 @@ function createInputEventCoalescer(sink, options = {}) {
284
285
  };
285
286
  }
286
287
 
288
+ // src/host/worker/bridge-envelopes.ts
289
+ var BRIDGE_PUSH_THEME = "ethisys:bridge:theme";
290
+ var BRIDGE_PUSH_LOCALE = "ethisys:bridge:locale";
291
+ var BRIDGE_PUSH_DENSITY = "ethisys:bridge:density";
292
+ var BRIDGE_PUSH_A11Y = "ethisys:bridge:a11y";
293
+ var BRIDGE_NAV_PUSH = "ethisys:bridge:nav";
294
+ var BRIDGE_CHROME_REQUEST = "ethisys:bridge:chrome:request";
295
+ var BRIDGE_LIFECYCLE_EVENT = "ethisys:bridge:lifecycle";
296
+ var BRIDGE_A11Y_ANNOUNCE = "ethisys:bridge:a11y:announce";
297
+
287
298
  // src/host/worker/transport.ts
288
299
  var WORKER_TRANSPORT_PROTOCOL = "ethisys.worker.remotedom.v1";
289
300
  var DEFAULT_MAX_CONCURRENT_MCP_REQUESTS = 8;
@@ -298,6 +309,7 @@ var WorkerRemoteDomTransport = class {
298
309
  abortController;
299
310
  inFlightMcpRequests = 0;
300
311
  remoteDomConsumer;
312
+ bridgeMessageConsumer;
301
313
  disposed = false;
302
314
  connected = false;
303
315
  constructor(options) {
@@ -434,6 +446,39 @@ var WorkerRemoteDomTransport = class {
434
446
  }
435
447
  };
436
448
  }
449
+ // ─── Bridge push methods (WI 4858 sub-plan 2) ─────────────────────────────
450
+ /**
451
+ * Register a consumer for inbound plugin→host bridge messages
452
+ * (chrome requests, a11y announce, lifecycle events). Only the most recently
453
+ * registered consumer is kept — the host mount wires exactly one.
454
+ */
455
+ onBridgeMessage(consumer) {
456
+ this.bridgeMessageConsumer = consumer;
457
+ }
458
+ /**
459
+ * Push the current host theme to the plugin worker. Safe to call on every
460
+ * host theme-context change — the transport coalesces nothing here; rate
461
+ * limiting at the call site is the host's responsibility.
462
+ */
463
+ pushTheme(payload) {
464
+ this.safePostMessage({ type: BRIDGE_PUSH_THEME, ...payload });
465
+ }
466
+ /** Push the current host locale and text direction to the plugin worker. */
467
+ pushLocale(payload) {
468
+ this.safePostMessage({ type: BRIDGE_PUSH_LOCALE, ...payload });
469
+ }
470
+ /** Push the current UI density preference to the plugin worker. */
471
+ pushDensity(payload) {
472
+ this.safePostMessage({ type: BRIDGE_PUSH_DENSITY, ...payload });
473
+ }
474
+ /** Push updated accessibility preferences to the plugin worker. */
475
+ pushA11y(payload) {
476
+ this.safePostMessage({ type: BRIDGE_PUSH_A11Y, ...payload });
477
+ }
478
+ /** Push the current SPA navigation state to the plugin worker. */
479
+ pushNav(payload) {
480
+ this.safePostMessage({ type: BRIDGE_NAV_PUSH, ...payload });
481
+ }
437
482
  /**
438
483
  * Tear down the worker and port. Idempotent.
439
484
  *
@@ -490,6 +535,11 @@ var WorkerRemoteDomTransport = class {
490
535
  case "ethisys:remotedom":
491
536
  this.remoteDomConsumer?.(message.payload);
492
537
  return;
538
+ case BRIDGE_CHROME_REQUEST:
539
+ case BRIDGE_A11Y_ANNOUNCE:
540
+ case BRIDGE_LIFECYCLE_EVENT:
541
+ this.bridgeMessageConsumer?.(message);
542
+ return;
493
543
  default:
494
544
  return;
495
545
  }
@@ -579,7 +629,254 @@ var WorkerRemoteDomTransport = class {
579
629
  this.safePostMessage({ id, type, ok: false, error: message });
580
630
  }
581
631
  };
632
+ var IFRAME_BRIDGE_PROTOCOL = "ethisys.iframe.bridge.v1";
633
+ var InboundEnvelope = z.discriminatedUnion("type", [
634
+ z.object({ type: z.literal("ethisys:mcp:invokeTool"), id: z.string().min(1), name: z.string().min(1), args: z.unknown(), nonce: z.string() }),
635
+ z.object({ type: z.literal("ethisys:mcp:getResource"), id: z.string().min(1), uri: z.string().min(1), nonce: z.string() }),
636
+ z.object({ type: z.literal("ethisys:event"), name: z.string().min(1), payload: z.unknown(), nonce: z.string() })
637
+ ]);
638
+ function base64url(bytes) {
639
+ let binary = "";
640
+ for (const b of bytes) {
641
+ binary += String.fromCharCode(b);
642
+ }
643
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
644
+ }
645
+ function mintNonce() {
646
+ const bytes = new Uint8Array(32);
647
+ crypto.getRandomValues(bytes);
648
+ return base64url(bytes);
649
+ }
650
+ var IframeBridgeTransport = class {
651
+ frameWindowRef;
652
+ targetOrigin;
653
+ capabilityTokenProvider;
654
+ mcpClient;
655
+ maxConcurrentMcpRequests;
656
+ maxMessagesPerSecond;
657
+ onSecurityEvent;
658
+ abortController = new AbortController();
659
+ hostPort;
660
+ framePort;
661
+ nonce;
662
+ eventConsumer;
663
+ inFlightMcpRequests = 0;
664
+ windowTimestamps = [];
665
+ connected = false;
666
+ disposed = false;
667
+ constructor(options) {
668
+ this.frameWindowRef = { current: options.frameWindow };
669
+ this.targetOrigin = options.targetOrigin;
670
+ this.capabilityTokenProvider = options.capabilityToken;
671
+ this.mcpClient = options.mcpClient;
672
+ this.maxConcurrentMcpRequests = Math.max(1, options.maxConcurrentMcpRequests ?? DEFAULT_MAX_CONCURRENT_MCP_REQUESTS);
673
+ this.maxMessagesPerSecond = Math.max(1, options.maxMessagesPerSecond ?? 64);
674
+ this.onSecurityEvent = options.onSecurityEvent;
675
+ }
676
+ /**
677
+ * Post the handshake to the frame. Idempotent — only the FIRST call mints the
678
+ * nonce, creates the channel, wires the host port, and transfers the frame
679
+ * port. The handshake uses the exact `targetOrigin` (never `"*"`).
680
+ */
681
+ connect() {
682
+ if (this.connected || this.disposed) {
683
+ return;
684
+ }
685
+ this.connected = true;
686
+ this.nonce = mintNonce();
687
+ const channel = new MessageChannel();
688
+ this.hostPort = channel.port1;
689
+ this.framePort = channel.port2;
690
+ this.hostPort.onmessage = (ev) => this.handlePortMessage(ev.data);
691
+ this.hostPort.start();
692
+ const handshake = {
693
+ type: "ethisys:iframe:handshake",
694
+ protocol: IFRAME_BRIDGE_PROTOCOL,
695
+ nonce: this.nonce
696
+ };
697
+ const frameWindow = this.frameWindowRef.current;
698
+ frameWindow?.postMessage(handshake, this.targetOrigin, [this.framePort]);
699
+ }
700
+ /**
701
+ * The per-mount handshake nonce (set after {@link connect}, `undefined` before
702
+ * connect / after dispose). The host mount reads this to validate inbound
703
+ * cross-origin `window`-level messages (which carry no MessagePort identity).
704
+ */
705
+ get connectedNonce() {
706
+ return this.nonce;
707
+ }
708
+ /** Register a consumer for inbound plugin→host `ethisys:event` envelopes. */
709
+ onEvent(consumer) {
710
+ this.eventConsumer = consumer;
711
+ }
712
+ /**
713
+ * Push a host→plugin envelope (theme/nav/chrome). Always uses the exact
714
+ * `targetOrigin` (never `"*"`) and stamps the handshake nonce.
715
+ */
716
+ postOutbound(envelope) {
717
+ if (this.disposed || this.nonce === void 0) {
718
+ return;
719
+ }
720
+ const frameWindow = this.frameWindowRef.current;
721
+ frameWindow?.postMessage({ ...envelope, nonce: this.nonce }, this.targetOrigin);
722
+ }
723
+ /** Tear down. Idempotent. Does NOT remove the iframe (the host mount owns it). */
724
+ dispose() {
725
+ if (this.disposed) {
726
+ return;
727
+ }
728
+ this.disposed = true;
729
+ try {
730
+ this.abortController.abort();
731
+ } catch {
732
+ }
733
+ try {
734
+ this.hostPort?.close();
735
+ } catch {
736
+ }
737
+ this.nonce = void 0;
738
+ this.frameWindowRef.current = null;
739
+ }
740
+ // ─── Inbound port message handling ────────────────────────────────────────
741
+ handlePortMessage(data) {
742
+ if (this.disposed) {
743
+ return;
744
+ }
745
+ const parsed = InboundEnvelope.safeParse(data);
746
+ if (!parsed.success) {
747
+ this.onSecurityEvent?.({ reason: "schema-invalid" });
748
+ return;
749
+ }
750
+ const message = parsed.data;
751
+ if (message.nonce !== this.nonce) {
752
+ this.onSecurityEvent?.({ reason: "nonce-mismatch" });
753
+ return;
754
+ }
755
+ if (this.isRateLimited()) {
756
+ this.onSecurityEvent?.({ reason: "rate-limit-exceeded", type: message.type });
757
+ if (message.type === "ethisys:mcp:invokeTool" || message.type === "ethisys:mcp:getResource") {
758
+ this.safePostMessage({
759
+ id: message.id,
760
+ type: `${message.type}:result`,
761
+ ok: false,
762
+ error: "iframe-bridge rate limit exceeded"
763
+ });
764
+ }
765
+ return;
766
+ }
767
+ switch (message.type) {
768
+ case "ethisys:mcp:invokeTool":
769
+ this.dispatchMcp(message, "ethisys:mcp:invokeTool:result", (m) => this.handleInvokeTool(m));
770
+ return;
771
+ case "ethisys:mcp:getResource":
772
+ this.dispatchMcp(message, "ethisys:mcp:getResource:result", (m) => this.handleGetResource(m));
773
+ return;
774
+ case "ethisys:event":
775
+ this.eventConsumer?.(message.name, message.payload);
776
+ return;
777
+ }
778
+ }
779
+ isRateLimited() {
780
+ const now = Date.now();
781
+ const cutoff = now - 1e3;
782
+ this.windowTimestamps = this.windowTimestamps.filter((t) => t > cutoff);
783
+ if (this.windowTimestamps.length >= this.maxMessagesPerSecond) {
784
+ return true;
785
+ }
786
+ this.windowTimestamps.push(now);
787
+ return false;
788
+ }
789
+ // ─── MCP brokering (structurally identical to the worker transport) ───────
790
+ dispatchMcp(message, resultType, handler) {
791
+ if (this.inFlightMcpRequests >= this.maxConcurrentMcpRequests) {
792
+ this.safePostMessage({
793
+ id: message.id,
794
+ type: resultType,
795
+ ok: false,
796
+ error: `MCP back-pressure: in-flight cap of ${this.maxConcurrentMcpRequests} reached.`
797
+ });
798
+ return;
799
+ }
800
+ this.inFlightMcpRequests++;
801
+ handler(message).finally(() => {
802
+ this.inFlightMcpRequests = Math.max(0, this.inFlightMcpRequests - 1);
803
+ });
804
+ }
805
+ async handleInvokeTool(message) {
806
+ let token;
807
+ try {
808
+ token = await this.capabilityTokenProvider();
809
+ } catch (err) {
810
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
811
+ return;
812
+ }
813
+ if (this.disposed) {
814
+ return;
815
+ }
816
+ try {
817
+ const result = await this.mcpClient.fetch({
818
+ kind: "invokeTool",
819
+ name: message.name,
820
+ args: message.args,
821
+ capabilityToken: token,
822
+ signal: this.abortController.signal
823
+ });
824
+ this.safePostMessage({
825
+ id: message.id,
826
+ type: "ethisys:mcp:invokeTool:result",
827
+ ok: result.ok,
828
+ data: result.data,
829
+ error: result.error
830
+ });
831
+ } catch (err) {
832
+ this.replyError(message.id, "ethisys:mcp:invokeTool:result", err);
833
+ }
834
+ }
835
+ async handleGetResource(message) {
836
+ let token;
837
+ try {
838
+ token = await this.capabilityTokenProvider();
839
+ } catch (err) {
840
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
841
+ return;
842
+ }
843
+ if (this.disposed) {
844
+ return;
845
+ }
846
+ try {
847
+ const result = await this.mcpClient.fetch({
848
+ kind: "getResource",
849
+ uri: message.uri,
850
+ capabilityToken: token,
851
+ signal: this.abortController.signal
852
+ });
853
+ this.safePostMessage({
854
+ id: message.id,
855
+ type: "ethisys:mcp:getResource:result",
856
+ ok: result.ok,
857
+ data: result.data,
858
+ error: result.error
859
+ });
860
+ } catch (err) {
861
+ this.replyError(message.id, "ethisys:mcp:getResource:result", err);
862
+ }
863
+ }
864
+ replyError(id, type, err) {
865
+ const message = err instanceof Error ? err.message : "MCP request failed";
866
+ this.safePostMessage({ id, type, ok: false, error: message });
867
+ }
868
+ /** Best-effort reply over the host port; tolerates post-dispose races. */
869
+ safePostMessage(message) {
870
+ if (this.disposed || this.hostPort === void 0) {
871
+ return;
872
+ }
873
+ try {
874
+ this.hostPort.postMessage(message);
875
+ } catch {
876
+ }
877
+ }
878
+ };
582
879
 
583
- export { CONTRACT_B_PRIMITIVES, DEFAULT_MAX_CONCURRENT_MCP_REQUESTS, DEFAULT_OFFSCREEN_COALESCE_MS, SemanticComponentRegistry, WORKER_TRANSPORT_PROTOCOL, WorkerRemoteDomTransport, createInputEventCoalescer, createOffscreenCanvasTransfer, evaluate, interpret };
880
+ export { CONTRACT_B_PRIMITIVES, DEFAULT_MAX_CONCURRENT_MCP_REQUESTS, DEFAULT_OFFSCREEN_COALESCE_MS, IFRAME_BRIDGE_PROTOCOL, IframeBridgeTransport, SemanticComponentRegistry, WORKER_TRANSPORT_PROTOCOL, WorkerRemoteDomTransport, createInputEventCoalescer, createOffscreenCanvasTransfer, evaluate, interpret };
584
881
  //# sourceMappingURL=index.js.map
585
882
  //# sourceMappingURL=index.js.map