@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 +17 -33
- package/dist/index.cjs +147 -56
- package/dist/index.d.cts +152 -70
- package/dist/index.d.ts +152 -70
- package/dist/index.js +147 -56
- package/package.json +1 -1
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
|
|
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
|
|
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(
|
|
63
|
+
### `mount(): () => void`
|
|
64
64
|
|
|
65
|
-
Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function.
|
|
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
|
-
### `
|
|
67
|
+
### `refreshContext(): void`
|
|
68
68
|
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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.
|
|
665
|
-
this.
|
|
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
|
|
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
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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(
|
|
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.
|
|
752
|
-
this.
|
|
778
|
+
this.connectedWindow = null;
|
|
779
|
+
this.connectedOrigin = null;
|
|
753
780
|
};
|
|
754
781
|
}
|
|
755
782
|
/**
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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
|
-
|
|
761
|
-
this.
|
|
762
|
-
this.pushContext(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
800
|
-
|
|
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
|
|
805
|
-
*
|
|
806
|
-
*
|
|
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
|
-
|
|
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",
|