@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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.3.0";
2
+ var version = "0.5.0";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
5
  function normalizeSubroute(subroute) {
@@ -15,7 +15,7 @@ function normalizeSubroute(subroute) {
15
15
  return normalized;
16
16
  }
17
17
  var PROTOCOL_ID = "nominal-vibe-bridge";
18
- var PROTOCOL_VERSION = 1;
18
+ var PROTOCOL_VERSION = 2;
19
19
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
20
20
  var CORRELATED_KINDS = ["request", "response", "progress"];
21
21
  var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
@@ -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
  // ---------------------------------------------------------------------------
@@ -367,8 +370,6 @@ var CONNECT_POLL_MS = 500;
367
370
  var DEFAULT_TIMEOUTS = {
368
371
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
369
372
  api: 3e4,
370
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
371
- context: 5e3,
372
373
  /** `INVALIDATE_CACHE`. */
373
374
  invalidate: 1e4,
374
375
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -381,14 +382,20 @@ function resolveRequestTimeout(type, requestTimeout) {
381
382
  return DEFAULT_TIMEOUTS.upload;
382
383
  case "INVALIDATE_CACHE":
383
384
  return DEFAULT_TIMEOUTS.invalidate;
384
- case "GET_CONTEXT":
385
- return DEFAULT_TIMEOUTS.context;
386
385
  default:
387
386
  return DEFAULT_TIMEOUTS.api;
388
387
  }
389
388
  }
390
389
 
391
390
  // src/VibeAppBridge.ts
391
+ function resolvePosthog(mod) {
392
+ let candidate = mod;
393
+ while (candidate && typeof candidate.init !== "function" && candidate.default) {
394
+ candidate = candidate.default;
395
+ }
396
+ return candidate;
397
+ }
398
+ var posthog = resolvePosthog(posthogDefault);
392
399
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
393
400
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
394
401
  function isPreviewSelfHost(hostname) {
@@ -452,17 +459,30 @@ var VibeAppBridge = class extends BridgeDataMethods {
452
459
  super();
453
460
  this.pendingRequests = /* @__PURE__ */ new Map();
454
461
  this.context = null;
462
+ /**
463
+ * Set once {@link initPostHog} has successfully called `posthog.init()`.
464
+ * Guards against a double-init and makes {@link track} a no-op until
465
+ * recording is actually live.
466
+ */
467
+ this.posthogInitialized = false;
455
468
  this.connectPromise = null;
456
469
  this.connectPollInterval = null;
457
470
  this.connectTimeout = null;
458
471
  this.onContextReceived = null;
459
- this.onContextError = null;
472
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
473
+ this.rejectConnect = null;
460
474
  /**
461
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
462
- * late response from a *previous* connect attempt, which would otherwise
463
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
475
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
476
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
477
+ * resolves the connect handshake and is cleared afterward. `null` until
478
+ * {@link onContextChange} is called.
464
479
  */
465
- this.connectPollIds = /* @__PURE__ */ new Set();
480
+ this.contextChangeCallback = null;
481
+ /**
482
+ * Persistent subscriber for host auth changes (Nominal logout or
483
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
484
+ */
485
+ this.authChangeCallback = null;
466
486
  this.lastReportedSubroute = null;
467
487
  this.suppressSubrouteReport = false;
468
488
  this.subrouteCallback = null;
@@ -475,7 +495,21 @@ var VibeAppBridge = class extends BridgeDataMethods {
475
495
  progress: (msg) => this.handleProgress(msg)
476
496
  };
477
497
  this.pushHandlers = {
478
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
498
+ CONTEXT_PUSH: (payload) => {
499
+ if (this.onContextReceived) {
500
+ this.onContextReceived(payload);
501
+ } else if (this.context) {
502
+ this.context = payload;
503
+ this.contextChangeCallback?.(payload);
504
+ }
505
+ },
506
+ // Persistent (not nulled after connect): the host delivers PostHog once at
507
+ // connect via its own push, and initPostHog is idempotent, so this can run
508
+ // whenever the message arrives, independent of how connect() resolved.
509
+ POSTHOG_PUSH: (payload) => this.initPostHog(payload),
510
+ // Persistent: the host pushes auth changes (logout, identity/tenant
511
+ // switch) at any time after connect.
512
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
479
513
  SUBROUTE_PUSH: (payload) => {
480
514
  const safe = normalizeSubroute(payload.subroute);
481
515
  if (!safe) return;
@@ -526,11 +560,11 @@ var VibeAppBridge = class extends BridgeDataMethods {
526
560
  * methods, `upload()`, and subroute reporting all require an established
527
561
  * connection. Concurrent calls return the same promise.
528
562
  *
529
- * Actively polls the host every 500ms until a response arrives, which
530
- * handles the race condition where the host's initial context push arrives
531
- * before this listener is ready. Rejects with `Bridge connect timed out`
532
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
533
- * or the host hasn't mounted).
563
+ * Sends a `CONNECT` handshake, re-sending every 500ms until the host replies
564
+ * by pushing the context (so it works even if the host mounts after the
565
+ * iframe loads). Context is only ever delivered by push. Rejects with `Bridge
566
+ * connect timed out` after 10 seconds if no context arrives (usually a
567
+ * `parentOrigin` mismatch or the host hasn't mounted).
534
568
  *
535
569
  * After connecting, if the context includes an initial subroute (e.g. from
536
570
  * a deep link), the bridge navigates to it and starts auto-tracking
@@ -560,16 +594,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
560
594
  this.stopConnectPolling();
561
595
  this.connectPromise = null;
562
596
  this.onContextReceived = null;
563
- this.onContextError = null;
597
+ this.rejectConnect = null;
564
598
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
565
599
  }, CONNECT_TIMEOUT_MS);
566
600
  this.connectTimeout = timer;
567
- this.onContextError = (error) => {
601
+ this.rejectConnect = (error) => {
568
602
  clearTimeout(timer);
569
603
  this.stopConnectPolling();
570
604
  this.connectPromise = null;
571
605
  this.onContextReceived = null;
572
- this.onContextError = null;
606
+ this.rejectConnect = null;
573
607
  reject(error);
574
608
  };
575
609
  this.onContextReceived = (ctx) => {
@@ -578,7 +612,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
578
612
  this.context = ctx;
579
613
  this.connectPromise = null;
580
614
  this.onContextReceived = null;
581
- this.onContextError = null;
615
+ this.rejectConnect = null;
582
616
  console.log(
583
617
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
584
618
  );
@@ -593,23 +627,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
593
627
  this.lastReportedSubroute = window.location.pathname + window.location.search;
594
628
  }
595
629
  this.setupNavigationTracking();
630
+ this.warnMissingListeners();
596
631
  resolve(ctx);
597
632
  };
598
- const poll = () => {
599
- const requestId = crypto.randomUUID();
600
- this.connectPollIds.add(requestId);
601
- const message = {
602
- __protocol: PROTOCOL_ID,
603
- __version: PROTOCOL_VERSION,
604
- kind: "request",
605
- type: "GET_CONTEXT",
606
- requestId,
607
- payload: { bridgeVersion: version }
608
- };
609
- this.postToParent(message);
633
+ const sendConnect = () => {
634
+ this.sendCommand("CONNECT", { bridgeVersion: version });
610
635
  };
611
- poll();
612
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
636
+ sendConnect();
637
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
613
638
  });
614
639
  return this.connectPromise;
615
640
  }
@@ -636,6 +661,56 @@ var VibeAppBridge = class extends BridgeDataMethods {
636
661
  this.subrouteCallback = null;
637
662
  };
638
663
  }
664
+ /**
665
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
666
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
667
+ * *after* connect — not for the initial context, which {@link connect} already
668
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
669
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
670
+ * connect if it isn't).
671
+ */
672
+ onContextChange(callback) {
673
+ this.contextChangeCallback = callback;
674
+ return () => {
675
+ this.contextChangeCallback = null;
676
+ };
677
+ }
678
+ /**
679
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
680
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
681
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
682
+ * host pushes after connect. Returns an unsubscribe function. Single
683
+ * subscriber: a second call replaces the first.
684
+ *
685
+ * **Wire this before {@link connect}** — without it the embedded app won't
686
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
687
+ * it isn't wired).
688
+ */
689
+ onAuthChange(callback) {
690
+ this.authChangeCallback = callback;
691
+ return () => {
692
+ this.authChangeCallback = null;
693
+ };
694
+ }
695
+ /**
696
+ * At connect, nudge the app if it hasn't wired the change listeners: every
697
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
698
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
699
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
700
+ * The skill/codegen does this by default; this catches hand-wired apps.
701
+ */
702
+ warnMissingListeners() {
703
+ if (!this.authChangeCallback) {
704
+ console.warn(
705
+ "[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()."
706
+ );
707
+ }
708
+ if (!this.contextChangeCallback) {
709
+ console.warn(
710
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
711
+ );
712
+ }
713
+ }
639
714
  // ---------------------------------------------------------------------------
640
715
  // Host navigation & UI commands
641
716
  //
@@ -686,9 +761,37 @@ var VibeAppBridge = class extends BridgeDataMethods {
686
761
  invalidateCache(payload) {
687
762
  return this.request("INVALIDATE_CACHE", payload);
688
763
  }
764
+ /**
765
+ * Captures a custom product event from inside the Vibe App, attributed to the
766
+ * same PostHog person as the host (via the `distinctId` the host supplied at
767
+ * connect).
768
+ *
769
+ * **No-op until {@link connect} has resolved with PostHog enabled by the
770
+ * host** — if the host didn't send a `posthog` block (older host, or recording
771
+ * disabled for this env/app), nothing is sent. App authors don't import
772
+ * `posthog-js` themselves; this is the supported event surface.
773
+ *
774
+ * The event is sent **directly** from the iframe's own PostHog instance (only
775
+ * the session *recording* is forwarded to the parent), so it carries the
776
+ * iframe instance's `$session_id` rather than the host's — events tie to the
777
+ * same person, but cross-boundary event↔replay timeline correlation is a known
778
+ * limitation.
779
+ *
780
+ * @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
781
+ * keeps them filterable from the host's own events.
782
+ * @param properties Optional event properties.
783
+ * @example
784
+ * ```ts
785
+ * bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
786
+ * ```
787
+ */
788
+ track(event, properties) {
789
+ if (!this.posthogInitialized) return;
790
+ posthog.capture(event, properties);
791
+ }
689
792
  destroy() {
690
793
  this.teardownNavigationTracking();
691
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
794
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
692
795
  this.stopConnectPolling();
693
796
  window.removeEventListener("message", this.boundHandleMessage);
694
797
  for (const pending of this.pendingRequests.values()) {
@@ -696,9 +799,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
696
799
  }
697
800
  this.pendingRequests.clear();
698
801
  this.context = null;
802
+ this.posthogInitialized = false;
699
803
  this.connectPromise = null;
700
804
  this.onContextReceived = null;
701
- this.onContextError = null;
805
+ this.rejectConnect = null;
806
+ this.contextChangeCallback = null;
807
+ this.authChangeCallback = null;
702
808
  this.subrouteCallback = null;
703
809
  this.lastReportedSubroute = null;
704
810
  }
@@ -711,7 +817,59 @@ var VibeAppBridge = class extends BridgeDataMethods {
711
817
  clearTimeout(this.connectTimeout);
712
818
  this.connectTimeout = null;
713
819
  }
714
- this.connectPollIds.clear();
820
+ }
821
+ /**
822
+ * Initializes PostHog session recording + analytics from the host-supplied
823
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
824
+ * (`config.enabled`).
825
+ *
826
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
827
+ * (recording disabled for this env/app) or `enabled: false`, this does
828
+ * nothing — `enabled` is the kill-switch even though `posthog-js` is bundled
829
+ * into the bridge. Cross-origin replay needs `recordCrossOriginIframes` on both
830
+ * sides so the parent receives the iframe's rrweb frames into one stitched
831
+ * recording, and `identify()` ties the iframe's recording + events to the same
832
+ * person as the host. Idempotent (the `posthogInitialized` guard) and wrapped
833
+ * in try/catch so a duplicate push or a PostHog failure is harmless.
834
+ */
835
+ initPostHog(config) {
836
+ if (!config.enabled) return;
837
+ if (this.posthogInitialized) return;
838
+ if (typeof window === "undefined") return;
839
+ if (!config.token || !config.apiHost || !config.distinctId) {
840
+ console.warn(
841
+ "[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
842
+ );
843
+ return;
844
+ }
845
+ try {
846
+ posthog.init(config.token, {
847
+ api_host: config.apiHost,
848
+ // absolute ingestion host, NOT a relative /ingest proxy
849
+ person_profiles: "identified_only",
850
+ capture_pageview: false,
851
+ // the host owns pageviews; don't double-count
852
+ autocapture: false,
853
+ // opt-in events only, via track()
854
+ // Seed the host's session id so track() events share the host's
855
+ // $session_id and line up with the stitched replay. Replay stitching
856
+ // itself does not depend on this (recordCrossOriginIframes handles it).
857
+ ...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
858
+ session_recording: {
859
+ recordCrossOriginIframes: true,
860
+ // REQUIRED so the parent receives our rrweb frames
861
+ // Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
862
+ // richer insight, but keep password fields masked.
863
+ maskAllInputs: false,
864
+ maskInputOptions: { password: true }
865
+ }
866
+ });
867
+ posthog.identify(config.distinctId);
868
+ this.posthogInitialized = true;
869
+ console.log("[vibe-bridge] PostHog session recording initialized");
870
+ } catch (err) {
871
+ console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
872
+ }
715
873
  }
716
874
  /**
717
875
  * Monkey-patches `history.pushState` and `history.replaceState` to
@@ -834,15 +992,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
834
992
  this.kindHandlers[event.data.kind]?.(event.data);
835
993
  }
836
994
  handleResponse(msg) {
837
- if (msg.type === "GET_CONTEXT") {
838
- if (!this.connectPollIds.has(msg.requestId)) return;
839
- if (msg.ok) {
840
- this.onContextReceived?.(msg.data);
841
- } else {
842
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
843
- }
844
- return;
845
- }
846
995
  const pending = this.pendingRequests.get(msg.requestId);
847
996
  if (!pending) return;
848
997
  this.pendingRequests.delete(msg.requestId);
@@ -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.5.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"