@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 +6 -3
- package/README.md +24 -2
- package/dist/index.cjs +206 -47
- package/dist/index.d.cts +168 -26
- package/dist/index.d.ts +168 -26
- package/dist/index.js +196 -47
- package/docs/connect-lifecycle.md +10 -0
- package/docs/getting-started.md +1 -1
- package/llms.txt +2 -2
- package/package.json +4 -1
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
511
|
+
/** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
|
|
512
|
+
this.rejectConnect = null;
|
|
489
513
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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.
|
|
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) =>
|
|
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
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* after 10 seconds if no context arrives (usually a
|
|
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.
|
|
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.
|
|
640
|
+
this.rejectConnect = (error) => {
|
|
597
641
|
clearTimeout(timer);
|
|
598
642
|
this.stopConnectPolling();
|
|
599
643
|
this.connectPromise = null;
|
|
600
644
|
this.onContextReceived = null;
|
|
601
|
-
this.
|
|
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.
|
|
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
|
|
628
|
-
|
|
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
|
-
|
|
641
|
-
this.connectPollInterval = setInterval(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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);
|