@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/README.md CHANGED
@@ -29,9 +29,9 @@ const host = new VibeAppHost({
29
29
  clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
30
30
  })
31
31
 
32
- // Start listening BEFORE the iframe loads, then push context on its `load` event.
32
+ // Start listening. The iframe identifies itself via its CONNECT handshake, and
33
+ // the host replies with its context — no iframe window needed up front.
33
34
  const unmount = host.mount()
34
- // once the iframe has loaded: host.pushContextTo(iframe.contentWindow)
35
35
  ```
36
36
 
37
37
  ## API
@@ -58,56 +58,40 @@ const trustedOrigins =
58
58
  : ['https://app.nominal.so', 'https://*.vercel.app', 'https://*.lovable.app']
59
59
  ```
60
60
 
61
- Because you cannot `postMessage` to a pattern, proactive context/subroute pushes target the exact entries plus any origin seen on a validated inbound message; a pattern-matched iframe still receives context via its `connect()` poll response.
61
+ Because you cannot `postMessage` to a pattern, proactive pushes target the exact allowlist entries plus the connected iframe's learned origin; a pattern-matched iframe is reached via the concrete origin learned from its `CONNECT` handshake.
62
62
 
63
- ### `mount(iframeWindow?): () => void`
63
+ ### `mount(): () => void`
64
64
 
65
- Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Pass `iframeWindow` to immediately push context; if omitted, the host answers `GET_CONTEXT` polls but won't proactively push until you call `pushContextTo`.
65
+ Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Takes no iframe window the iframe identifies itself via its `CONNECT` handshake (and re-identifies on every reload), and the host replies with its context.
66
66
 
67
- ### `pushContextTo(iframeWindow): void`
67
+ ### `refreshContext(): void`
68
68
 
69
- Pushes the current context to the iframe and stores the window reference for future pushes (e.g. subroute sync). Call once the iframe has loaded if you called `mount()` with no argument.
69
+ Re-reads `getContext()` and pushes it to the connected iframe (e.g. after a tenant/subsidiary switch), so the app reacts via `bridge.onContextChange`. No-op until the iframe has connected.
70
+
71
+ ### `notifyAuthChange(auth): void`
72
+
73
+ Pushes a Nominal auth change (logout, or identity/tenant switch) to the connected iframe, so the app reacts via `bridge.onAuthChange`. No-op until the iframe has connected.
70
74
 
71
75
  ### Exports
72
76
 
73
- `VibeAppHost`, and the types `VibeAppHostOptions`, `HostContext`, `HostHandlers`, `RequestHandlers`, `ContextPayload`, `VibeApiClientOptions`.
77
+ `VibeAppHost`, and the types `VibeAppHostOptions`, `HostContext`, `HostHandlers`, `RequestHandlers`, `ContextPayload`, `AuthPayload`, `VibeApiClientOptions`.
74
78
 
75
79
  ## Mounting inside Next.js (nom-ui)
76
80
 
77
- The iframe is server-rendered, so it may start loading before React hydrates, and cross-origin `contentDocument` is always `null`. The robust pattern: `mount()` (no arg) immediately to start listening, then `pushContextTo(iframe.contentWindow)` from the iframe's `load` event.
81
+ Just `mount()` in an effect — no iframe-window handling, no `load`-event dance. The iframe is server-rendered and may load before React hydrates, but that's fine: its bridge re-sends `CONNECT` until the host (now listening) answers, so there's no race.
78
82
 
