@nominalso/vibe-bridge 0.3.0 → 0.4.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.4.0";
32
42
 
33
43
  // ../protocol-types/dist/index.js
34
44
  function normalizeSubroute(subroute) {
@@ -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
  // ---------------------------------------------------------------------------
@@ -418,6 +431,14 @@ function resolveRequestTimeout(type, requestTimeout) {
418
431
  }
419
432
 
420
433
  // src/VibeAppBridge.ts
434
+ function resolvePosthog(mod) {
435
+ let candidate = mod;
436
+ while (candidate && typeof candidate.init !== "function" && candidate.default) {
437
+ candidate = candidate.default;
438
+ }
439
+ return candidate;
440
+ }
441
+ var posthog = resolvePosthog(import_posthog_js.default);
421
442
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
422
443
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
423
444
  function isPreviewSelfHost(hostname) {
@@ -481,6 +502,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
481
502
  super();
482
503
  this.pendingRequests = /* @__PURE__ */ new Map();
483
504
  this.context = null;
505
+ /**
506
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
507
+ * Guards against a double-init and makes {@link track} a no-op until
508
+ * recording is actually live.
509
+ */
510
+ this.posthogInitialized = false;
484
511
  this.connectPromise = null;
485
512
  this.connectPollInterval = null;
486
513
  this.connectTimeout = null;
@@ -611,6 +638,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
611
638
  console.log(
612
639
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
613
640
  );
641
+ this.initPostHog(ctx);
614
642
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
615
643
  if (initialSubroute && initialSubroute !== "/") {
616
644
  this.withSubrouteSuppressed(() => {
@@ -715,6 +743,34 @@ var VibeAppBridge = class extends BridgeDataMethods {
715
743
  invalidateCache(payload) {
716
744
  return this.request("INVALIDATE_CACHE", payload);
717
745
  }
746
+ /**
747
+ * Captures a custom product event from inside the Vibe App, attributed to the
748
+ * same PostHog person as the host (via the `distinctId` the host supplied at
749
+ * connect).
750
+ *
751
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
752
+ * 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.
755
+ *
756
+ * The event is sent **directly** from the iframe's own PostHog instance (only
757
+ * the session *recording* is forwarded to the parent), so it carries the
758
+ * iframe instance's `$session_id` rather than the host's — events tie to the
759
+ * same person, but cross-boundary event↔replay timeline correlation is a known
760
+ * limitation.
761
+ *
762
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
763
+ * keeps them filterable from the host's own events.
764
+ * @param properties Optional event properties.
765
+ * @example
766
+ * ```ts
767
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
768
+ * ```
769
+ */
770
+ track(event, properties) {
771
+ if (!this.posthogInitialized) return;
772
+ posthog.capture(event, properties);
773
+ }
718
774
  destroy() {
719
775
  this.teardownNavigationTracking();
720
776
  this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
@@ -725,6 +781,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
725
781
  }
726
782
  this.pendingRequests.clear();
727
783
  this.context = null;
784
+ this.posthogInitialized = false;
728
785
  this.connectPromise = null;
729
786
  this.onContextReceived = null;
730
787
  this.onContextError = null;
@@ -742,6 +799,59 @@ var VibeAppBridge = class extends BridgeDataMethods {
742
799
  }
743
800
  this.connectPollIds.clear();
744
801
  }
802
+ /**
803
+ * Initializes PostHog session recording + analytics from the host-supplied
804
+ * config, **only when the host opted in** (`ctx.posthog?.enabled`).
805
+ *
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()`.
814
+ */
815
+ initPostHog(ctx) {
816
+ const config = ctx.posthog;
817
+ if (!config?.enabled) return;
818
+ if (this.posthogInitialized) return;
819
+ if (typeof window === "undefined") return;
820
+ if (!config.token || !config.apiHost || !config.distinctId) {
821
+ console.warn(
822
+ "[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
823
+ );
824
+ return;
825
+ }
826
+ try {
827
+ posthog.init(config.token, {
828
+ api_host: config.apiHost,
829
+ // absolute ingestion host, NOT a relative /ingest proxy
830
+ person_profiles: "identified_only",
831
+ capture_pageview: false,
832
+ // the host owns pageviews; don't double-count
833
+ autocapture: false,
834
+ // opt-in events only, via track()
835
+ // Seed the host's session id so track() events share the host's
836
+ // $session_id and line up with the stitched replay. Replay stitching
837
+ // itself does not depend on this (recordCrossOriginIframes handles it).
838
+ ...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
839
+ session_recording: {
840
+ recordCrossOriginIframes: true,
841
+ // REQUIRED so the parent receives our rrweb frames
842
+ // Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
843
+ // richer insight, but keep password fields masked.
844
+ maskAllInputs: false,
845
+ maskInputOptions: { password: true }
846
+ }
847
+ });
848
+ posthog.identify(config.distinctId);
849
+ this.posthogInitialized = true;
850
+ console.log("[vibe-bridge] PostHog session recording initialized");
851
+ } catch (err) {
852
+ console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
853
+ }
854
+ }
745
855
  /**
746
856
  * Monkey-patches `history.pushState` and `history.replaceState` to
747
857
  * auto-detect SPA navigation and report it to the host. Called after
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.3.0";
1
+ var version = "0.4.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -3182,6 +3182,54 @@ interface BridgeSubsidiary {
3182
3182
  id: number;
3183
3183
  displayName: string;
3184
3184
  }
3185
+ /**
3186
+ * PostHog configuration the host supplies so the iframe can record a session
3187
+ * replay (stitched into the host's recording via `recordCrossOriginIframes`)
3188
+ * and emit custom events, all attributed to the **same PostHog person** as
3189
+ * nom-ui.
3190
+ *
3191
+ * The host (nom-ui) is the single source of truth for these values — it knows
3192
+ * the project key, the env-aware ingestion host, and the resolved person — and
3193
+ * delivers them over the bridge so the iframe never hardcodes any of it. When
3194
+ * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3195
+ * or recording disabled for this env/app), the bridge initializes nothing.
3196
+ */
3197
+ interface PostHogContext {
3198
+ /** Project API key — the same PostHog project the host (nom-ui) uses. */
3199
+ token: string;
3200
+ /**
3201
+ * **Absolute** ingestion host, e.g. `"https://us.i.posthog.com"`.
3202
+ *
3203
+ * Must NOT be the host's relative `/ingest` reverse-proxy path: a relative
3204
+ * path resolves against the iframe's *own* (cross-origin) host and 404s.
3205
+ */
3206
+ apiHost: string;
3207
+ /**
3208
+ * Host-resolved PostHog distinct id. The bridge calls `identify()` with it so
3209
+ * the iframe's recording and events attribute to the same person as the host.
3210
+ */
3211
+ distinctId: string;
3212
+ /**
3213
+ * **Optional.** The host's current PostHog session id (from
3214
+ * `posthog.get_session_id()`). When provided, the bridge seeds it as the
3215
+ * iframe instance's session via `bootstrap.sessionID`, so custom events from
3216
+ * `bridge.track()` share the **same `$session_id`** as the host — letting them
3217
+ * line up with the stitched session replay on one timeline.
3218
+ *
3219
+ * Not needed for *replay* stitching itself (that works via
3220
+ * `recordCrossOriginIframes` on both sides regardless). Must be a valid UUID
3221
+ * v7 — PostHog session ids already are. It is a one-time seed at connect, so
3222
+ * if the host's session later rotates the two can diverge; alignment is
3223
+ * best-effort for the session in progress.
3224
+ */
3225
+ sessionId?: string;
3226
+ /**
3227
+ * Master switch, env- and/or app-gated by the host. The bridge only calls
3228
+ * `posthog.init()` when this is `true`; it stays the kill-switch even though
3229
+ * `posthog-js` is bundled into the bridge.
3230
+ */
3231
+ enabled: boolean;
3232
+ }
3185
3233
  interface ContextPayload {
3186
3234
  tenant: string;
3187
3235
  subsidiaryId: number;
@@ -3194,6 +3242,13 @@ interface ContextPayload {
3194
3242
  subroute?: string;
3195
3243
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3196
3244
  lastClosedPeriodSlug?: string;
3245
+ /**
3246
+ * PostHog config for in-iframe session recording + custom events. **Absent
3247
+ * when recording is off** (older host, or disabled for this env/app), in which
3248
+ * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3249
+ * compatible.
3250
+ */
3251
+ posthog?: PostHogContext;
3197
3252
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3198
3253
  hostVersion: string;
3199
3254
  }
@@ -3731,6 +3786,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
3731
3786
  private readonly pendingRequests;
3732
3787
  private readonly boundHandleMessage;
3733
3788
  private context;
3789
+ /**
3790
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
3791
+ * Guards against a double-init and makes {@link track} a no-op until
3792
+ * recording is actually live.
3793
+ */
3794
+ private posthogInitialized;
3734
3795
  private connectPromise;
3735
3796
  private connectPollInterval;
3736
3797
  private connectTimeout;
@@ -3841,8 +3902,47 @@ declare class VibeAppBridge extends BridgeDataMethods {
3841
3902
  * ```
3842
3903
  */
3843
3904
  invalidateCache(payload: InvalidateCachePayload): Promise<InvalidateResult>;
3905
+ /**
3906
+ * Captures a custom product event from inside the Vibe App, attributed to the
3907
+ * same PostHog person as the host (via the `distinctId` the host supplied at
3908
+ * connect).
3909
+ *
3910
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
3911
+ * host** — if the host didn't send a `posthog` block (older host, or recording
3912
+ * disabled for this env/app), nothing is sent. App authors don't import
3913
+ * `posthog-js` themselves; this is the supported event surface.
3914
+ *
3915
+ * The event is sent **directly** from the iframe's own PostHog instance (only
3916
+ * the session *recording* is forwarded to the parent), so it carries the
3917
+ * iframe instance's `$session_id` rather than the host's — events tie to the
3918
+ * same person, but cross-boundary event↔replay timeline correlation is a known
3919
+ * limitation.
3920
+ *
3921
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
3922
+ * keeps them filterable from the host's own events.
3923
+ * @param properties Optional event properties.
3924
+ * @example
3925
+ * ```ts
3926
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
3927
+ * ```
3928
+ */
3929
+ track(event: string, properties?: Record<string, unknown>): void;
3844
3930
  destroy(): void;
3845
3931
  private stopConnectPolling;
3932
+ /**
3933
+ * Initializes PostHog session recording + analytics from the host-supplied
3934
+ * config, **only when the host opted in** (`ctx.posthog?.enabled`).
3935
+ *
3936
+ * The host is the single source of truth: when it sends no `posthog` block
3937
+ * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
3938
+ * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
3939
+ * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
3940
+ * on both sides so the parent receives the iframe's rrweb frames into one
3941
+ * stitched recording, and `identify()` ties the iframe's recording + events to
3942
+ * the same person as the host. Wrapped in try/catch so a PostHog failure is
3943
+ * best-effort and never blocks `connect()`.
3944
+ */
3945
+ private initPostHog;
3846
3946
  /**
3847
3947
  * Monkey-patches `history.pushState` and `history.replaceState` to
3848
3948
  * auto-detect SPA navigation and report it to the host. Called after
@@ -3878,4 +3978,4 @@ declare class VibeAppBridge extends BridgeDataMethods {
3878
3978
  private handleProgress;
3879
3979
  }
3880
3980
 
3881
- export { version as BRIDGE_VERSION, BridgeError, type BridgeSubsidiary, type ContextPayload, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type RequestRegistry, type SetSideNavPayload, type UploadOptions, type UploadProgress, type UploadResponse, VibeAppBridge, type VibeAppBridgeOptions };
3981
+ export { version as BRIDGE_VERSION, BridgeError, type BridgeSubsidiary, type ContextPayload, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type PostHogContext, type RequestRegistry, type SetSideNavPayload, type UploadOptions, type UploadProgress, type UploadResponse, VibeAppBridge, type VibeAppBridgeOptions };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- var version = "0.3.0";
1
+ var version = "0.4.0";
2
2
 
3
3
  type AccountingAccountDimensionValues = {
4
4
  account_id: string;
@@ -3182,6 +3182,54 @@ interface BridgeSubsidiary {
3182
3182
  id: number;
3183
3183
  displayName: string;
3184
3184
  }
3185
+ /**
3186
+ * PostHog configuration the host supplies so the iframe can record a session
3187
+ * replay (stitched into the host's recording via `recordCrossOriginIframes`)
3188
+ * and emit custom events, all attributed to the **same PostHog person** as
3189
+ * nom-ui.
3190
+ *
3191
+ * The host (nom-ui) is the single source of truth for these values — it knows
3192
+ * the project key, the env-aware ingestion host, and the resolved person — and
3193
+ * delivers them over the bridge so the iframe never hardcodes any of it. When
3194
+ * the host omits the {@link ContextPayload.posthog} block entirely (older host,
3195
+ * or recording disabled for this env/app), the bridge initializes nothing.
3196
+ */
3197
+ interface PostHogContext {
3198
+ /** Project API key — the same PostHog project the host (nom-ui) uses. */
3199
+ token: string;
3200
+ /**
3201
+ * **Absolute** ingestion host, e.g. `"https://us.i.posthog.com"`.
3202
+ *
3203
+ * Must NOT be the host's relative `/ingest` reverse-proxy path: a relative
3204
+ * path resolves against the iframe's *own* (cross-origin) host and 404s.
3205
+ */
3206
+ apiHost: string;
3207
+ /**
3208
+ * Host-resolved PostHog distinct id. The bridge calls `identify()` with it so
3209
+ * the iframe's recording and events attribute to the same person as the host.
3210
+ */
3211
+ distinctId: string;
3212
+ /**
3213
+ * **Optional.** The host's current PostHog session id (from
3214
+ * `posthog.get_session_id()`). When provided, the bridge seeds it as the
3215
+ * iframe instance's session via `bootstrap.sessionID`, so custom events from
3216
+ * `bridge.track()` share the **same `$session_id`** as the host — letting them
3217
+ * line up with the stitched session replay on one timeline.
3218
+ *
3219
+ * Not needed for *replay* stitching itself (that works via
3220
+ * `recordCrossOriginIframes` on both sides regardless). Must be a valid UUID
3221
+ * v7 — PostHog session ids already are. It is a one-time seed at connect, so
3222
+ * if the host's session later rotates the two can diverge; alignment is
3223
+ * best-effort for the session in progress.
3224
+ */
3225
+ sessionId?: string;
3226
+ /**
3227
+ * Master switch, env- and/or app-gated by the host. The bridge only calls
3228
+ * `posthog.init()` when this is `true`; it stays the kill-switch even though
3229
+ * `posthog-js` is bundled into the bridge.
3230
+ */
3231
+ enabled: boolean;
3232
+ }
3185
3233
  interface ContextPayload {
3186
3234
  tenant: string;
3187
3235
  subsidiaryId: number;
@@ -3194,6 +3242,13 @@ interface ContextPayload {
3194
3242
  subroute?: string;
3195
3243
  /** Slug of the last closed accounting period, e.g. "mar-2026". */
3196
3244
  lastClosedPeriodSlug?: string;
3245
+ /**
3246
+ * PostHog config for in-iframe session recording + custom events. **Absent
3247
+ * when recording is off** (older host, or disabled for this env/app), in which
3248
+ * case the bridge initializes no analytics. Optional so old hosts/bridges stay
3249
+ * compatible.
3250
+ */
3251
+ posthog?: PostHogContext;
3197
3252
  /** Version of the host SDK (@nominalso/vibe-host) running in nom-ui. */
3198
3253
  hostVersion: string;
3199
3254
  }
@@ -3731,6 +3786,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
3731
3786
  private readonly pendingRequests;
3732
3787
  private readonly boundHandleMessage;
3733
3788
  private context;
3789
+ /**
3790
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
3791
+ * Guards against a double-init and makes {@link track} a no-op until
3792
+ * recording is actually live.
3793
+ */
3794
+ private posthogInitialized;
3734
3795
  private connectPromise;
3735
3796
  private connectPollInterval;
3736
3797
  private connectTimeout;
@@ -3841,8 +3902,47 @@ declare class VibeAppBridge extends BridgeDataMethods {
3841
3902
  * ```
3842
3903
  */
3843
3904
  invalidateCache(payload: InvalidateCachePayload): Promise<InvalidateResult>;
3905
+ /**
3906
+ * Captures a custom product event from inside the Vibe App, attributed to the
3907
+ * same PostHog person as the host (via the `distinctId` the host supplied at
3908
+ * connect).
3909
+ *
3910
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
3911
+ * host** — if the host didn't send a `posthog` block (older host, or recording
3912
+ * disabled for this env/app), nothing is sent. App authors don't import
3913
+ * `posthog-js` themselves; this is the supported event surface.
3914
+ *
3915
+ * The event is sent **directly** from the iframe's own PostHog instance (only
3916
+ * the session *recording* is forwarded to the parent), so it carries the
3917
+ * iframe instance's `$session_id` rather than the host's — events tie to the
3918
+ * same person, but cross-boundary event↔replay timeline correlation is a known
3919
+ * limitation.
3920
+ *
3921
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
3922
+ * keeps them filterable from the host's own events.
3923
+ * @param properties Optional event properties.
3924
+ * @example
3925
+ * ```ts
3926
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
3927
+ * ```
3928
+ */
3929
+ track(event: string, properties?: Record<string, unknown>): void;
3844
3930
  destroy(): void;
3845
3931
  private stopConnectPolling;
3932
+ /**
3933
+ * Initializes PostHog session recording + analytics from the host-supplied
3934
+ * config, **only when the host opted in** (`ctx.posthog?.enabled`).
3935
+ *
3936
+ * The host is the single source of truth: when it sends no `posthog` block
3937
+ * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
3938
+ * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
3939
+ * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
3940
+ * on both sides so the parent receives the iframe's rrweb frames into one
3941
+ * stitched recording, and `identify()` ties the iframe's recording + events to
3942
+ * the same person as the host. Wrapped in try/catch so a PostHog failure is
3943
+ * best-effort and never blocks `connect()`.
3944
+ */
3945
+ private initPostHog;
3846
3946
  /**
3847
3947
  * Monkey-patches `history.pushState` and `history.replaceState` to
3848
3948
  * auto-detect SPA navigation and report it to the host. Called after
@@ -3878,4 +3978,4 @@ declare class VibeAppBridge extends BridgeDataMethods {
3878
3978
  private handleProgress;
3879
3979
  }
3880
3980
 
3881
- export { version as BRIDGE_VERSION, BridgeError, type BridgeSubsidiary, type ContextPayload, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type RequestRegistry, type SetSideNavPayload, type UploadOptions, type UploadProgress, type UploadResponse, VibeAppBridge, type VibeAppBridgeOptions };
3981
+ export { version as BRIDGE_VERSION, BridgeError, type BridgeSubsidiary, type ContextPayload, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type PostHogContext, type RequestRegistry, type SetSideNavPayload, type UploadOptions, type UploadProgress, type UploadResponse, VibeAppBridge, type VibeAppBridgeOptions };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.3.0";
2
+ var version = "0.4.0";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
5
  function normalizeSubroute(subroute) {
@@ -72,6 +72,9 @@ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
72
72
  return false;
73
73
  }
74
74
 
75
+ // src/VibeAppBridge.ts
76
+ import posthogDefault from "posthog-js";
77
+
75
78
  // src/data-methods.ts
76
79
  var BridgeDataMethods = class {
77
80
  // ---------------------------------------------------------------------------
@@ -389,6 +392,14 @@ function resolveRequestTimeout(type, requestTimeout) {
389
392
  }
390
393
 
391
394
  // src/VibeAppBridge.ts
395
+ function resolvePosthog(mod) {
396
+ let candidate = mod;
397
+ while (candidate && typeof candidate.init !== "function" && candidate.default) {
398
+ candidate = candidate.default;
399
+ }
400
+ return candidate;
401
+ }
402
+ var posthog = resolvePosthog(posthogDefault);
392
403
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
393
404
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
394
405
  function isPreviewSelfHost(hostname) {
@@ -452,6 +463,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
452
463
  super();
453
464
  this.pendingRequests = /* @__PURE__ */ new Map();
454
465
  this.context = null;
466
+ /**
467
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
468
+ * Guards against a double-init and makes {@link track} a no-op until
469
+ * recording is actually live.
470
+ */
471
+ this.posthogInitialized = false;
455
472
  this.connectPromise = null;
456
473
  this.connectPollInterval = null;
457
474
  this.connectTimeout = null;
@@ -582,6 +599,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
582
599
  console.log(
583
600
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
584
601
  );
602
+ this.initPostHog(ctx);
585
603
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
586
604
  if (initialSubroute && initialSubroute !== "/") {
587
605
  this.withSubrouteSuppressed(() => {
@@ -686,6 +704,34 @@ var VibeAppBridge = class extends BridgeDataMethods {
686
704
  invalidateCache(payload) {
687
705
  return this.request("INVALIDATE_CACHE", payload);
688
706
  }
707
+ /**
708
+ * Captures a custom product event from inside the Vibe App, attributed to the
709
+ * same PostHog person as the host (via the `distinctId` the host supplied at
710
+ * connect).
711
+ *
712
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
713
+ * host** — if the host didn't send a `posthog` block (older host, or recording
714
+ * disabled for this env/app), nothing is sent. App authors don't import
715
+ * `posthog-js` themselves; this is the supported event surface.
716
+ *
717
+ * The event is sent **directly** from the iframe's own PostHog instance (only
718
+ * the session *recording* is forwarded to the parent), so it carries the
719
+ * iframe instance's `$session_id` rather than the host's — events tie to the
720
+ * same person, but cross-boundary event↔replay timeline correlation is a known
721
+ * limitation.
722
+ *
723
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
724
+ * keeps them filterable from the host's own events.
725
+ * @param properties Optional event properties.
726
+ * @example
727
+ * ```ts
728
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
729
+ * ```
730
+ */
731
+ track(event, properties) {
732
+ if (!this.posthogInitialized) return;
733
+ posthog.capture(event, properties);
734
+ }
689
735
  destroy() {
690
736
  this.teardownNavigationTracking();
691
737
  this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
@@ -696,6 +742,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
696
742
  }
697
743
  this.pendingRequests.clear();
698
744
  this.context = null;
745
+ this.posthogInitialized = false;
699
746
  this.connectPromise = null;
700
747
  this.onContextReceived = null;
701
748
  this.onContextError = null;
@@ -713,6 +760,59 @@ var VibeAppBridge = class extends BridgeDataMethods {
713
760
  }
714
761
  this.connectPollIds.clear();
715
762
  }
763
+ /**
764
+ * Initializes PostHog session recording + analytics from the host-supplied
765
+ * config, **only when the host opted in** (`ctx.posthog?.enabled`).
766
+ *
767
+ * The host is the single source of truth: when it sends no `posthog` block
768
+ * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
769
+ * this does nothing — `enabled` is the kill-switch even though `posthog-js` is
770
+ * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
771
+ * on both sides so the parent receives the iframe's rrweb frames into one
772
+ * stitched recording, and `identify()` ties the iframe's recording + events to
773
+ * the same person as the host. Wrapped in try/catch so a PostHog failure is
774
+ * best-effort and never blocks `connect()`.
775
+ */
776
+ initPostHog(ctx) {
777
+ const config = ctx.posthog;
778
+ if (!config?.enabled) return;
779
+ if (this.posthogInitialized) return;
780
+ if (typeof window === "undefined") return;
781
+ if (!config.token || !config.apiHost || !config.distinctId) {
782
+ console.warn(
783
+ "[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
784
+ );
785
+ return;
786
+ }
787
+ try {
788
+ posthog.init(config.token, {
789
+ api_host: config.apiHost,
790
+ // absolute ingestion host, NOT a relative /ingest proxy
791
+ person_profiles: "identified_only",
792
+ capture_pageview: false,
793
+ // the host owns pageviews; don't double-count
794
+ autocapture: false,
795
+ // opt-in events only, via track()
796
+ // Seed the host's session id so track() events share the host's
797
+ // $session_id and line up with the stitched replay. Replay stitching
798
+ // itself does not depend on this (recordCrossOriginIframes handles it).
799
+ ...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
800
+ session_recording: {
801
+ recordCrossOriginIframes: true,
802
+ // REQUIRED so the parent receives our rrweb frames
803
+ // Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
804
+ // richer insight, but keep password fields masked.
805
+ maskAllInputs: false,
806
+ maskInputOptions: { password: true }
807
+ }
808
+ });
809
+ posthog.identify(config.distinctId);
810
+ this.posthogInitialized = true;
811
+ console.log("[vibe-bridge] PostHog session recording initialized");
812
+ } catch (err) {
813
+ console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
814
+ }
815
+ }
716
816
  /**
717
817
  * Monkey-patches `history.pushState` and `history.replaceState` to
718
818
  * auto-detect SPA navigation and report it to the host. Called after
@@ -20,10 +20,20 @@ interface ContextPayload {
20
20
  user: { id: string; displayName: string }
21
21
  subroute?: string // initial deep-link path, if any
22
22
  lastClosedPeriodSlug?: string // e.g. "mar-2026"
23
+ posthog?: {
24
+ // present only when the host enables in-iframe recording
25
+ token: string
26
+ apiHost: string // absolute ingestion host, e.g. "https://us.i.posthog.com"
27
+ distinctId: string // host-resolved person id (so events tie to the same person)
28
+ sessionId?: string // host's posthog.get_session_id(); seeds bootstrap so track() events share the host session
29
+ enabled: boolean // master switch; false/absent → bridge initializes nothing
30
+ }
23
31
  hostVersion: string // version of @nominalso/vibe-host running in the host
24
32
  }
25
33
  ```
26
34
 
35
+ When the resolved context carries `posthog.enabled === true`, the bridge auto-initializes `posthog-js` for cross-origin session recording (stitched into the host's recording) and exposes `bridge.track(event, properties?)` for custom events — see [Session recording & custom events](../README.md#session-recording--custom-events). If the host omits the `posthog` block, recording stays off.
36
+
27
37
  ## How it works
28
38
 
29
39
  The host pushes context to the iframe on load, but the iframe's listener may not be ready when that push arrives. `connect()` solves the race:
@@ -8,7 +8,7 @@
8
8
  npm install @nominalso/vibe-bridge
9
9
  ```
10
10
 
11
- No runtime dependencies. Ships ESM + CJS with self-contained TypeScript types.
11
+ Bundles `posthog-js` (its only runtime dependency) for host-enabled in-iframe session recording. Ships ESM + CJS with self-contained TypeScript types.
12
12
 
13
13
  ## Minimal app
14
14
 
package/llms.txt CHANGED
@@ -1,8 +1,8 @@
1
1
  # @nominalso/vibe-bridge
2
2
 
3
- > Iframe-side TypeScript SDK for building Nominal Vibe Apps — standalone browser apps (often built with Lovable) embedded in the Nominal platform via a cross-origin iframe. Connects the app to its Nominal host over a typed postMessage protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync. Browser-only, zero runtime dependencies.
3
+ > Iframe-side TypeScript SDK for building Nominal Vibe Apps — standalone browser apps (often built with Lovable) embedded in the Nominal platform via a cross-origin iframe. Connects the app to its Nominal host over a typed postMessage protocol: read Nominal data, submit Close-Management task outputs, upload files, keep deep-link routing in sync, and (when the host enables it) record the in-iframe session to PostHog. Browser-only; bundles posthog-js as its only runtime dependency.
4
4
 
5
- The integration is always the same: `new VibeAppBridge()` (no config — `parentOrigin` is optional and the bridge auto-detects the embedding Nominal host; pass it only to pin explicitly or to allow preview origins), `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`.
5
+ The integration is always the same: `new VibeAppBridge()` (no config — `parentOrigin` is optional and the bridge auto-detects the embedding Nominal host; pass it only to pin explicitly or to allow preview origins), `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`. Session recording auto-initializes on `connect()` only when the host sends a `posthog` block with `enabled: true`; emit custom events with `bridge.track(event, properties?)` (a no-op until then).
6
6
 
7
7
  ## Docs
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",
@@ -33,6 +33,9 @@
33
33
  "registry": "https://registry.npmjs.org/",
34
34
  "access": "public"
35
35
  },
36
+ "dependencies": {
37
+ "posthog-js": "^1.393.5"
38
+ },
36
39
  "devDependencies": {
37
40
  "@nominalso/vibe-api-types": "0.0.1",
38
41
  "@nominalso/vibe-protocol-types": "0.0.1"