@nominalso/vibe-bridge 0.2.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 +11 -10
- package/README.md +63 -21
- package/dist/index.cjs +234 -10
- package/dist/index.d.cts +141 -10
- package/dist/index.d.ts +141 -10
- package/dist/index.js +224 -10
- package/docs/connect-lifecycle.md +11 -1
- package/docs/getting-started.md +13 -11
- package/llms.txt +3 -3
- 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
|
|
|
@@ -15,9 +15,7 @@ The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — yo
|
|
|
15
15
|
```ts
|
|
16
16
|
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
17
17
|
|
|
18
|
-
const bridge = new VibeAppBridge(
|
|
19
|
-
parentOrigin: import.meta.env.VITE_PARENT_ORIGIN, // exact origin of the embedding Nominal app
|
|
20
|
-
})
|
|
18
|
+
const bridge = new VibeAppBridge() // no config — auto-detects the embedding Nominal app
|
|
21
19
|
|
|
22
20
|
const ctx: ContextPayload = await bridge.connect() // call ONCE, await before anything else
|
|
23
21
|
|
|
@@ -32,27 +30,30 @@ bridge.destroy() // on unmount
|
|
|
32
30
|
|
|
33
31
|
## Core surface
|
|
34
32
|
|
|
35
|
-
- `new VibeAppBridge({ parentOrigin
|
|
36
|
-
- `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `
|
|
33
|
+
- `new VibeAppBridge({ parentOrigin?, requestTimeout? })` — both optional; `new VibeAppBridge()` auto-detects the host. `requestTimeout` omitted → tuned per-op defaults (API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000` ms).
|
|
34
|
+
- `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on a host that never mounts, or immediately with code `PARENT_ORIGIN_UNRESOLVED` if no host could be detected/pinned.
|
|
37
35
|
- ~46 typed data methods, e.g. `getChartOfAccounts`, `getSubsidiaries`, `getPeriods`, `getJournalEntries`, `getAccounts`, `postTaskOutput`. Each takes the operation's `payload` and returns its `data`.
|
|
38
36
|
- `request('<OPERATION>', payload, onProgress?)` — typed escape hatch for any operation; `'<OPERATION>'` autocompletes and narrows the payload/return types.
|
|
39
37
|
- `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
|
|
40
38
|
- `navigate`/`navigateTo`/`refresh` and `ui.setSideNav`/`ui.switchSubsidiary` — drive the host's chrome (router, side nav, subsidiary switcher); `invalidateCache(...)` busts host caches.
|
|
41
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.
|
|
42
41
|
- `destroy()`.
|
|
43
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
|
+
|
|
44
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`).
|
|
45
46
|
|
|
46
|
-
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`.
|
|
47
48
|
|
|
48
|
-
`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.
|
|
49
50
|
|
|
50
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/`.
|
|
51
52
|
|
|
52
53
|
## Gotchas (wrong → right)
|
|
53
54
|
|
|
54
55
|
- **Don't call data methods before `connect()` resolves.** Always `await bridge.connect()` first.
|
|
55
|
-
- **`parentOrigin` is an origin
|
|
56
|
+
- **`parentOrigin` is optional** — omit it and the bridge auto-detects the embedding host. If you do pass it, it's an origin or list of origins (not a URL): `https://app.nominal.so` ✅ — not `https://app.nominal.so/app`, no trailing slash. For preview testing pass a list with a glob: `['https://app.nominal.so', 'https://*.vercel.app']` — patterns match only when the app itself runs on a preview host.
|
|
56
57
|
- **`upload()` takes a `File`**, not a path or `FormData`.
|
|
57
58
|
- **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
|
|
58
59
|
- **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
|
|
@@ -60,5 +61,5 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
|
|
|
60
61
|
## Don't
|
|
61
62
|
|
|
62
63
|
- Don't poll or busy-wait for context — `await connect()` resolves when it's ready.
|
|
63
|
-
- Don't
|
|
64
|
+
- Don't pass `parentOrigin` unless you mean to pin the host; the default auto-detects it. If you pin, read it from an env var (and add a preview pattern to the list when testing deploy previews).
|
|
64
65
|
- Don't import from `@nominalso/vibe-protocol-types` / `vibe-api-types` — they are not published; all public types are re-exported from `@nominalso/vibe-bridge`.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typically built with [Lovable](https://lovable.dev)) embedded in the Nominal platform via a cross-origin `<iframe>`. The bridge connects your 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.
|
|
4
4
|
|
|
5
|
-
> **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same —
|
|
5
|
+
> **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same — `new VibeAppBridge()` (no config — it auto-detects the Nominal host), `await bridge.connect()` **once**, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,18 +10,17 @@ 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
|
|
|
17
17
|
```ts
|
|
18
18
|
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
19
19
|
|
|
20
|
-
// 1. Construct
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
20
|
+
// 1. Construct. No config needed — the bridge auto-detects the Nominal app
|
|
21
|
+
// that embeds it. (Pin the host origin explicitly only if you want to;
|
|
22
|
+
// see "Pinning the host origin" below.)
|
|
23
|
+
const bridge = new VibeAppBridge()
|
|
25
24
|
|
|
26
25
|
// 2. Connect ONCE on init and await it before any other call.
|
|
27
26
|
// Resolves with the tenant/user context (or rejects after 10s).
|
|
@@ -47,28 +46,47 @@ await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })
|
|
|
47
46
|
bridge.destroy()
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
### Pinning the host origin (optional)
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
By default you pass **nothing** — the bridge talks only to the Nominal page that
|
|
52
|
+
actually embeds it. That's safe because Nominal serves Vibe Apps behind a
|
|
53
|
+
`frame-ancestors` CSP, so only nom-ui can frame your app. Most apps need nothing
|
|
54
|
+
more.
|
|
55
|
+
|
|
56
|
+
Pass `parentOrigin` to pin explicitly — defense in depth, or when running
|
|
57
|
+
outside Nominal's edge. Drive it from an env var, or use a list / **glob
|
|
58
|
+
patterns** to accept dynamic preview deployments:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
|
|
62
|
+
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
|
|
63
|
+
```
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
```sh
|
|
66
|
+
# .env.local
|
|
57
67
|
VITE_PARENT_ORIGIN=http://localhost:3000
|
|
58
68
|
```
|
|
59
69
|
|
|
70
|
+
A pattern's `*` matches exactly one DNS label or port (anchored, scheme
|
|
71
|
+
literal): `https://*.vercel.app` matches `https://pr-7.vercel.app` but not
|
|
72
|
+
`https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. **Patterns are
|
|
73
|
+
honoured only when this app's own page is served from a recognised preview host**
|
|
74
|
+
(`*.vercel.app`, `*.lovable.app`, `localhost`, …) — on a production custom
|
|
75
|
+
domain pattern entries are ignored and only exact origins match. Always include
|
|
76
|
+
at least one exact origin alongside any patterns.
|
|
77
|
+
|
|
60
78
|
## API
|
|
61
79
|
|
|
62
80
|
### `new VibeAppBridge(options)`
|
|
63
81
|
|
|
64
|
-
| Option | Type
|
|
65
|
-
| ---------------- |
|
|
66
|
-
| `parentOrigin` | `string` |
|
|
67
|
-
| `requestTimeout` | `number`
|
|
82
|
+
| Option | Type | Default | Description |
|
|
83
|
+
| ---------------- | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `parentOrigin` | `string \| string[]` | _auto_ | **Optional.** Omit to auto-detect the embedding Nominal host. Pass to pin: exact origins always match; glob patterns (`https://*.vercel.app`) match only when this app runs on a preview host. See above. |
|
|
85
|
+
| `requestTimeout` | `number` | _per-op_ | Global timeout (ms) before a call rejects with `BridgeError` code `'TIMEOUT'`. Omit to use per-operation defaults: API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`. |
|
|
68
86
|
|
|
69
87
|
### `connect(): Promise<ContextPayload>`
|
|
70
88
|
|
|
71
|
-
Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). Concurrent calls return the same promise.
|
|
89
|
+
Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). If no concrete parent origin can be resolved (e.g. a pattern-only `parentOrigin` on a non-preview host), it rejects immediately with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`. Concurrent calls return the same promise.
|
|
72
90
|
|
|
73
91
|
### Data operations
|
|
74
92
|
|
|
@@ -100,9 +118,31 @@ Uploads a `File` through the host (converts to `ArrayBuffer` first — sandboxed
|
|
|
100
118
|
|
|
101
119
|
Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount.
|
|
102
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
|
+
|
|
103
143
|
### Exports
|
|
104
144
|
|
|
105
|
-
`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`.
|
|
106
146
|
|
|
107
147
|
## Common recipes
|
|
108
148
|
|
|
@@ -115,7 +155,7 @@ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
|
115
155
|
function useVibeBridge() {
|
|
116
156
|
const [ctx, setCtx] = useState<ContextPayload | null>(null)
|
|
117
157
|
useEffect(() => {
|
|
118
|
-
const bridge = new VibeAppBridge(
|
|
158
|
+
const bridge = new VibeAppBridge()
|
|
119
159
|
bridge.connect().then(setCtx).catch(console.error)
|
|
120
160
|
return () => bridge.destroy()
|
|
121
161
|
}, [])
|
|
@@ -172,13 +212,15 @@ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.su
|
|
|
172
212
|
```
|
|
173
213
|
|
|
174
214
|
```ts
|
|
175
|
-
// ❌ WRONG —
|
|
215
|
+
// ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
|
|
176
216
|
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
|
|
177
217
|
// ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
|
|
178
218
|
new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
|
|
179
219
|
|
|
180
|
-
// ✅ CORRECT — scheme + host + port only,
|
|
220
|
+
// ✅ CORRECT — scheme + host + port only, matching the embedding app.
|
|
181
221
|
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
|
|
222
|
+
// ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
|
|
223
|
+
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
|
|
182
224
|
```
|
|
183
225
|
|
|
184
226
|
```ts
|
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.4.0";
|
|
32
42
|
|
|
33
43
|
// ../protocol-types/dist/index.js
|
|
34
44
|
function normalizeSubroute(subroute) {
|
|
@@ -73,6 +83,36 @@ var HttpBridgeError = class extends BridgeError {
|
|
|
73
83
|
this.name = "HttpBridgeError";
|
|
74
84
|
}
|
|
75
85
|
};
|
|
86
|
+
function isOriginPattern(entry) {
|
|
87
|
+
return entry.includes("*");
|
|
88
|
+
}
|
|
89
|
+
var patternCache = /* @__PURE__ */ new Map();
|
|
90
|
+
function patternToRegExp(pattern) {
|
|
91
|
+
const cached = patternCache.get(pattern);
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
|
|
95
|
+
const compiled = new RegExp(`^${body}$`);
|
|
96
|
+
patternCache.set(pattern, compiled);
|
|
97
|
+
return compiled;
|
|
98
|
+
}
|
|
99
|
+
function matchesOrigin(origin, entry) {
|
|
100
|
+
if (!isOriginPattern(entry)) return origin === entry;
|
|
101
|
+
return patternToRegExp(entry).test(origin);
|
|
102
|
+
}
|
|
103
|
+
function isOriginAllowed(origin, allowlist, { allowPatterns }) {
|
|
104
|
+
for (const entry of allowlist) {
|
|
105
|
+
if (isOriginPattern(entry)) {
|
|
106
|
+
if (allowPatterns && matchesOrigin(origin, entry)) return true;
|
|
107
|
+
} else if (origin === entry) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/VibeAppBridge.ts
|
|
115
|
+
var import_posthog_js = __toESM(require("posthog-js"), 1);
|
|
76
116
|
|
|
77
117
|
// src/data-methods.ts
|
|
78
118
|
var BridgeDataMethods = class {
|
|
@@ -391,13 +431,86 @@ function resolveRequestTimeout(type, requestTimeout) {
|
|
|
391
431
|
}
|
|
392
432
|
|
|
393
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);
|
|
442
|
+
var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
|
|
443
|
+
var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
|
|
444
|
+
function isPreviewSelfHost(hostname) {
|
|
445
|
+
return PREVIEW_HOST_RE.test(hostname);
|
|
446
|
+
}
|
|
447
|
+
function resolveActualParentOrigin() {
|
|
448
|
+
if (typeof window === "undefined") return null;
|
|
449
|
+
try {
|
|
450
|
+
const ancestors = window.location.ancestorOrigins;
|
|
451
|
+
if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
if (document.referrer) return new URL(document.referrer).origin;
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function resolveOriginPolicy(configured) {
|
|
461
|
+
if (typeof window !== "undefined") {
|
|
462
|
+
const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
|
|
463
|
+
if (typeof override === "string" && override.length > 0) {
|
|
464
|
+
console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
|
|
465
|
+
return { targetOrigin: override, isTrusted: (o) => o === override };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
|
|
469
|
+
const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
470
|
+
if (allowlist.length === 0) {
|
|
471
|
+
const actual2 = resolveActualParentOrigin();
|
|
472
|
+
if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
|
|
473
|
+
console.warn(
|
|
474
|
+
"[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
|
|
475
|
+
);
|
|
476
|
+
return { targetOrigin: null, isTrusted: () => false };
|
|
477
|
+
}
|
|
478
|
+
const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
|
|
479
|
+
const hasPatterns = exactEntries.length !== allowlist.length;
|
|
480
|
+
if (allowlist.length === 1 && !hasPatterns) {
|
|
481
|
+
const only = allowlist[0];
|
|
482
|
+
return { targetOrigin: only, isTrusted: (o) => o === only };
|
|
483
|
+
}
|
|
484
|
+
const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
|
|
485
|
+
const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
|
|
486
|
+
const actual = resolveActualParentOrigin();
|
|
487
|
+
let targetOrigin = null;
|
|
488
|
+
if (actual && isTrusted(actual)) {
|
|
489
|
+
targetOrigin = actual;
|
|
490
|
+
} else if (actual === null && !allowPatterns && exactEntries.length === 1) {
|
|
491
|
+
targetOrigin = exactEntries[0];
|
|
492
|
+
}
|
|
493
|
+
if (targetOrigin === null) {
|
|
494
|
+
console.warn(
|
|
495
|
+
"[vibe-bridge] Could not resolve a parent origin to post to. Check `parentOrigin` against the embedding host" + (hasPatterns && !allowPatterns ? " \u2014 pattern entries are ignored because this page is not served from a recognised preview host." : ".")
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return { targetOrigin, isTrusted };
|
|
499
|
+
}
|
|
394
500
|
var VibeAppBridge = class extends BridgeDataMethods {
|
|
395
|
-
constructor({ parentOrigin, requestTimeout }) {
|
|
501
|
+
constructor({ parentOrigin, requestTimeout } = {}) {
|
|
396
502
|
super();
|
|
397
503
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
398
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;
|
|
399
511
|
this.connectPromise = null;
|
|
400
512
|
this.connectPollInterval = null;
|
|
513
|
+
this.connectTimeout = null;
|
|
401
514
|
this.onContextReceived = null;
|
|
402
515
|
this.onContextError = null;
|
|
403
516
|
/**
|
|
@@ -452,7 +565,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
452
565
|
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
453
566
|
}
|
|
454
567
|
};
|
|
455
|
-
|
|
568
|
+
const policy = resolveOriginPolicy(parentOrigin);
|
|
569
|
+
this.targetOrigin = policy.targetOrigin;
|
|
570
|
+
this.isTrustedOrigin = policy.isTrusted;
|
|
456
571
|
this.requestTimeout = requestTimeout;
|
|
457
572
|
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
458
573
|
window.addEventListener("message", this.boundHandleMessage);
|
|
@@ -488,6 +603,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
488
603
|
connect() {
|
|
489
604
|
if (this.context) return Promise.resolve(this.context);
|
|
490
605
|
if (this.connectPromise) return this.connectPromise;
|
|
606
|
+
if (this.targetOrigin === null) {
|
|
607
|
+
return Promise.reject(
|
|
608
|
+
new BridgeError(
|
|
609
|
+
"PARENT_ORIGIN_UNRESOLVED",
|
|
610
|
+
"Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
|
|
611
|
+
)
|
|
612
|
+
);
|
|
613
|
+
}
|
|
491
614
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
492
615
|
const timer = setTimeout(() => {
|
|
493
616
|
this.stopConnectPolling();
|
|
@@ -496,6 +619,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
496
619
|
this.onContextError = null;
|
|
497
620
|
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
498
621
|
}, CONNECT_TIMEOUT_MS);
|
|
622
|
+
this.connectTimeout = timer;
|
|
499
623
|
this.onContextError = (error) => {
|
|
500
624
|
clearTimeout(timer);
|
|
501
625
|
this.stopConnectPolling();
|
|
@@ -514,6 +638,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
514
638
|
console.log(
|
|
515
639
|
`[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
|
|
516
640
|
);
|
|
641
|
+
this.initPostHog(ctx);
|
|
517
642
|
const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
|
|
518
643
|
if (initialSubroute && initialSubroute !== "/") {
|
|
519
644
|
this.withSubrouteSuppressed(() => {
|
|
@@ -538,7 +663,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
538
663
|
requestId,
|
|
539
664
|
payload: { bridgeVersion: version }
|
|
540
665
|
};
|
|
541
|
-
|
|
666
|
+
this.postToParent(message);
|
|
542
667
|
};
|
|
543
668
|
poll();
|
|
544
669
|
this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
|
|
@@ -618,8 +743,37 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
618
743
|
invalidateCache(payload) {
|
|
619
744
|
return this.request("INVALIDATE_CACHE", payload);
|
|
620
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
|
+
}
|
|
621
774
|
destroy() {
|
|
622
775
|
this.teardownNavigationTracking();
|
|
776
|
+
this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
623
777
|
this.stopConnectPolling();
|
|
624
778
|
window.removeEventListener("message", this.boundHandleMessage);
|
|
625
779
|
for (const pending of this.pendingRequests.values()) {
|
|
@@ -627,6 +781,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
627
781
|
}
|
|
628
782
|
this.pendingRequests.clear();
|
|
629
783
|
this.context = null;
|
|
784
|
+
this.posthogInitialized = false;
|
|
630
785
|
this.connectPromise = null;
|
|
631
786
|
this.onContextReceived = null;
|
|
632
787
|
this.onContextError = null;
|
|
@@ -638,8 +793,65 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
638
793
|
clearInterval(this.connectPollInterval);
|
|
639
794
|
this.connectPollInterval = null;
|
|
640
795
|
}
|
|
796
|
+
if (this.connectTimeout !== null) {
|
|
797
|
+
clearTimeout(this.connectTimeout);
|
|
798
|
+
this.connectTimeout = null;
|
|
799
|
+
}
|
|
641
800
|
this.connectPollIds.clear();
|
|
642
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
|
+
}
|
|
643
855
|
/**
|
|
644
856
|
* Monkey-patches `history.pushState` and `history.replaceState` to
|
|
645
857
|
* auto-detect SPA navigation and report it to the host. Called after
|
|
@@ -694,10 +906,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
694
906
|
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
695
907
|
}
|
|
696
908
|
sendCommand(type, payload) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
909
|
+
this.postToParent({
|
|
910
|
+
__protocol: PROTOCOL_ID,
|
|
911
|
+
__version: PROTOCOL_VERSION,
|
|
912
|
+
kind: "command",
|
|
913
|
+
type,
|
|
914
|
+
payload
|
|
915
|
+
});
|
|
701
916
|
}
|
|
702
917
|
/**
|
|
703
918
|
* Low-level typed request for **any** operation in {@link RequestRegistry}.
|
|
@@ -740,11 +955,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
740
955
|
requestId,
|
|
741
956
|
payload
|
|
742
957
|
};
|
|
743
|
-
|
|
958
|
+
this.postToParent(message);
|
|
744
959
|
});
|
|
745
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* Posts to the embedding host. No-op when no concrete target origin could be
|
|
963
|
+
* resolved (a config/embedding error) — `connect()` rejects fast in that case
|
|
964
|
+
* so calls never silently hang waiting on a timeout.
|
|
965
|
+
*/
|
|
966
|
+
postToParent(message) {
|
|
967
|
+
if (this.targetOrigin === null) return;
|
|
968
|
+
window.parent.postMessage(message, this.targetOrigin);
|
|
969
|
+
}
|
|
746
970
|
handleMessage(event) {
|
|
747
|
-
if (event.origin
|
|
971
|
+
if (!this.isTrustedOrigin(event.origin)) return;
|
|
748
972
|
if (!isBridgeMessage(event.data)) return;
|
|
749
973
|
this.kindHandlers[event.data.kind]?.(event.data);
|
|
750
974
|
}
|