@nominalso/vibe-bridge 0.1.0 → 0.3.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
@@ -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,15 +30,18 @@ bridge.destroy() // on unmount
32
30
 
33
31
  ## Core surface
34
32
 
35
- - `new VibeAppBridge({ parentOrigin, requestTimeout? })`
36
- - `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `parentOrigin` mismatch / host not mounted.
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 }>`.
38
+ - `navigate`/`navigateTo`/`refresh` and `ui.setSideNav`/`ui.switchSubsidiary` — drive the host's chrome (router, side nav, subsidiary switcher); `invalidateCache(...)` busts host caches.
40
39
  - `reportSubroute(subroute, { replace? })`, `onSubrouteRequest(cb): () => void` — usually unnecessary; standard SPA navigation is auto-tracked.
41
40
  - `destroy()`.
42
41
 
43
- Exported types: `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`, and `BRIDGE_VERSION`.
42
+ 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
+
44
+ Exported values/types: `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
44
45
 
45
46
  `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, hostVersion }`.
46
47
 
@@ -49,7 +50,7 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
49
50
  ## Gotchas (wrong → right)
50
51
 
51
52
  - **Don't call data methods before `connect()` resolves.** Always `await bridge.connect()` first.
52
- - **`parentOrigin` is an origin, not a URL.** `https://app.nominal.so` ✅ — not `https://app.nominal.so/app` and not a trailing slash. A mismatch makes `connect()` time out after 10 s.
53
+ - **`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.
53
54
  - **`upload()` takes a `File`**, not a path or `FormData`.
54
55
  - **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
55
56
  - **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
@@ -57,5 +58,5 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
57
58
  ## Don't
58
59
 
59
60
  - Don't poll or busy-wait for context — `await connect()` resolves when it's ready.
60
- - Don't hardcode `parentOrigin`; read it from an env var so dev and prod differ.
61
+ - 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).
61
62
  - 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 — construct `VibeAppBridge` with the host `parentOrigin`, `await bridge.connect()` **once**, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
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
 
@@ -17,11 +17,10 @@ Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
17
17
  ```ts
18
18
  import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
19
19
 
20
- // 1. Construct with the origin of the Nominal app embedding this iframe.
21
- // Must match exactly drive it from an env var.
22
- const bridge = new VibeAppBridge({
23
- parentOrigin: import.meta.env.VITE_PARENT_ORIGIN,
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
- `parentOrigin` via environment variable:
49
+ ### Pinning the host origin (optional)
51
50
 
52
- ```sh
53
- # .env (committed standalone/dev default)
54
- VITE_PARENT_ORIGIN=http://localhost:5173
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
55
 
56
- # .env.local (git-ignoredoverride when testing inside nom-ui)
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
+ ```
64
+
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 | Default | Description |
65
- | ---------------- | -------- | ------- | ---------------------------------------------------------------------------------------- |
66
- | `parentOrigin` | `string` | | Origin of the Nominal app embedding this iframe. **Must match the host origin exactly.** |
67
- | `requestTimeout` | `number` | `10000` | Per-request timeout in milliseconds before a call rejects. |
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
 
@@ -102,7 +120,7 @@ Removes listeners, rejects pending requests, and restores patched history method
102
120
 
103
121
  ### Exports
104
122
 
105
- `VibeAppBridge`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
123
+ `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
106
124
 
107
125
  ## Common recipes
108
126
 
@@ -115,7 +133,7 @@ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
115
133
  function useVibeBridge() {
116
134
  const [ctx, setCtx] = useState<ContextPayload | null>(null)
117
135
  useEffect(() => {
118
- const bridge = new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
136
+ const bridge = new VibeAppBridge()
119
137
  bridge.connect().then(setCtx).catch(console.error)
120
138
  return () => bridge.destroy()
121
139
  }, [])
@@ -138,6 +156,26 @@ await bridge.upload(file, {
138
156
  const events = await bridge.request('GET_AUDIT_EVENTS', {})
139
157
  ```
140
158
 
159
+ **Handle a failure by its code:**
160
+
161
+ A rejected operation throws a `BridgeError` carrying the host's `code` (`'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`, …); branch on it instead of string-matching the message. A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`), which adds the HTTP `status` — branch on `err.status` (e.g. `404`).
162
+
163
+ ```ts
164
+ import { BridgeError, HttpBridgeError } from '@nominalso/vibe-bridge'
165
+
166
+ try {
167
+ await bridge.getAccounts({})
168
+ } catch (err) {
169
+ if (err instanceof HttpBridgeError && err.status === 404) {
170
+ // handle not-found
171
+ }
172
+ if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
173
+ // back off and retry
174
+ }
175
+ throw err
176
+ }
177
+ ```
178
+
141
179
  ## Common mistakes
142
180
 
143
181
  ```ts
@@ -152,13 +190,15 @@ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.su
152
190
  ```
153
191
 
154
192
  ```ts
155
- // ❌ WRONG — parentOrigin must be an ORIGIN, not a URL with a path, and must match exactly.
193
+ // ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
156
194
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
157
195
  // ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
158
196
  new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
159
197
 
160
- // ✅ CORRECT — scheme + host + port only, exact match to the embedding app.
198
+ // ✅ CORRECT — scheme + host + port only, matching the embedding app.
161
199
  new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
200
+ // ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
201
+ new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
162
202
  ```
163
203
 
164
204
  ```ts