79
83
  ```tsx
80
84
  'use client'
81
- import { useEffect, useRef } from 'react'
85
+ import { useEffect } from 'react'
82
86
  import { VibeAppHost } from '@nominalso/vibe-host'
83
87
 
84
88
  function VibeAppFrame({ host, src }: { host: VibeAppHost; src: string }) {
85
- const iframeRef = useRef<HTMLIFrameElement>(null)
86
-
87
- useEffect(() => {
88
- const iframe = iframeRef.current
89
- if (!iframe) return
90
- const unmount = host.mount() // listen before the iframe loads
91
- const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
92
- iframe.addEventListener('load', onLoad)
93
- return () => {
94
- iframe.removeEventListener('load', onLoad)
95
- unmount()
96
- }
97
- }, [host])
98
-
99
- return (
100
- <iframe
101
- ref={iframeRef}
102
- src={src}
103
- sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
104
- />
105
- )
89
+ useEffect(() => host.mount(), [host]) // returns the unmount cleanup
90
+
91
+ return <iframe src={src} sandbox="allow-scripts allow-forms allow-same-origin allow-popups" />
106
92
  }
107
93
  ```
108
94
 
109
- Calling `contentWindow.postMessage` while the iframe is still at `about:blank` throws a `DOMException` — that's why context is pushed from the `load` event, not on effect setup.
110
-
111
95
  ## How it fits together
112
96
 
