@nominalso/vibe-bridge 0.3.0 → 0.5.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/AGENTS.md CHANGED
@@ -4,7 +4,7 @@ Instructions for AI coding agents (Claude Code, Cursor, Copilot, Lovable, etc.)
4
4
 
5
5
  ## What this package is
6
6
 
7
- `@nominalso/vibe-bridge` is the **iframe-side** SDK for a Nominal Vibe App — a standalone browser app (often built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The app cannot call Nominal APIs directly; it talks to its Nominal host over a typed `postMessage` bridge. This package is that bridge. It is **browser-only** (uses `window`, `postMessage`, `crypto.randomUUID`) and has **zero runtime dependencies**.
7
+ `@nominalso/vibe-bridge` is the **iframe-side** SDK for a Nominal Vibe App — a standalone browser app (often built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The app cannot call Nominal APIs directly; it talks to its Nominal host over a typed `postMessage` bridge. This package is that bridge. It is **browser-only** (uses `window`, `postMessage`, `crypto.randomUUID`) and bundles **`posthog-js`** (its only runtime dependency) for host-enabled in-iframe session recording.
8
8
 
9
9
  The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — you do not install it in a Vibe App).
10
10
 
@@ -37,13 +37,16 @@ bridge.destroy() // on unmount
37
37
  - `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
38
38
  - `navigate`/`navigateTo`/`refresh` and `ui.setSideNav`/`ui.switchSubsidiary` — drive the host's chrome (router, side nav, subsidiary switcher); `invalidateCache(...)` busts host caches.
39
39
  - `reportSubroute(subroute, { replace? })`, `onSubrouteRequest(cb): () => void` — usually unnecessary; standard SPA navigation is auto-tracked.
40
+ - `track(event: string, properties?): void` — capture a custom PostHog event. **No-op unless the host enabled recording** (see below); always safe to call.
40
41
  - `destroy()`.
41
42
 
43
+ **Session recording (PostHog).** The bridge bundles `posthog-js` and, on `connect()`, initializes it **only if the host sent a `posthog` block with `enabled: true`** (cross-origin replay stitched into the host's recording, masking mirroring nom-ui — inputs unmasked, password fields masked — attributed to the same person via `identify`). If the host sends nothing, the bridge initializes nothing — `enabled` is the kill-switch. You configure nothing in the app; just call `track(...)` for custom events. If you serve the app yourself, allow `connect-src https://*.i.posthog.com https://us-assets.i.posthog.com` and `worker-src blob:` in its CSP (no `script-src` entry — posthog is bundled, not from a CDN).
44
+
42
45
  A failed operation throws a `BridgeError` (`extends Error`) with a machine-readable `code` (`'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`, …); branch on `err.code` rather than the message. A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`) carrying the HTTP `status` — branch on `err.status` (e.g. `404`).
43
46
 
44
- Exported values/types: `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
47
+ Exported values/types: `VibeAppBridge`, `BridgeError`, `HttpBridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `PostHogContext`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
45
48
 
46
- `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, hostVersion }`.
49
+ `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, posthog?: { token, apiHost, distinctId, enabled }, hostVersion }`. The `posthog` block is supplied by the host only when recording is on.
47
50
 
48
51
  The full method/operation list is in `README.md` ("Operation catalog") and in `docs/data-fetching.md`. The detailed wire-up guides are in `docs/`.
49
52
 
package/README.md CHANGED
@@ -10,7 +10,7 @@ Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typi
10
10
  npm install @nominalso/vibe-bridge
11
11
  ```
12
12
 
