@nominalso/vibe-host 0.4.0 → 0.5.1

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.4.0";
2
+ var version = "0.5.1";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
5
  function normalizeSubroute(subroute) {
@@ -15,7 +15,7 @@ function normalizeSubroute(subroute) {
15
15
  return normalized;
16
16
  }
17
17
  var PROTOCOL_ID = "nominal-vibe-bridge";
18
- var PROTOCOL_VERSION = 1;
18
+ var PROTOCOL_VERSION = 2;
19
19
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
20
20
  var CORRELATED_KINDS = ["request", "response", "progress"];
21
21
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -30,20 +30,46 @@ function isBridgeMessage(data) {
30
30
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
31
31
  return true;
32
32
  }
33
+ var BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:BridgeError");
34
+ var HTTP_BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:HttpBridgeError");
33
35
  var BridgeError = class extends Error {
34
36
  constructor(code, message) {
35
37
  super(message);
36
38
  this.code = code;
37
39
  this.name = "BridgeError";
38
40
  }
41
+ /**
42
+ * Cross-realm-safe type guard. Prefer this over `instanceof BridgeError` when
43
+ * an error may have been thrown by a differently-built copy of the SDK (e.g.
44
+ * server bundle vs client bundle) — it matches on a process-global brand
45
+ * rather than class identity.
46
+ */
47
+ static isBridgeError(err) {
48
+ return typeof err === "object" && err !== null && err[BRIDGE_ERROR_BRAND] === true;
49
+ }
39
50
  };
51
+ Object.defineProperty(BridgeError.prototype, BRIDGE_ERROR_BRAND, {
52
+ value: true,
53
+ enumerable: false
54
+ });
40
55
  var HttpBridgeError = class extends BridgeError {
41
56
  constructor(status, message) {
42
57
  super("REQUEST_FAILED", message);
43
58
  this.status = status;
44
59
  this.name = "HttpBridgeError";
45
60
  }
61
+ /**
62
+ * Cross-realm-safe type guard (see {@link BridgeError.isBridgeError}). Returns
63
+ * `true` only for `HttpBridgeError` instances, not plain `BridgeError`s.
64
+ */
65
+ static isHttpBridgeError(err) {
66
+ return typeof err === "object" && err !== null && err[HTTP_BRIDGE_ERROR_BRAND] === true;
67
+ }
46
68
  };
69
+ Object.defineProperty(HttpBridgeError.prototype, HTTP_BRIDGE_ERROR_BRAND, {
70
+ value: true,
71
+ enumerable: false
72
+ });
47
73
  function isOriginPattern(entry) {
48
74
  return entry.includes("*");
49
75
  }
@@ -632,18 +658,20 @@ function rejectUnwired(op) {
632
658
  var VibeAppHost = class {
633
659
  constructor(opts) {
634
660
  /**
635
- * Concrete iframe origins observed on validated inbound messages. Because you
636
- * cannot `postMessage` to a glob pattern, proactive pushes target these
637
- * learned origins (plus the exact allowlist entries) rather than the patterns.
638
- */
639
- this.connectedOrigins = /* @__PURE__ */ new Set();
640
- this.iframeWindow = null;
641
- /**
642
- * Source windows we've already logged a successful handshake for. The bridge
643
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
644
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
661
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
662
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
663
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
664
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
665
+ * and cleared together on unmount, so they can never diverge.
666
+ *
667
+ * There is deliberately no other window reference. The connected window can
668
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
669
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
670
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
671
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
645
672
  */
646
- this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
673
+ this.connectedWindow = null;
674
+ this.connectedOrigin = null;
647
675
  this.kindHandlers = {
648
676
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
649
677
  command: (msg) => this.dispatchCommand(msg)
@@ -652,6 +680,7 @@ var VibeAppHost = class {
652
680
  this.trustedOrigins = new Set(trustedOrigins);
653
681
  this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
654
682
  this.getContext = getContext;
683
+ this.getPostHog = opts.getPostHog;
655
684
  this.appBasePath = appBasePath;
656
685
  this.onRequestComplete = opts.onRequestComplete;
657
686
  this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
@@ -664,7 +693,8 @@ var VibeAppHost = class {
664
693
  this.commandHandlers = this.buildCommandHandlers(opts);
665
694
  }
666
695
  dispatchCommand(msg) {
667
- const handler = this.commandHandlers[msg.type];
696
+ const handlers = this.commandHandlers;
697
+ const handler = handlers[msg.type];
668
698
  if (!handler) {
669
699
  console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
670
700
  return;
@@ -710,31 +740,41 @@ var VibeAppHost = class {
710
740
  * Starts listening for bridge messages and sets up browser back/forward sync.
711
741
  * Returns a cleanup function to call on unmount.
712
742
  *
713
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
714
- * the host will respond to GET_CONTEXT polls from the bridge but won't
715
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
716
- * loaded to trigger the proactive push.
743
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
744
+ * handshake (and re-learns it on every reload), then delivers context as the
745
+ * reply to that handshake. Just call `mount()` once the host exists.
717
746
  */
718
- mount(iframeWindow) {
719
- if (iframeWindow) this.iframeWindow = iframeWindow;
747
+ mount() {
720
748
  window.addEventListener("message", this.boundHandleMessage);
721
749
  window.addEventListener("popstate", this.boundHandlePopState);
722
- if (iframeWindow) this.pushContext(iframeWindow);
723
750
  return () => {
724
751
  window.removeEventListener("message", this.boundHandleMessage);
725
752
  window.removeEventListener("popstate", this.boundHandlePopState);
726
- this.iframeWindow = null;
727
- this.connectedOrigins.clear();
753
+ this.connectedWindow = null;
754
+ this.connectedOrigin = null;
728
755
  };
729
756
  }
730
757
  /**
731
- * Pushes the current context to the iframe and updates the stored iframe
732
- * window reference. Call this once the iframe has loaded if `mount()` was
733
- * called without an `iframeWindow`.
758
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
759
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
760
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
761
+ * iframe is mounted.
762
+ */
763
+ refreshContext() {
764
+ if (!this.connectedWindow) return;
765
+ this.pushContext(this.connectedWindow);
766
+ }
767
+ /**
768
+ * Notifies the connected iframe of a Nominal auth change — a logout
769
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
770
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
771
+ * or signs its own backend out. No-op until the iframe has connected. When auth
772
+ * and context change together, call this *before* {@link refreshContext} ("auth
773
+ * change, then context").
734
774
  */
735
- pushContextTo(iframeWindow) {
736
- this.iframeWindow = iframeWindow;
737
- this.pushContext(iframeWindow);
775
+ notifyAuthChange(auth) {
776
+ if (!this.connectedWindow) return;
777
+ this.pushToIframe("AUTH_CHANGED", auth);
738
778
  }
739
779
  pushContext(target) {
740
780
  const message = {
@@ -749,14 +789,14 @@ var VibeAppHost = class {
749
789
  }
750
790
  }
751
791
  handlePopState() {
752
- if (!this.iframeWindow) return;
792
+ if (!this.connectedWindow) return;
753
793
  const currentPath = window.location.pathname;
754
794
  if (!currentPath.startsWith(this.appBasePath)) return;
755
795
  const subroute = currentPath.slice(this.appBasePath.length) || "/";
756
796
  this.pushToIframe("SUBROUTE_PUSH", { subroute });
757
797
  }
758
798
  pushToIframe(type, payload) {
759
- if (!this.iframeWindow) return;
799
+ if (!this.connectedWindow) return;
760
800
  const message = {
761
801
  __protocol: PROTOCOL_ID,
762
802
  __version: PROTOCOL_VERSION,
@@ -765,44 +805,95 @@ var VibeAppHost = class {
765
805
  payload
766
806
  };
767
807
  for (const origin of this.concreteTargets()) {
768
- this.iframeWindow.postMessage(message, origin);
808
+ this.connectedWindow.postMessage(message, origin);
769
809
  }
770
810
  }
811
+ /**
812
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
813
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
814
+ * nothing (recording disabled). Targets the exact handshaking window/origin
815
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
816
+ */
817
+ pushPostHog(target, origin) {
818
+ const payload = this.getPostHog?.();
819
+ if (!payload) return;
820
+ const message = {
821
+ __protocol: PROTOCOL_ID,
822
+ __version: PROTOCOL_VERSION,
823
+ kind: "push",
824
+ type: "POSTHOG_PUSH",
825
+ payload
826
+ };
827
+ target.postMessage(message, origin);
828
+ }
771
829
  async handleMessage(event) {
772
830
  if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
773
831
  if (!isBridgeMessage(event.data)) return;
774
- this.connectedOrigins.add(event.origin);
775
- await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
832
+ const msg = event.data;
833
+ const source = event.source;
834
+ if (msg.kind === "command" && msg.type === "CONNECT") {
835
+ this.handleConnect(source, event.origin);
836
+ return;
837
+ }
838
+ if (source !== this.connectedWindow) {
839
+ if (msg.kind === "request") {
840
+ this.respond(source, event.origin, {
841
+ kind: "response",
842
+ type: msg.type,
843
+ requestId: msg.requestId,
844
+ ok: false,
845
+ error: "Vibe App is not connected \u2014 call connect() first.",
846
+ code: "NOT_CONNECTED"
847
+ });
848
+ }
849
+ return;
850
+ }
851
+ await this.kindHandlers[msg.kind]?.(msg, source, event.origin);
852
+ }
853
+ /**
854
+ * Handles the CONNECT handshake: records the connecting window/origin (which
855
+ * opens the connection gate for it) and pushes the initial context + one-time
856
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
857
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
858
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
859
+ * normally. If `getContext()` throws, the window is left unconnected so the
860
+ * bridge's retry can recover.
861
+ */
862
+ handleConnect(source, origin) {
863
+ if (source === this.connectedWindow) return;
864
+ try {
865
+ const ctx = { ...this.getContext(), hostVersion: version };
866
+ this.connectedWindow = source;
867
+ this.connectedOrigin = origin;
868
+ console.log(
869
+ `[vibe-host] Vibe App connected \u2014 host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
870
+ );
871
+ const message = {
872
+ __protocol: PROTOCOL_ID,
873
+ __version: PROTOCOL_VERSION,
874
+ kind: "push",
875
+ type: "CONTEXT_PUSH",
876
+ payload: ctx
877
+ };
878
+ source.postMessage(message, origin);
879
+ this.pushPostHog(source, origin);
880
+ } catch (err) {
881
+ console.error("[vibe-host] getContext threw during connect \u2014 context not delivered.", err);
882
+ }
776
883
  }
777
884
  /**
778
885
  * Concrete origins a proactive push may target: the exact allowlist entries
779
- * plus any origins seen on validated inbound messages. Never includes glob
780
- * patterns (you cannot `postMessage` to one). When only exact origins are
781
- * configured this equals the configured set, so behaviour is unchanged.
886
+ * plus the connected iframe's learned origin. Never includes glob patterns
887
+ * (you cannot `postMessage` to one). When only exact origins are configured
888
+ * this equals the configured set, so behaviour is unchanged.
782
889
  */
783
890
  concreteTargets() {
784
- return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
891
+ const targets = new Set(this.exactOrigins);
892
+ if (this.connectedOrigin) targets.add(this.connectedOrigin);
893
+ return targets;
785
894
  }
786
895
  async handleRequest(msg, source, origin) {
787
896
  const { requestId, type } = msg;
788
- if (type === "GET_CONTEXT") {
789
- try {
790
- const ctx = { ...this.getContext(), hostVersion: version };
791
- if (!this.handshakeLoggedWindows.has(source)) {
792
- this.handshakeLoggedWindows.add(source);
793
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
794
- console.log(
795
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
796
- );
797
- }
798
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
799
- } catch (err) {
800
- const error = err instanceof Error ? err.message : String(err);
801
- const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
802
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
803
- }
804
- return;
805
- }
806
897
  if (!checkRateLimit(this.rateLimiter)) {
807
898
  this.respond(source, origin, {
808
899
  kind: "response",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-host",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Host-side SDK for embedding Nominal Vibe Apps — receives bridge requests over a typed postMessage protocol and dispatches them to Nominal APIs (used by nom-ui).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",