113
97
  The iframe side is [`@nominalso/vibe-bridge`](https://www.npmjs.com/package/@nominalso/vibe-bridge). See the [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol and architecture.
package/dist/index.cjs CHANGED
@@ -27,7 +27,7 @@ __export(index_exports, {
27
27
  module.exports = __toCommonJS(index_exports);
28
28
 
29
29
  // package.json
30
- var version = "0.4.0";
30
+ var version = "0.5.1";
31
31
 
32
32
  // ../protocol-types/dist/index.js
33
33
  function normalizeSubroute(subroute) {
@@ -43,7 +43,7 @@ function normalizeSubroute(subroute) {
43
43
  return normalized;
44
44
  }
45
45
  var PROTOCOL_ID = "nominal-vibe-bridge";
46
- var PROTOCOL_VERSION = 1;
46
+ var PROTOCOL_VERSION = 2;
47
47
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
48
48
  var CORRELATED_KINDS = ["request", "response", "progress"];
49
49
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -58,20 +58,46 @@ function isBridgeMessage(data) {
58
58
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
59
59
  return true;
60
60
  }
61
+ var BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:BridgeError");
62
+ var HTTP_BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:HttpBridgeError");
61
63
  var BridgeError = class extends Error {
62
64
  constructor(code, message) {
63
65
  super(message);
64
66
  this.code = code;
65
67
  this.name = "BridgeError";
66
68
  }
69
+ /**
70
+ * Cross-realm-safe type guard. Prefer this over `instanceof BridgeError` when
71
+ * an error may have been thrown by a differently-built copy of the SDK (e.g.
72
+ * server bundle vs client bundle) — it matches on a process-global brand
73
+ * rather than class identity.
74
+ */
75
+ static isBridgeError(err) {
76
+ return typeof err === "object" && err !== null && err[BRIDGE_ERROR_BRAND] === true;
77
+ }
67
78
  };
79
+ Object.defineProperty(BridgeError.prototype, BRIDGE_ERROR_BRAND, {
80
+ value: true,
81
+ enumerable: false
82
+ });
68
83
  var HttpBridgeError = class extends BridgeError {
69
84
  constructor(status, message) {
70
85
  super("REQUEST_FAILED", message);
71
86
  this.status = status;
72
87
  this.name = "HttpBridgeError";
73
88
  }
89
+ /**
90
+ * Cross-realm-safe type guard (see {@link BridgeError.isBridgeError}). Returns
91
+ * `true` only for `HttpBridgeError` instances, not plain `BridgeError`s.
92
+ */
93
+ static isHttpBridgeError(err) {
94
+ return typeof err === "object" && err !== null && err[HTTP_BRIDGE_ERROR_BRAND] === true;
95
+ }
74
96
  };
97
+ Object.defineProperty(HttpBridgeError.prototype, HTTP_BRIDGE_ERROR_BRAND, {
98
+ value: true,
99
+ enumerable: false
100
+ });
75
101
  function isOriginPattern(entry) {
76
102
  return entry.includes("*");
77
103
  }
@@ -657,18 +683,20 @@ function rejectUnwired(op) {
657
683
  var VibeAppHost = class {
658
684
  constructor(opts) {
659
685
  /**
660
- * Concrete iframe origins observed on validated inbound messages. Because you
661
- * cannot `postMessage` to a glob pattern, proactive pushes target these
662
- * learned origins (plus the exact allowlist entries) rather than the patterns.
686
+ * The connected iframe, as two facets of one thing: `connectedWindow` is its
687
+ * `Window` (identity + the single push target) and `connectedOrigin` is its
688
+ * origin (what you `postMessage` to you cannot post to a `Window` ref, nor to
689
+ * a glob). Both are written together by {@link handleConnect} (the sole writer)
690
+ * and cleared together on unmount, so they can never diverge.
691
+ *
692
+ * There is deliberately no other window reference. The connected window can
693
+ * only be learned from each `CONNECT` handshake's `event.source`, never at
694
+ * mount: a reloaded/remounted iframe is a *new* `Window` with the same origin,
695
+ * so a mount-time window would be stale. The gate blocks every non-`CONNECT`
696
+ * message whose source isn't `connectedWindow`; every proactive push targets it.
663
697
  */
664
- this.connectedOrigins = /* @__PURE__ */ new Set();
665
- this.iframeWindow = null;
666
- /**
667
- * Source windows we've already logged a successful handshake for. The bridge
668
- * polls `GET_CONTEXT` on an interval until it connects, so without this the
669
- * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
670
- */
671
- this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
698
+ this.connectedWindow = null;
699
+ this.connectedOrigin = null;
672
700
  this.kindHandlers = {
673
701
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
674
702
  command: (msg) => this.dispatchCommand(msg)
@@ -677,6 +705,7 @@ var VibeAppHost = class {
677
705
  this.trustedOrigins = new Set(trustedOrigins);
678
706
  this.exactOrigins = trustedOrigins.filter((entry) => !isOriginPattern(entry));
679
707
  this.getContext = getContext;
708
+ this.getPostHog = opts.getPostHog;
680
709
  this.appBasePath = appBasePath;
681
710
  this.onRequestComplete = opts.onRequestComplete;
682
711
  this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
@@ -689,7 +718,8 @@ var VibeAppHost = class {
689
718
  this.commandHandlers = this.buildCommandHandlers(opts);
690
719
  }
691
720
  dispatchCommand(msg) {
692
- const handler = this.commandHandlers[msg.type];
721
+ const handlers = this.commandHandlers;
722
+ const handler = handlers[msg.type];
693
723
  if (!handler) {
694
724
  console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
695
725
  return;
@@ -735,31 +765,41 @@ var VibeAppHost = class {
735
765
  * Starts listening for bridge messages and sets up browser back/forward sync.
736
766
  * Returns a cleanup function to call on unmount.
737
767
  *
738
- * Pass `iframeWindow` to immediately push context to the iframe. If omitted,
739
- * the host will respond to GET_CONTEXT polls from the bridge but won't
740
- * proactively push call `pushContextTo(iframeWindow)` once the iframe has
741
- * loaded to trigger the proactive push.
768
+ * Takes no iframe window: the host learns the iframe from its `CONNECT`
769
+ * handshake (and re-learns it on every reload), then delivers context as the
770
+ * reply to that handshake. Just call `mount()` once the host exists.
742
771
  */
743
- mount(iframeWindow) {
744
- if (iframeWindow) this.iframeWindow = iframeWindow;
772
+ mount() {
745
773
  window.addEventListener("message", this.boundHandleMessage);
746
774
  window.addEventListener("popstate", this.boundHandlePopState);
747
- if (iframeWindow) this.pushContext(iframeWindow);
748
775
  return () => {
749
776
  window.removeEventListener("message", this.boundHandleMessage);
750
777
  window.removeEventListener("popstate", this.boundHandlePopState);
751
- this.iframeWindow = null;
752
- this.connectedOrigins.clear();
778
+ this.connectedWindow = null;
779
+ this.connectedOrigin = null;
753
780
  };
754
781
  }
755
782
  /**
756
- * Pushes the current context to the iframe and updates the stored iframe
757
- * window reference. Call this once the iframe has loaded if `mount()` was
758
- * called without an `iframeWindow`.
783
+ * Re-reads `getContext()` and re-pushes it to the connected iframe, so an open
784
+ * app reacts to a tenant/subsidiary switch or period close without reloading.
785
+ * PostHog is not re-sent (it rides only the connect handshake). No-op until an
786
+ * iframe is mounted.
759
787
  */
760
- pushContextTo(iframeWindow) {
761
- this.iframeWindow = iframeWindow;
762
- this.pushContext(iframeWindow);
788
+ refreshContext() {
789
+ if (!this.connectedWindow) return;
790
+ this.pushContext(this.connectedWindow);
791
+ }
792
+ /**
793
+ * Notifies the connected iframe of a Nominal auth change — a logout
794
+ * (`authenticated: false`) or an identity/tenant switch (`authenticated: true`
795
+ * with a new `userId`/`tenant`) — via `AUTH_CHANGED`, so the app re-authenticates
796
+ * or signs its own backend out. No-op until the iframe has connected. When auth
797
+ * and context change together, call this *before* {@link refreshContext} ("auth
798
+ * change, then context").
799
+ */
800
+ notifyAuthChange(auth) {
801
+ if (!this.connectedWindow) return;
802
+ this.pushToIframe("AUTH_CHANGED", auth);
763
803
  }
764
804
  pushContext(target) {
765
805
  const message = {
@@ -774,14 +814,14 @@ var VibeAppHost = class {
774
814
  }
775
815
  }
776
816
  handlePopState() {
777
- if (!this.iframeWindow) return;
817
+ if (!this.connectedWindow) return;
778
818
  const currentPath = window.location.pathname;
779
819
  if (!currentPath.startsWith(this.appBasePath)) return;
780
820
  const subroute = currentPath.slice(this.appBasePath.length) || "/";
781
821
  this.pushToIframe("SUBROUTE_PUSH", { subroute });
782
822
  }
783
823
  pushToIframe(type, payload) {
784
- if (!this.iframeWindow) return;
824
+ if (!this.connectedWindow) return;
785
825
  const message = {
786
826
  __protocol: PROTOCOL_ID,
787
827
  __version: PROTOCOL_VERSION,
@@ -790,44 +830,95 @@ var VibeAppHost = class {
790
830
  payload
791
831
  };
792
832
  for (const origin of this.concreteTargets()) {
793
- this.iframeWindow.postMessage(message, origin);
833
+ this.connectedWindow.postMessage(message, origin);
794
834
  }
795
835
  }
836
+ /**
837
+ * Sends the host's PostHog config to a freshly connected iframe as a one-time
838
+ * POSTHOG_PUSH. No-op when no `getPostHog` source is configured or it returns
839
+ * nothing (recording disabled). Targets the exact handshaking window/origin
840
+ * (no `concreteTargets` fan-out) since it fires from a validated inbound poll.
841
+ */
842
+ pushPostHog(target, origin) {
843
+ const payload = this.getPostHog?.();
844
+ if (!payload) return;
845
+ const message = {
846
+ __protocol: PROTOCOL_ID,
847
+ __version: PROTOCOL_VERSION,
848
+ kind: "push",
849
+ type: "POSTHOG_PUSH",
850
+ payload
851
+ };
852
+ target.postMessage(message, origin);
853
+ }
796
854
  async handleMessage(event) {
797
855
  if (!isOriginAllowed(event.origin, this.trustedOrigins, { allowPatterns: true })) return;
798
856
  if (!isBridgeMessage(event.data)) return;
799
- this.connectedOrigins.add(event.origin);
800
- await this.kindHandlers[event.data.kind]?.(event.data, event.source, event.origin);
857
+ const msg = event.data;
858
+ const source = event.source;
859
+ if (msg.kind === "command" && msg.type === "CONNECT") {
860
+ this.handleConnect(source, event.origin);
861
+ return;
862
+ }
863
+ if (source !== this.connectedWindow) {
864
+ if (msg.kind === "request") {
865
+ this.respond(source, event.origin, {
866
+ kind: "response",
867
+ type: msg.type,
868
+ requestId: msg.requestId,
869
+ ok: false,
870
+ error: "Vibe App is not connected \u2014 call connect() first.",
871
+ code: "NOT_CONNECTED"
872
+ });
873
+ }
874
+ return;
875
+ }
876
+ await this.kindHandlers[msg.kind]?.(msg, source, event.origin);
877
+ }
878
+ /**
879
+ * Handles the CONNECT handshake: records the connecting window/origin (which
880
+ * opens the connection gate for it) and pushes the initial context + one-time
881
+ * PostHog config. Only the first CONNECT per origin pushes — later retries are
882
+ * ignored, so the bridge receives exactly one initial CONTEXT_PUSH (no
883
+ * spurious onContextChange). A reloaded iframe is a new window and re-connects
884
+ * normally. If `getContext()` throws, the window is left unconnected so the
885
+ * bridge's retry can recover.
886
+ */
887
+ handleConnect(source, origin) {
888
+ if (source === this.connectedWindow) return;
889
+ try {
890
+ const ctx = { ...this.getContext(), hostVersion: version };
891
+ this.connectedWindow = source;
892
+ this.connectedOrigin = origin;
893
+ console.log(
894
+ `[vibe-host] Vibe App connected \u2014 host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
895
+ );
896
+ const message = {
897
+ __protocol: PROTOCOL_ID,
898
+ __version: PROTOCOL_VERSION,
899
+ kind: "push",
900
+ type: "CONTEXT_PUSH",
901
+ payload: ctx
902
+ };
903
+ source.postMessage(message, origin);
904
+ this.pushPostHog(source, origin);
905
+ } catch (err) {
906
+ console.error("[vibe-host] getContext threw during connect \u2014 context not delivered.", err);
907
+ }
801
908
  }
802
909
  /**
803
910
  * Concrete origins a proactive push may target: the exact allowlist entries
804
- * plus any origins seen on validated inbound messages. Never includes glob
805
- * patterns (you cannot `postMessage` to one). When only exact origins are
806
- * configured this equals the configured set, so behaviour is unchanged.
911
+ * plus the connected iframe's learned origin. Never includes glob patterns
912
+ * (you cannot `postMessage` to one). When only exact origins are configured
913
+ * this equals the configured set, so behaviour is unchanged.
807
914
  */
808
915
  concreteTargets() {
809
- return /* @__PURE__ */ new Set([...this.exactOrigins, ...this.connectedOrigins]);
916
+ const targets = new Set(this.exactOrigins);
917
+ if (this.connectedOrigin) targets.add(this.connectedOrigin);
918
+ return targets;
810
919
  }
811
920
  async handleRequest(msg, source, origin) {
812
921
  const { requestId, type } = msg;
813
- if (type === "GET_CONTEXT") {
814
- try {
815
- const ctx = { ...this.getContext(), hostVersion: version };
816
- if (!this.handshakeLoggedWindows.has(source)) {
817
- this.handshakeLoggedWindows.add(source);
818
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
819
- console.log(
820
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
821
- );
822
- }
823
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
824
- } catch (err) {
825
- const error = err instanceof Error ? err.message : String(err);
826
- const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
827
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
828
- }
829
- return;
830
- }
831
922
  if (!checkRateLimit(this.rateLimiter)) {
832
923
  this.respond(source, origin, {
833
924
  kind: "response",