@nominalso/vibe-bridge 0.4.0 → 0.6.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.
package/dist/index.cjs CHANGED
@@ -28,17 +28,14 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
29
 
30
30
  // src/index.ts
31
- var index_exports = {};
32
- __export(index_exports, {
33
- BRIDGE_VERSION: () => version,
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ BRIDGE_VERSION: () => BRIDGE_VERSION,
34
34
  BridgeError: () => BridgeError,
35
35
  HttpBridgeError: () => HttpBridgeError,
36
36
  VibeAppBridge: () => VibeAppBridge
37
37
  });
38
- module.exports = __toCommonJS(index_exports);
39
-
40
- // package.json
41
- var version = "0.4.0";
38
+ module.exports = __toCommonJS(src_exports);
42
39
 
43
40
  // ../protocol-types/dist/index.js
44
41
  function normalizeSubroute(subroute) {
@@ -54,7 +51,7 @@ function normalizeSubroute(subroute) {
54
51
  return normalized;
55
52
  }
56
53
  var PROTOCOL_ID = "nominal-vibe-bridge";
57
- var PROTOCOL_VERSION = 1;
54
+ var PROTOCOL_VERSION = 2;
58
55
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
59
56
  var CORRELATED_KINDS = ["request", "response", "progress"];
60
57
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -69,20 +66,46 @@ function isBridgeMessage(data) {
69
66
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
70
67
  return true;
71
68
  }
69
+ var BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:BridgeError");
70
+ var HTTP_BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:HttpBridgeError");
72
71
  var BridgeError = class extends Error {
73
72
  constructor(code, message) {
74
73
  super(message);
75
74
  this.code = code;
76
75
  this.name = "BridgeError";
77
76
  }
77
+ /**
78
+ * Cross-realm-safe type guard. Prefer this over `instanceof BridgeError` when
79
+ * an error may have been thrown by a differently-built copy of the SDK (e.g.
80
+ * server bundle vs client bundle) — it matches on a process-global brand
81
+ * rather than class identity.
82
+ */
83
+ static isBridgeError(err) {
84
+ return typeof err === "object" && err !== null && err[BRIDGE_ERROR_BRAND] === true;
85
+ }
78
86
  };
87
+ Object.defineProperty(BridgeError.prototype, BRIDGE_ERROR_BRAND, {
88
+ value: true,
89
+ enumerable: false
90
+ });
79
91
  var HttpBridgeError = class extends BridgeError {
80
92
  constructor(status, message) {
81
93
  super("REQUEST_FAILED", message);
82
94
  this.status = status;
83
95
  this.name = "HttpBridgeError";
84
96
  }
97
+ /**
98
+ * Cross-realm-safe type guard (see {@link BridgeError.isBridgeError}). Returns
99
+ * `true` only for `HttpBridgeError` instances, not plain `BridgeError`s.
100
+ */
101
+ static isHttpBridgeError(err) {
102
+ return typeof err === "object" && err !== null && err[HTTP_BRIDGE_ERROR_BRAND] === true;
103
+ }
85
104
  };
105
+ Object.defineProperty(HttpBridgeError.prototype, HTTP_BRIDGE_ERROR_BRAND, {
106
+ value: true,
107
+ enumerable: false
108
+ });
86
109
  function isOriginPattern(entry) {
87
110
  return entry.includes("*");
88
111
  }
@@ -111,8 +134,11 @@ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
111
134
  return false;
112
135
  }
113
136
 
114
- // src/VibeAppBridge.ts
115
- var import_posthog_js = __toESM(require("posthog-js"), 1);
137
+ // package.json
138
+ var version = "0.6.0";
139
+
140
+ // src/version.ts
141
+ var BRIDGE_VERSION = version;
116
142
 
117
143
  // src/data-methods.ts