13
- Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
13
+ Bundles [`posthog-js`](https://posthog.com/docs/libraries/js) (its only runtime dependency) so the host can enable in-iframe session recording — see [Session recording & custom events](#session-recording--custom-events). Ships ESM + CJS and self-contained TypeScript types.
14
14
 
15
15
  ## Quickstart
16
16
 
@@ -118,9 +118,31 @@ Uploads a `File` through the host (converts to `ArrayBuffer` first — sandboxed
118
118
 
119
119
  Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount.
120
120
 
121
+ ### Session recording & custom events
122
+
123
+ The cross-origin iframe is invisible to the host's own session replay (same-origin policy), so the bridge bundles `posthog-js` and initializes it **itself** — but only when the host opts in. On `connect()`, if the resolved context carries a `posthog` block with `enabled: true`, the bridge:
124
+
125
+ - inits `posthog-js` with `recordCrossOriginIframes: true` (so your replay is **stitched into the host's recording**) and masking that mirrors nom-ui — `maskAllInputs: false` with `maskInputOptions: { password: true }` (inputs unmasked for richer insight, password fields kept masked);
126
+ - calls `identify()` with the host-resolved `distinctId`, so the recording and your events attribute to the **same PostHog person** as nom-ui;
127
+ - if the host also passes its `sessionId` (its `posthog.get_session_id()`), seeds it via `bootstrap.sessionID` so `track()` events share the host's **`$session_id`** and line up with the stitched replay.
128
+
129
+ The host is the single source of truth. **If it sends no `posthog` block (older host, or recording disabled for this env/app), the bridge initializes nothing** — `enabled` is the kill-switch even though `posthog-js` is bundled. You configure nothing in the app.
130
+
131
+ #### `track(event, properties?)`
132
+
133
+ Capture a custom product event. No-op until `connect()` has resolved with PostHog enabled by the host — so it's always safe to call.
134
+
135
+ ```ts
136
+ bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
137
+ ```
138
+
139
+ Namespace your events (e.g. `vibe:<slug>:<action>`) so they stay filterable from the host's own events. Events are sent directly from the iframe's PostHog instance and tie to the same person via `identify()`. By default they carry the iframe instance's `$session_id`; when the host supplies its `sessionId` in the posthog context, the bridge seeds it so events share the host's `$session_id` and correlate with the stitched replay (best-effort — a one-time seed at connect, so a later host-session rotation can diverge).
140
+
141
+ > **App-side CSP.** Whoever serves your app must allow, on the app's own origin, `connect-src https://*.i.posthog.com https://us-assets.i.posthog.com` and `worker-src blob:` (recording runs in a web worker). No `script-src` entry is needed — `posthog-js` is bundled into your app via the bridge, not loaded from a CDN.
142
+
121
143
  ### Exports
122
144
 
123
- `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
145
+ `VibeAppBridge`, `BridgeError`, `HttpBridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `PostHogContext`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
124
146
 
125
147
  ## Common recipes
126
148
 
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -28,7 +38,7 @@ __export(index_exports, {
28
38
  module.exports = __toCommonJS(index_exports);
29
39
 
30
40
  // package.json
31
- var version = "0.3.0";
41
+ var version = "0.5.0";
32
42
 
33
43
  // ../protocol-types/dist/index.js
34
44
  function normalizeSubroute(subroute) {
@@ -44,7 +54,7 @@ function normalizeSubroute(subroute) {
44
54
  return normalized;
45
55
  }
46
56
  var PROTOCOL_ID = "nominal-vibe-bridge";
47
- var PROTOCOL_VERSION = 1;
57
+ var PROTOCOL_VERSION = 2;
48
58
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
49
59
  var CORRELATED_KINDS = ["request", "response", "progress"];
50
60
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -101,6 +111,9 @@ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
101
111
  return false;
102
112
  }
103
113
 
114
+ // src/VibeAppBridge.ts
115
+ var import_posthog_js = __toESM(require("posthog-js"), 1);
116
+
104
117
  // src/data-methods.ts
105
118
  var BridgeDataMethods = class {
106
119
  // ---------------------------------------------------------------------------
@@ -396,8 +409,6 @@ var CONNECT_POLL_MS = 500;
396
409
  var DEFAULT_TIMEOUTS = {
397
410
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
398
411
  api: 3e4,
399
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
400
- context: 5e3,
401
412
  /** `INVALIDATE_CACHE`. */
402
413
  invalidate: 1e4,
403
414
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -410,14 +421,20 @@ function resolveRequestTimeout(type, requestTimeout) {
410
421
  return DEFAULT_TIMEOUTS.upload;
411
422
  case "INVALIDATE_CACHE":
412
423
  return DEFAULT_TIMEOUTS.invalidate;
413
- case "GET_CONTEXT":
414
- return DEFAULT_TIMEOUTS.context;
415
424
  default:
416
425
  return DEFAULT_TIMEOUTS.api;
417
426
  }
418
427
  }
419
428
 
420
429
  // src/VibeAppBridge.ts
430
+ function resolvePosthog(mod) {
431
+ let candidate = mod;
432
+ while (candidate && typeof candidate.init !== "function" && candidate.default) {
433
+ candidate = candidate.default;
434
+ }
435
+ return candidate;
436
+ }
437
+ var posthog = resolvePosthog(import_posthog_js.default);
421
438
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
422
439
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
423
440
  function isPreviewSelfHost(hostname) {
@@ -481,17 +498,30 @@ var VibeAppBridge = class extends BridgeDataMethods {
481
498
  super();
482
499
  this.pendingRequests = /* @__PURE__ */ new Map();
483
500
  this.context = null;
501
+ /**
502
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
503
+ * Guards against a double-init and makes {@link track} a no-op until
504
+ * recording is actually live.
505
+ */
506
+ this.posthogInitialized = false;
484
507
  this.connectPromise = null;
485
508
  this.connectPollInterval = null;
486
509
  this.connectTimeout = null;
487
510
  this.onContextReceived = null;
488
- this.onContextError = null;
511
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
512
+ this.rejectConnect = null;
489
513
  /**
490
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
491
- * late response from a *previous* connect attempt, which would otherwise
492
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
514
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
515
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
516
+ * resolves the connect handshake and is cleared afterward. `null` until
517
+ * {@link onContextChange} is called.
493
518
  */
494
- this.connectPollIds = /* @__PURE__ */ new Set();
519
+ this.contextChangeCallback = null;
520
+ /**
521
+ * Persistent subscriber for host auth changes (Nominal logout or
522
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
523
+ */
524
+ this.authChangeCallback = null;
495
525
  this.lastReportedSubroute = null;
496
526
  this.suppressSubrouteReport = false;
497
527
  this.subrouteCallback = null;
@@ -504,7 +534,21 @@ var VibeAppBridge = class extends BridgeDataMethods {
504
534
  progress: (msg) => this.handleProgress(msg)
505
535
  };
506
536
  this.pushHandlers = {
507
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
537
+ CONTEXT_PUSH: (payload) => {
538
+ if (this.onContextReceived) {
539
+ this.onContextReceived(payload);
540
+ } else if (this.context) {
541
+ this.context = payload;
542
+ this.contextChangeCallback?.(payload);
543
+ }
544
+ },
545
+ // Persistent (not nulled after connect): the host delivers PostHog once at
546
+ // connect via its own push, and initPostHog is idempotent, so this can run
547
+ // whenever the message arrives, independent of how connect() resolved.
548
+ POSTHOG_PUSH: (payload) => this.initPostHog(payload),
549
+ // Persistent: the host pushes auth changes (logout, identity/tenant
550
+ // switch) at any time after connect.
551
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
508
552
  SUBROUTE_PUSH: (payload) => {
509
553
  const safe = normalizeSubroute(payload.subroute);
510
554
  if (!safe) return;
@@ -555,11 +599,11 @@ var VibeAppBridge = class extends BridgeDataMethods {
555
599
  * methods, `upload()`, and subroute reporting all require an established
556
600
  * connection. Concurrent calls return the same promise.
557
601
  *
558
- * Actively polls the host every 500ms until a response arrives, which
559
- * handles the race condition where the host's initial context push arrives
560
- * before this listener is ready. Rejects with `Bridge connect timed out`
561
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
562
- * or the host hasn't mounted).
602
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
603
+ * by pushing the context (so it works even if the host mounts after the
604
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
605
+ * connect timed out` after 10 seconds if no context arrives (usually a
606
+ * `parentOrigin` mismatch or the host hasn't mounted).
563
607
  *
564
608
  * After connecting, if the context includes an initial subroute (e.g. from
565
609
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -589,16 +633,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
589
633
  this.stopConnectPolling();
590
634
  this.connectPromise = null;
591
635
  this.onContextReceived = null;
592
- this.onContextError = null;
636
+ this.rejectConnect = null;
593
637
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
594
638
  }, CONNECT_TIMEOUT_MS);
595
639
  this.connectTimeout = timer;
596
- this.onContextError = (error) => {
640
+ this.rejectConnect = (error) => {
597
641
  clearTimeout(timer);
598
642
  this.stopConnectPolling();
599
643
  this.connectPromise = null;
600
644
  this.onContextReceived = null;
601
- this.onContextError = null;
645
+ this.rejectConnect = null;
602
646
  reject(error);
603
647
  };
604
648
  this.onContextReceived = (ctx) => {
@@ -607,7 +651,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
607
651
  this.context = ctx;
608
652
  this.connectPromise = null;
609
653
  this.onContextReceived = null;
610
- this.onContextError = null;
654
+ this.rejectConnect = null;
611
655
  console.log(
612
656
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
613
657
  );
@@ -622,23 +666,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
622
666
  this.lastReportedSubroute = window.location.pathname + window.location.search;
623
667
  }
624
668
  this.setupNavigationTracking();
669
+ this.warnMissingListeners();
625
670
  resolve(ctx);
626
671
  };
627
- const poll = () => {
628
- const requestId = crypto.randomUUID();
629
- this.connectPollIds.add(requestId);
630
- const message = {
631
- __protocol: PROTOCOL_ID,
632
- __version: PROTOCOL_VERSION,
633
- kind: "request",
634
- type: "GET_CONTEXT",
635
- requestId,
636
- payload: { bridgeVersion: version }
637
- };
638
- this.postToParent(message);
672
+ const sendConnect = () => {
673
+ this.sendCommand("CONNECT", { bridgeVersion: version });
639
674
  };
640
- poll();
641
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
675
+ sendConnect();
676
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
642
677
  });
643
678
  return this.connectPromise;
644
679
  }
@@ -665,6 +700,56 @@ var VibeAppBridge = class extends BridgeDataMethods {
665
700
  this.subrouteCallback = null;
666
701
  };
667
702
  }
703
+ /**
704
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
705
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
706
+ * *after* connect — not for the initial context, which {@link connect} already
707
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
708
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
709
+ * connect if it isn't).
710
+ */
711
+ onContextChange(callback) {
712
+ this.contextChangeCallback = callback;
713
+ return () => {
714
+ this.contextChangeCallback = null;
715
+ };
716
+ }
717
+ /**
718
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
719
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
720
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
721
+ * host pushes after connect. Returns an unsubscribe function. Single
722
+ * subscriber: a second call replaces the first.
723
+ *
724
+ * **Wire this before {@link connect}** — without it the embedded app won't
725
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
726
+ * it isn't wired).
727
+ */
728
+ onAuthChange(callback) {
729
+ this.authChangeCallback = callback;
730
+ return () => {
731
+ this.authChangeCallback = null;
732
+ };
733
+ }
734
+ /**
735
+ * At connect, nudge the app if it hasn't wired the change listeners: every
736
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
737
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
738
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
739
+ * The skill/codegen does this by default; this catches hand-wired apps.
740
+ */
741
+ warnMissingListeners() {
742
+ if (!this.authChangeCallback) {
743
+ console.warn(
744
+ "[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()."
745
+ );
746
+ }
747
+ if (!this.contextChangeCallback) {
748
+ console.warn(
749
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
750
+ );
751
+ }
752
+ }
668
753
  // ---------------------------------------------------------------------------
669
754
  // Host navigation & UI commands
670
755
  //
@@ -715,9 +800,37 @@ var VibeAppBridge = class extends BridgeDataMethods {
715
800
  invalidateCache(payload) {
716
801
  return this.request("INVALIDATE_CACHE", payload);
717
802
  }
803
+ /**
804
+ * Captures a custom product event from inside the Vibe App, attributed to the
805
+ * same PostHog person as the host (via the `distinctId` the host supplied at
806
+ * connect).
807
+ *
808
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
809
+ * host** — if the host didn't send a `posthog` block (older host, or recording
810
+ * disabled for this env/app), nothing is sent. App authors don't import
811
+ * `posthog-js` themselves; this is the supported event surface.
812
+ *
813
+ * The event is sent **directly** from the iframe's own PostHog instance (only
814
+ * the session *recording* is forwarded to the parent), so it carries the
815
+ * iframe instance's `$session_id` rather than the host's — events tie to the
816
+ * same person, but cross-boundary event↔replay timeline correlation is a known
817
+ * limitation.
818
+ *
819
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
820
+ * keeps them filterable from the host's own events.
821
+ * @param properties Optional event properties.
822
+ * @example
823
+ * ```ts
824
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
825
+ * ```
826
+ */
827
+ track(event, properties) {
828
+ if (!this.posthogInitialized) return;
829
+ posthog.capture(event, properties);
830
+ }
718
831
  destroy() {
719
832
  this.teardownNavigationTracking();
720
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
833
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
721
834
  this.stopConnectPolling();
722
835
  window.removeEventListener("message", this.boundHandleMessage);
723
836
  for (const pending of this.pendingRequests.values()) {
@@ -725,9 +838,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
725
838
  }
726
839
  this.pendingRequests.clear();
727
840
  this.context = null;
841
+ this.posthogInitialized = false;
728
842
  this.connectPromise = null;
729
843
  this.onContextReceived = null;
730
- this.onContextError = null;
844
+ this.rejectConnect = null;
845
+ this.contextChangeCallback = null;
846
+ this.authChangeCallback = null;
731
847
  this.subrouteCallback = null;
732
848
  this.lastReportedSubroute = null;
733
849
  }
@@ -740,7 +856,59 @@ var VibeAppBridge = class extends BridgeDataMethods {
740
856
  clearTimeout(this.connectTimeout);
741
857
  this.connectTimeout = null;
742
858
  }
743
- this.connectPollIds.clear();
859
+ }
860
+ /**
861
+ * Initializes PostHog session recording + analytics from the host-supplied
862
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
863
+ * (`config.enabled`).
864
+ *
865
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
866
+ * (recording disabled for this env/app) or `enabled: false`, this does
867
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
868
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
869
+ * sides so the parent receives the iframe's rrweb frames into one stitched
870
+ * recording, and `identify()` ties the iframe's recording + events to the same
871
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
872
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
873
+ */
874
+ initPostHog(config) {
875
+ if (!config.enabled) return;
876
+ if (this.posthogInitialized) return;
877
+ if (typeof window === "undefined") return;
878
+ if (!config.token || !config.apiHost || !config.distinctId) {
879
+ console.warn(
880
+ "[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
881
+ );
882
+ return;
883
+ }
884
+ try {
885
+ posthog.init(config.token, {
886
+ api_host: config.apiHost,
887
+ // absolute ingestion host, NOT a relative /ingest proxy
888
+ person_profiles: "identified_only",
889
+ capture_pageview: false,
890
+ // the host owns pageviews; don't double-count
891
+ autocapture: false,
892
+ // opt-in events only, via track()
893
+ // Seed the host's session id so track() events share the host's
894
+ // $session_id and line up with the stitched replay. Replay stitching
895
+ // itself does not depend on this (recordCrossOriginIframes handles it).
896
+ ...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
897
+ session_recording: {
898
+ recordCrossOriginIframes: true,
899
+ // REQUIRED so the parent receives our rrweb frames
900
+ // Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
901
+ // richer insight, but keep password fields masked.
902
+ maskAllInputs: false,
903
+ maskInputOptions: { password: true }
904
+ }
905
+ });
906
+ posthog.identify(config.distinctId);
907
+ this.posthogInitialized = true;
908
+ console.log("[vibe-bridge] PostHog session recording initialized");
909
+ } catch (err) {
910
+ console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
911
+ }
744
912
  }
745
913
  /**
746
914
  * Monkey-patches `history.pushState` and `history.replaceState` to
@@ -863,15 +1031,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
863
1031
  this.kindHandlers[event.data.kind]?.(event.data);
864
1032
  }
865
1033
  handleResponse(msg) {
866
- if (msg.type === "GET_CONTEXT") {
867
- if (!this.connectPollIds.has(msg.requestId)) return;
868
- if (msg.ok) {
869
- this.onContextReceived?.(msg.data);
870
- } else {
871
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
872
- }
873
- return;
874
- }
875
1034
  const pending = this.pendingRequests.get(msg.requestId);
876
1035
  if (!pending) return;
877
1036
  this.pendingRequests.delete(msg.requestId);