@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/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// package.json
|
|
2
|
-
var version = "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 =
|
|
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
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
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(
|
|
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.
|
|
727
|
-
this.
|
|
753
|
+
this.connectedWindow = null;
|
|
754
|
+
this.connectedOrigin = null;
|
|
728
755
|
};
|
|
729
756
|
}
|
|
730
757
|
/**
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
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
|
-
|
|
736
|
-
this.
|
|
737
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
775
|
-
|
|
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
|
|
780
|
-
*
|
|
781
|
-
*
|
|
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
|
-
|
|
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.
|
|
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",
|