118
144
  var BridgeDataMethods = class {
@@ -409,8 +435,6 @@ var CONNECT_POLL_MS = 500;
409
435
  var DEFAULT_TIMEOUTS = {
410
436
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
411
437
  api: 3e4,
412
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
413
- context: 5e3,
414
438
  /** `INVALIDATE_CACHE`. */
415
439
  invalidate: 1e4,
416
440
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -423,8 +447,6 @@ function resolveRequestTimeout(type, requestTimeout) {
423
447
  return DEFAULT_TIMEOUTS.upload;
424
448
  case "INVALIDATE_CACHE":
425
449
  return DEFAULT_TIMEOUTS.invalidate;
426
- case "GET_CONTEXT":
427
- return DEFAULT_TIMEOUTS.context;
428
450
  default:
429
451
  return DEFAULT_TIMEOUTS.api;
430
452
  }
@@ -432,13 +454,23 @@ function resolveRequestTimeout(type, requestTimeout) {
432
454
 
433
455
  // src/VibeAppBridge.ts
434
456
  function resolvePosthog(mod) {
435
- let candidate = mod;
436
- while (candidate && typeof candidate.init !== "function" && candidate.default) {
437
- candidate = candidate.default;
438
- }
439
- return candidate;
457
+ const hasInit = (o) => typeof o?.init === "function";
458
+ const d1 = mod?.default;
459
+ if (hasInit(d1)) return d1;
460
+ const d2 = d1?.default;
461
+ if (hasInit(d2)) return d2;
462
+ if (hasInit(mod)) return mod;
463
+ return null;
464
+ }
465
+ function serverEnvError(method) {
466
+ return new BridgeError(
467
+ "SERVER_ENVIRONMENT",
468
+ `VibeAppBridge.${method} was called outside a browser (no \`window\`). The bridge only runs in the browser \u2014 call it from a client component or effect, not during SSR, in a React Server Component, or in a Worker.`
469
+ );
470
+ }
471
+ function assertBrowser(method) {
472
+ if (typeof window === "undefined") throw serverEnvError(method);
440
473
  }
441
- var posthog = resolvePosthog(import_posthog_js.default);
442
474
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
443
475
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
444
476
  function isPreviewSelfHost(hostname) {
@@ -498,9 +530,22 @@ function resolveOriginPolicy(configured) {
498
530
  return { targetOrigin, isTrusted };
499
531
  }
500
532
  var VibeAppBridge = class extends BridgeDataMethods {
501
- constructor({ parentOrigin, requestTimeout } = {}) {
533
+ /**
534
+ * Stores options only — **pure and side-effect-free**. No `window`,
535
+ * `document`, `history`, `postMessage`, `crypto`, or `posthog-js` access
536
+ * happens here, so constructing a bridge is safe during SSR / in a Server
537
+ * Component / in a Worker. Browser wiring is deferred to {@link connect} /
538
+ * {@link attach}.
539
+ */
540
+ constructor(options = {}) {
502
541
  super();
542
+ /** Concrete origin to `postMessage` to, or `null` until resolved at {@link attach}. */
543
+ this.targetOrigin = null;
544
+ /** Validates an inbound `event.origin`; rejects everything until {@link attach}. */
545
+ this.isTrustedOrigin = () => false;
503
546
  this.pendingRequests = /* @__PURE__ */ new Map();
547
+ /** Whether {@link attach} has installed the message listener + resolved origin. */
548
+ this.attached = false;
504
549
  this.context = null;
505
550
  /**
506
551
  * Set once {@link initPostHog} has successfully called `posthog.init()`.
@@ -508,17 +553,34 @@ var VibeAppBridge = class extends BridgeDataMethods {
508
553
  * recording is actually live.
509
554
  */
510
555
  this.posthogInitialized = false;
556
+ /** The lazily-imported `posthog-js` singleton, once recording is initialized. */
557
+ this.posthogInstance = null;
558
+ /**
559
+ * Set for the duration of an in-flight {@link initPostHog} (which is async — it
560
+ * awaits `import('posthog-js')`). Serializes initialization so two overlapping
561
+ * `POSTHOG_PUSH`es can't both call `posthog.init()`.
562
+ */
563
+ this.posthogIniting = false;
564
+ /** So the "posthog-js not installed" warning is logged at most once. */
565
+ this.posthogUnavailableWarned = false;
511
566
  this.connectPromise = null;
512
567
  this.connectPollInterval = null;
513
568
  this.connectTimeout = null;
514
569
  this.onContextReceived = null;
515
- this.onContextError = null;
570
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
571
+ this.rejectConnect = null;
516
572
  /**
517
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
518
- * late response from a *previous* connect attempt, which would otherwise
519
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
573
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
574
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
575
+ * resolves the connect handshake and is cleared afterward. `null` until
576
+ * {@link onContextChange} is called.
520
577
  */
521
- this.connectPollIds = /* @__PURE__ */ new Set();
578
+ this.contextChangeCallback = null;
579
+ /**
580
+ * Persistent subscriber for host auth changes (Nominal logout or
581
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
582
+ */
583
+ this.authChangeCallback = null;
522
584
  this.lastReportedSubroute = null;
523
585
  this.suppressSubrouteReport = false;
524
586
  this.subrouteCallback = null;
@@ -531,7 +593,25 @@ var VibeAppBridge = class extends BridgeDataMethods {
531
593
  progress: (msg) => this.handleProgress(msg)
532
594
  };
533
595
  this.pushHandlers = {
534
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
596
+ CONTEXT_PUSH: (payload) => {
597
+ if (this.onContextReceived) {
598
+ this.onContextReceived(payload);
599
+ } else if (this.context) {
600
+ this.context = payload;
601
+ this.contextChangeCallback?.(payload);
602
+ }
603
+ },
604
+ // Persistent (not nulled after connect): the host delivers PostHog once at
605
+ // connect via its own push, and initPostHog is idempotent, so this can run
606
+ // whenever the message arrives, independent of how connect() resolved. Fire
607
+ // and forget — initPostHog is async (lazy-imports posthog-js) and swallows
608
+ // its own errors.
609
+ POSTHOG_PUSH: (payload) => {
610
+ void this.initPostHog(payload);
611
+ },
612
+ // Persistent: the host pushes auth changes (logout, identity/tenant
613
+ // switch) at any time after connect.
614
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
535
615
  SUBROUTE_PUSH: (payload) => {
536
616
  const safe = normalizeSubroute(payload.subroute);
537
617
  if (!safe) return;
@@ -558,42 +638,72 @@ var VibeAppBridge = class extends BridgeDataMethods {
558
638
  this.ui = {
559
639
  /** Collapse or expand the host's side navigation. */
560
640
  setSideNav: (payload) => {
641
+ assertBrowser("ui.setSideNav()");
561
642
  this.sendCommand("SET_SIDE_NAV", payload);
562
643
  },
563
644
  /** Switch the host to a different subsidiary. */
564
645
  switchSubsidiary: (subsidiaryId) => {
646
+ assertBrowser("ui.switchSubsidiary()");
565
647
  this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
566
648
  }
567
649
  };
568
- const policy = resolveOriginPolicy(parentOrigin);
569
- this.targetOrigin = policy.targetOrigin;
570
- this.isTrustedOrigin = policy.isTrusted;
571
- this.requestTimeout = requestTimeout;
650
+ this.parentOriginOption = options.parentOrigin;
651
+ this.requestTimeout = options.requestTimeout;
572
652
  this.boundHandleMessage = this.handleMessage.bind(this);
573
- window.addEventListener("message", this.boundHandleMessage);
574
653
  }
575
654
  dispatchPush(msg) {
576
655
  const handler = this.pushHandlers[msg.type];
577
656
  handler(msg.payload);
578
657
  }
658
+ /**
659
+ * Installs the browser wiring: resolves the parent-origin policy and starts
660
+ * listening for host `postMessage`s. **Idempotent** and safe to call before
661
+ * {@link connect} if you want the bridge receiving pushes early; {@link
662
+ * connect} calls it for you, so most apps never call this directly.
663
+ *
664
+ * @throws `BridgeError` code `'SERVER_ENVIRONMENT'` if called where there is no
665
+ * `window`.
666
+ */
667
+ attach() {
668
+ assertBrowser("attach()");
669
+ if (this.attached) return;
670
+ const policy = resolveOriginPolicy(this.parentOriginOption);
671
+ this.targetOrigin = policy.targetOrigin;
672
+ this.isTrustedOrigin = policy.isTrusted;
673
+ window.addEventListener("message", this.boundHandleMessage);
674
+ this.attached = true;
675
+ }
676
+ /**
677
+ * Lazily installs the browser wiring on first transport use. Keeps the
678
+ * constructor pure while ensuring that any method reaching the wire (a data
679
+ * `request`, a fire-and-forget command) works without an explicit prior
680
+ * `attach()`/`connect()` — attach stays idempotent, so `connect()` is
681
+ * unaffected.
682
+ */
683
+ ensureAttached() {
684
+ if (!this.attached) this.attach();
685
+ }
579
686
  /**
580
687
  * Connects to the host and returns the tenant/user {@link ContextPayload}.
581
688
  * **Call this once on app init and await it before any other method** — data
582
689
  * methods, `upload()`, and subroute reporting all require an established
583
690
  * connection. Concurrent calls return the same promise.
584
691
  *
585
- * Actively polls the host every 500ms until a response arrives, which
586
- * handles the race condition where the host's initial context push arrives
587
- * before this listener is ready. Rejects with `Bridge connect timed out`
588
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
589
- * or the host hasn't mounted).
692
+ * First {@link attach | attaches} the browser wiring (message listener +
693
+ * origin resolution), then sends a `CONNECT` handshake, re-sending every 500ms
694
+ * until the host replies by pushing the context (so it works even if the host
695
+ * mounts after the iframe loads). Context is only ever delivered by push.
696
+ * Rejects with `Bridge connect timed out` after 10 seconds if no context
697
+ * arrives (usually a `parentOrigin` mismatch or the host hasn't mounted).
590
698
  *
591
699
  * After connecting, if the context includes an initial subroute (e.g. from
592
700
  * a deep link), the bridge navigates to it and starts auto-tracking
593
701
  * internal navigation via the history API.
594
702
  *
595
703
  * @returns The tenant/user/subsidiary context for this app session.
596
- * @throws If no context is received within 10 seconds.
704
+ * @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
705
+ * where there is no `window`, `'PARENT_ORIGIN_UNRESOLVED'` if no embedding
706
+ * origin could be resolved, or `'TIMEOUT'` if no context arrives in 10s.
597
707
  * @example
598
708
  * ```ts
599
709
  * const ctx = await bridge.connect()
@@ -601,8 +711,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
601
711
  * ```
602
712
  */
603
713
  connect() {
714
+ if (typeof window === "undefined") return Promise.reject(serverEnvError("connect()"));
604
715
  if (this.context) return Promise.resolve(this.context);
605
716
  if (this.connectPromise) return this.connectPromise;
717
+ this.attach();
606
718
  if (this.targetOrigin === null) {
607
719
  return Promise.reject(
608
720
  new BridgeError(
@@ -616,16 +728,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
616
728
  this.stopConnectPolling();
617
729
  this.connectPromise = null;
618
730
  this.onContextReceived = null;
619
- this.onContextError = null;
731
+ this.rejectConnect = null;
620
732
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
621
733
  }, CONNECT_TIMEOUT_MS);
622
734
  this.connectTimeout = timer;
623
- this.onContextError = (error) => {
735
+ this.rejectConnect = (error) => {
624
736
  clearTimeout(timer);
625
737
  this.stopConnectPolling();
626
738
  this.connectPromise = null;
627
739
  this.onContextReceived = null;
628
- this.onContextError = null;
740
+ this.rejectConnect = null;
629
741
  reject(error);
630
742
  };
631
743
  this.onContextReceived = (ctx) => {
@@ -634,11 +746,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
634
746
  this.context = ctx;
635
747
  this.connectPromise = null;
636
748
  this.onContextReceived = null;
637
- this.onContextError = null;
749
+ this.rejectConnect = null;
638
750
  console.log(
639
- `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
751
+ `[vibe-bridge] Connected \u2014 bridge: ${BRIDGE_VERSION}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
640
752
  );
641
- this.initPostHog(ctx);
642
753
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
643
754
  if (initialSubroute && initialSubroute !== "/") {
644
755
  this.withSubrouteSuppressed(() => {
@@ -650,23 +761,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
650
761
  this.lastReportedSubroute = window.location.pathname + window.location.search;
651
762
  }
652
763
  this.setupNavigationTracking();
764
+ this.warnMissingListeners();
653
765
  resolve(ctx);
654
766
  };
655
- const poll = () => {
656
- const requestId = crypto.randomUUID();
657
- this.connectPollIds.add(requestId);
658
- const message = {
659
- __protocol: PROTOCOL_ID,
660
- __version: PROTOCOL_VERSION,
661
- kind: "request",
662
- type: "GET_CONTEXT",
663
- requestId,
664
- payload: { bridgeVersion: version }
665
- };
666
- this.postToParent(message);
767
+ const sendConnect = () => {
768
+ this.sendCommand("CONNECT", { bridgeVersion: BRIDGE_VERSION });
667
769
  };
668
- poll();
669
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
770
+ sendConnect();
771
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
670
772
  });
671
773
  return this.connectPromise;
672
774
  }
@@ -676,6 +778,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
676
778
  * Use this for hash-based routers or non-standard navigation patterns.
677
779
  */
678
780
  reportSubroute(subroute, options) {
781
+ assertBrowser("reportSubroute()");
679
782
  const normalized = normalizeSubroute(subroute);
680
783
  if (normalized === null) return;
681
784
  this.lastReportedSubroute = normalized;
@@ -686,6 +789,8 @@ var VibeAppBridge = class extends BridgeDataMethods {
686
789
  * If no callback is registered, the SDK uses `history.pushState` + a
687
790
  * `popstate` event, which works for most SPA routers automatically.
688
791
  * Returns an unsubscribe function.
792
+ *
793
+ * Pure — only stores the callback, so it is safe to call before {@link connect}.
689
794
  */
690
795
  onSubrouteRequest(callback) {
691
796
  this.subrouteCallback = callback;
@@ -693,6 +798,58 @@ var VibeAppBridge = class extends BridgeDataMethods {
693
798
  this.subrouteCallback = null;
694
799
  };
695
800
  }
801
+ /**
802
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
803
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
804
+ * *after* connect — not for the initial context, which {@link connect} already
805
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
806
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
807
+ * connect if it isn't).
808
+ *
809
+ * Pure — only stores the callback, so it is safe to call before {@link connect}.
810
+ */
811
+ onContextChange(callback) {
812
+ this.contextChangeCallback = callback;
813
+ return () => {
814
+ this.contextChangeCallback = null;
815
+ };
816
+ }
817
+ /**
818
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
819
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
820
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
821
+ * host pushes after connect. Returns an unsubscribe function. Single
822
+ * subscriber: a second call replaces the first.
823
+ *
824
+ * **Wire this before {@link connect}** — without it the embedded app won't
825
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
826
+ * it isn't wired). Pure — only stores the callback.
827
+ */
828
+ onAuthChange(callback) {
829
+ this.authChangeCallback = callback;
830
+ return () => {
831
+ this.authChangeCallback = null;
832
+ };
833
+ }
834
+ /**
835
+ * At connect, nudge the app if it hasn't wired the change listeners: every
836
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
837
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
838
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
839
+ * The skill/codegen does this by default; this catches hand-wired apps.
840
+ */
841
+ warnMissingListeners() {
842
+ if (!this.authChangeCallback) {
843
+ console.warn(
844
+ "[vibe-bridge] connected without onAuthChange \u2014 the app will not sign out on Nominal logout or react to an identity/tenant switch. Call bridge.onAuthChange() before connect()."
845
+ );
846
+ }
847
+ if (!this.contextChangeCallback) {
848
+ console.warn(
849
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
850
+ );
851
+ }
852
+ }
696
853
  // ---------------------------------------------------------------------------
697
854
  // Host navigation & UI commands
698
855
  //
@@ -712,6 +869,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
712
869
  * ```
713
870
  */
714
871
  navigate(path, action = "push") {
872
+ assertBrowser("navigate()");
715
873
  this.sendCommand("NAVIGATE", { path, action });
716
874
  }
717
875
  /**
@@ -725,10 +883,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
725
883
  * ```
726
884
  */
727
885
  navigateTo(route, routeParams, action = "push") {
886
+ assertBrowser("navigateTo()");
728
887
  this.sendCommand("NAVIGATE", { route, routeParams, action });
729
888
  }
730
889
  /** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
731
890
  refresh() {
891
+ assertBrowser("refresh()");
732
892
  this.sendCommand("NAVIGATE", { action: "refresh" });
733
893
  }
734
894
  /**
@@ -750,8 +910,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
750
910
  *
751
911
  * **No-op until {@link connect} has resolved with PostHog enabled by the
752
912
  * host** — if the host didn't send a `posthog` block (older host, or recording
753
- * disabled for this env/app), nothing is sent. App authors don't import
754
- * `posthog-js` themselves; this is the supported event surface.
913
+ * disabled for this env/app), or `posthog-js` isn't installed (it is an
914
+ * optional dependency), nothing is sent. App authors don't import `posthog-js`
915
+ * themselves; this is the supported event surface.
755
916
  *
756
917
  * The event is sent **directly** from the iframe's own PostHog instance (only
757
918
  * the session *recording* is forwarded to the parent), so it carries the
@@ -768,23 +929,38 @@ var VibeAppBridge = class extends BridgeDataMethods {
768
929
  * ```
769
930
  */
770
931
  track(event, properties) {
771
- if (!this.posthogInitialized) return;
772
- posthog.capture(event, properties);
932
+ assertBrowser("track()");
933
+ if (!this.posthogInitialized || !this.posthogInstance) return;
934
+ this.posthogInstance.capture(event, properties);
773
935
  }
936
+ /**
937
+ * Tears down the bridge: stops the connect handshake, removes the message
938
+ * listener, restores patched history methods, and rejects any in-flight calls
939
+ * with a `'DESTROYED'` error. **Idempotent and safe on the server** — calling
940
+ * it when {@link connect}/{@link attach} never ran (or where there is no
941
+ * `window`) is a no-op, so it is safe as a React effect cleanup.
942
+ */
774
943
  destroy() {
775
944
  this.teardownNavigationTracking();
776
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
945
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
777
946
  this.stopConnectPolling();
778
- window.removeEventListener("message", this.boundHandleMessage);
947
+ if (this.attached && typeof window !== "undefined") {
948
+ window.removeEventListener("message", this.boundHandleMessage);
949
+ }
950
+ this.attached = false;
779
951
  for (const pending of this.pendingRequests.values()) {
780
952
  pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
781
953
  }
782
954
  this.pendingRequests.clear();
783
955
  this.context = null;
784
956
  this.posthogInitialized = false;
957
+ this.posthogInstance = null;
958
+ this.posthogIniting = false;
785
959
  this.connectPromise = null;
786
960
  this.onContextReceived = null;
787
- this.onContextError = null;
961
+ this.rejectConnect = null;
962
+ this.contextChangeCallback = null;
963
+ this.authChangeCallback = null;
788
964
  this.subrouteCallback = null;
789
965
  this.lastReportedSubroute = null;
790
966
  }
@@ -797,25 +973,35 @@ var VibeAppBridge = class extends BridgeDataMethods {
797
973
  clearTimeout(this.connectTimeout);
798
974
  this.connectTimeout = null;
799
975
  }
800
- this.connectPollIds.clear();
976
+ }
977
+ warnPosthogUnavailable() {
978
+ if (this.posthogUnavailableWarned) return;
979
+ this.posthogUnavailableWarned = true;
980
+ console.warn(
981
+ "[vibe-bridge] The host enabled session recording but `posthog-js` is not installed (it is an optional dependency). Recording and track() are disabled. Run `npm i posthog-js` to enable them."
982
+ );
801
983
  }
802
984
  /**
803
985
  * Initializes PostHog session recording + analytics from the host-supplied
804
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
986
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
987
+ * (`config.enabled`).
988
+ *
989
+ * `posthog-js` is an **optional dependency**, loaded lazily via `await
990
+ * import('posthog-js')` the first time recording is enabled — so it stays out
991
+ * of the import graph (and out of any server bundle) for apps that never
992
+ * record. If it isn't installed, this warns once and no-ops (never throws).
805
993
  *
806
- * The host is the single source of truth: when it sends no `posthog` block
807
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
808
- * this does nothing `enabled` is the kill-switch even though `posthog-js` is
809
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
810
- * on both sides so the parent receives the iframe's rrweb frames into one
811
- * stitched recording, and `identify()` ties the iframe's recording + events to
812
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
813
- * best-effort and never blocks `connect()`.
994
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
995
+ * (recording disabled for this env/app) or `enabled: false`, this does
996
+ * nothing. Cross-origin replay needs `recordCrossOriginIframes` on both sides
997
+ * so the parent receives the iframe's rrweb frames into one stitched recording,
998
+ * and `identify()` ties the iframe's recording + events to the same person as
999
+ * the host. Idempotent (the `posthogInitialized` guard) and wrapped in
1000
+ * try/catch so a duplicate push or a PostHog failure is harmless.
814
1001
  */
815
- initPostHog(ctx) {
816
- const config = ctx.posthog;
817
- if (!config?.enabled) return;
818
- if (this.posthogInitialized) return;
1002
+ async initPostHog(config) {
1003
+ if (!config.enabled) return;
1004
+ if (this.posthogInitialized || this.posthogIniting) return;
819
1005
  if (typeof window === "undefined") return;
820
1006
  if (!config.token || !config.apiHost || !config.distinctId) {
821
1007
  console.warn(
@@ -823,7 +1009,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
823
1009
  );
824
1010
  return;
825
1011
  }
1012
+ this.posthogIniting = true;
826
1013
  try {
1014
+ let posthog;
1015
+ try {
1016
+ posthog = resolvePosthog(await import("posthog-js"));
1017
+ } catch {
1018
+ this.warnPosthogUnavailable();
1019
+ return;
1020
+ }
1021
+ if (!posthog) {
1022
+ this.warnPosthogUnavailable();
1023
+ return;
1024
+ }
1025
+ if (!this.attached) return;
827
1026
  posthog.init(config.token, {
828
1027
  api_host: config.apiHost,
829
1028
  // absolute ingestion host, NOT a relative /ingest proxy
@@ -846,16 +1045,19 @@ var VibeAppBridge = class extends BridgeDataMethods {
846
1045
  }
847
1046
  });
848
1047
  posthog.identify(config.distinctId);
1048
+ this.posthogInstance = posthog;
849
1049
  this.posthogInitialized = true;
850
1050
  console.log("[vibe-bridge] PostHog session recording initialized");
851
1051
  } catch (err) {
852
1052
  console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
1053
+ } finally {
1054
+ this.posthogIniting = false;
853
1055
  }
854
1056
  }
855
1057
  /**
856
1058
  * Monkey-patches `history.pushState` and `history.replaceState` to
857
1059
  * auto-detect SPA navigation and report it to the host. Called after
858
- * connect() resolves.
1060
+ * connect() resolves (so `window`/`history` are guaranteed present).
859
1061
  */
860
1062
  setupNavigationTracking() {
861
1063
  if (this.origPushState) return;
@@ -906,6 +1108,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
906
1108
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
907
1109
  }
908
1110
  sendCommand(type, payload) {
1111
+ this.ensureAttached();
909
1112
  this.postToParent({
910
1113
  __protocol: PROTOCOL_ID,
911
1114
  __version: PROTOCOL_VERSION,
@@ -921,12 +1124,18 @@ var VibeAppBridge = class extends BridgeDataMethods {
921
1124
  * `onProgress` callback. `type` autocompletes to every operation name and
922
1125
  * narrows `payload`/return to that operation's types.
923
1126
  *
1127
+ * @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
1128
+ * where there is no `window`.
924
1129
  * @example
925
1130
  * ```ts
926
1131
  * const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })
927
1132
  * ```
928
1133
  */
929
1134
  request(type, payload, onProgress) {
1135
+ if (typeof window === "undefined") {
1136
+ return Promise.reject(serverEnvError(`request(${String(type)})`));
1137
+ }
1138
+ this.ensureAttached();
930
1139
  return new Promise((resolve, reject) => {
931
1140
  const requestId = crypto.randomUUID();
932
1141
  const timer = setTimeout(
@@ -973,15 +1182,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
973
1182
  this.kindHandlers[event.data.kind]?.(event.data);
974
1183
  }
975
1184
  handleResponse(msg) {
976
- if (msg.type === "GET_CONTEXT") {
977
- if (!this.connectPollIds.has(msg.requestId)) return;
978
- if (msg.ok) {
979
- this.onContextReceived?.(msg.data);
980
- } else {
981
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
982
- }
983
- return;
984
- }
985
1185
  const pending = this.pendingRequests.get(msg.requestId);
986
1186
  if (!pending) return;
987
1187
  this.pendingRequests.delete(msg.requestId);
@@ -1007,3 +1207,4 @@ var VibeAppBridge = class extends BridgeDataMethods {
1007
1207
  HttpBridgeError,
1008
1208
  VibeAppBridge
1009
1209
  });
1210
+ //# sourceMappingURL=index.cjs.map