@nominalso/vibe-bridge 0.0.1 → 0.1.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 ADDED
@@ -0,0 +1,61 @@
1
+ # AGENTS.md — @nominalso/vibe-bridge
2
+
3
+ Instructions for AI coding agents (Claude Code, Cursor, Copilot, Lovable, etc.) integrating this package. This is a **consumer usage guide**, not repo build config.
4
+
5
+ ## What this package is
6
+
7
+ `@nominalso/vibe-bridge` is the **iframe-side** SDK for a Nominal Vibe App — a standalone browser app (often built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The app cannot call Nominal APIs directly; it talks to its Nominal host over a typed `postMessage` bridge. This package is that bridge. It is **browser-only** (uses `window`, `postMessage`, `crypto.randomUUID`) and has **zero runtime dependencies**.
8
+
9
+ The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — you do not install it in a Vibe App).
10
+
11
+ ## The one rule
12
+
13
+ **Construct → `await connect()` once → then everything else.** `connect()` establishes the session context (tenant, subsidiary, user). Every data method, `upload()`, and subroute reporting requires it to have resolved first.
14
+
15
+ ```ts
16
+ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
17
+
18
+ const bridge = new VibeAppBridge({
19
+ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN, // exact origin of the embedding Nominal app
20
+ })
21
+
22
+ const ctx: ContextPayload = await bridge.connect() // call ONCE, await before anything else
23
+
24
+ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.subsidiaryId } })
25
+
26
+ await bridge.upload(file, { entityType: 'JOURNAL_ENTRY', entityId: '123' })
27
+
28
+ await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} }) // only write path
29
+
30
+ bridge.destroy() // on unmount
31
+ ```
32
+
33
+ ## Core surface
34
+
35
+ - `new VibeAppBridge({ parentOrigin, requestTimeout? })`
36
+ - `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `parentOrigin` mismatch / host not mounted.
37
+ - ~46 typed data methods, e.g. `getChartOfAccounts`, `getSubsidiaries`, `getPeriods`, `getJournalEntries`, `getAccounts`, `postTaskOutput`. Each takes the operation's `payload` and returns its `data`.
38
+ - `request('<OPERATION>', payload, onProgress?)` — typed escape hatch for any operation; `'<OPERATION>'` autocompletes and narrows the payload/return types.
39
+ - `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
40
+ - `reportSubroute(subroute, { replace? })`, `onSubrouteRequest(cb): () => void` — usually unnecessary; standard SPA navigation is auto-tracked.
41
+ - `destroy()`.
42
+
43
+ Exported types: `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`, and `BRIDGE_VERSION`.
44
+
45
+ `ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, hostVersion }`.
46
+
47
+ 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/`.
48
+
49
+ ## Gotchas (wrong → right)
50
+
51
+ - **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
+ - **`upload()` takes a `File`**, not a path or `FormData`.
54
+ - **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
55
+ - **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
56
+
57
+ ## Don't
58
+
59
+ - 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 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
@@ -1,6 +1,8 @@
1
1
  # @nominalso/vibe-bridge
2
2
 
3
- Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typically built with Lovable) embedded in the Nominal platform via a cross-origin `<iframe>`. The bridge connects your app to its Nominal host over a typed `postMessage` protocol: fetch Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync.
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
+
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.
4
6
 
5
7
  ## Install
6
8
 
@@ -8,30 +10,270 @@ Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typi
8
10
  npm install @nominalso/vibe-bridge
9
11
  ```
10
12
 
11
- ## Usage
13
+ Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
14
+
15
+ ## Quickstart
12
16
 
13
17
  ```ts
14
- import { VibeAppBridge } from '@nominalso/vibe-bridge'
18
+ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
15
19
 
20
+ // 1. Construct with the origin of the Nominal app embedding this iframe.
21
+ // Must match exactly — drive it from an env var.
16
22
  const bridge = new VibeAppBridge({
17
- // Must match the Nominal app origin exactly.
18
23
  parentOrigin: import.meta.env.VITE_PARENT_ORIGIN,
19
24
  })
20
25
 
21
- // Call once on init resolves when the host context arrives.
22
- const ctx = await bridge.connect()
26
+ // 2. Connect ONCE on init and await it before any other call.
27
+ // Resolves with the tenant/user context (or rejects after 10s).
28
+ const ctx: ContextPayload = await bridge.connect()
29
+ // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
23
30
 
24
- const accounts = await bridge.getChartOfAccounts()
25
- await bridge.postTaskOutput(payload)
26
- await bridge.upload(file, { entityType: 'JOURNAL_ENTRY', entityId: '123' })
31
+ // 3. Read Nominal data (any of the ~46 named operations).
32
+ const accounts = await bridge.getChartOfAccounts({
33
+ path: { subsidiary_id: ctx.subsidiaryId },
34
+ })
35
+
36
+ // 4. Upload a file through the host.
37
+ const uploaded = await bridge.upload(file, {
38
+ entityType: 'JOURNAL_ENTRY',
39
+ entityId: '123',
40
+ onProgress: (p) => console.log(`${p.progress}%`),
41
+ })
27
42
 
28
- // On unmount
43
+ // 5. Submit a Close-Management task output — the ONLY write path into Nominal.
44
+ await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })
45
+
46
+ // 6. Tear down on unmount.
29
47
  bridge.destroy()
30
48
  ```
31
49
 
32
- The host side is [`@nominalso/vibe-host`](https://www.npmjs.com/package/@nominalso/vibe-host). See the
33
- [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol, architecture, and
34
- `connect()` semantics.
50
+ `parentOrigin` via environment variable:
51
+
52
+ ```sh
53
+ # .env (committed — standalone/dev default)
54
+ VITE_PARENT_ORIGIN=http://localhost:5173
55
+
56
+ # .env.local (git-ignored — override when testing inside nom-ui)
57
+ VITE_PARENT_ORIGIN=http://localhost:3000
58
+ ```
59
+
60
+ ## API
61
+
62
+ ### `new VibeAppBridge(options)`
63
+
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. |
68
+
69
+ ### `connect(): Promise<ContextPayload>`
70
+
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.
72
+
73
+ ### Data operations
74
+
75
+ Every operation has a typed named method (e.g. `bridge.getChartOfAccounts(payload)`); payloads and return types come straight from the Nominal API. For any operation, you can also call the generic escape hatch:
76
+
77
+ ```ts
78
+ // `type` autocompletes to every operation name and narrows payload + return.
79
+ const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })
80
+ ```
81
+
82
+ See the [operation catalog](#operation-catalog) for the full list.
83
+
84
+ ### `upload(file, options): Promise<UploadResponse>`
85
+
86
+ Uploads a `File` through the host (converts to `ArrayBuffer` first — sandboxed iframes cannot pass `File` handles across windows). Resolves with `{ attachmentId, name }` once Nominal has stored it.
87
+
88
+ | Option | Type | Description |
89
+ | ------------ | ----------------------------- | ----------------------------------------------------------- |
90
+ | `entityType` | `string` | Domain entity the file attaches to, e.g. `'JOURNAL_ENTRY'`. |
91
+ | `entityId` | `string \| number` (optional) | Id of the entity, when applicable. |
92
+ | `onProgress` | `(p: UploadProgress) => void` | Called with incremental progress (`0`–`100`). |
93
+
94
+ ### Subroute deep-linking
95
+
96
+ - `reportSubroute(subroute, { replace? })` — manually report the current subroute. **Usually unnecessary** — standard SPA navigation (`history.pushState`/`replaceState`) is auto-detected. Use it for hash-based or non-standard routers.
97
+ - `onSubrouteRequest(cb): () => void` — register a callback for host-initiated navigation (browser back/forward). Returns an unsubscribe function. If you don't register one, the SDK falls back to `history.pushState` + a `popstate` event, which works for most SPA routers.
98
+
99
+ ### `destroy()`
100
+
101
+ Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount.
102
+
103
+ ### Exports
104
+
105
+ `VibeAppBridge`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
106
+
107
+ ## Common recipes
108
+
109
+ **React — connect on mount, tear down on unmount:**
110
+
111
+ ```tsx
112
+ import { useEffect, useState } from 'react'
113
+ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
114
+
115
+ function useVibeBridge() {
116
+ const [ctx, setCtx] = useState<ContextPayload | null>(null)
117
+ useEffect(() => {
118
+ const bridge = new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
119
+ bridge.connect().then(setCtx).catch(console.error)
120
+ return () => bridge.destroy()
121
+ }, [])
122
+ return ctx
123
+ }
124
+ ```
125
+
126
+ **Upload with a progress bar:**
127
+
128
+ ```ts
129
+ await bridge.upload(file, {
130
+ entityType: 'JOURNAL_ENTRY',
131
+ onProgress: (p) => (progressBar.style.width = `${p.progress}%`),
132
+ })
133
+ ```
134
+
135
+ **An operation without a named method — use the typed `request()`:**
136
+
137
+ ```ts
138
+ const events = await bridge.request('GET_AUDIT_EVENTS', {})
139
+ ```
140
+
141
+ ## Common mistakes
142
+
143
+ ```ts
144
+ // ❌ WRONG — calling a data method before connect() resolves.
145
+ const bridge = new VibeAppBridge({ parentOrigin })
146
+ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: 1 } })
147
+
148
+ // ✅ CORRECT — await connect() first; it establishes the session context.
149
+ const bridge = new VibeAppBridge({ parentOrigin })
150
+ const ctx = await bridge.connect()
151
+ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.subsidiaryId } })
152
+ ```
153
+
154
+ ```ts
155
+ // ❌ WRONG — parentOrigin must be an ORIGIN, not a URL with a path, and must match exactly.
156
+ new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
157
+ // ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
158
+ new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
159
+
160
+ // ✅ CORRECT — scheme + host + port only, exact match to the embedding app.
161
+ new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
162
+ ```
163
+
164
+ ```ts
165
+ // ❌ WRONG — upload takes a File object, not a path or FormData.
166
+ await bridge.upload('/tmp/report.pdf', { entityType: 'JOURNAL_ENTRY' })
167
+
168
+ // ✅ CORRECT — pass the File (e.g. from an <input type="file">).
169
+ await bridge.upload(fileInput.files![0], { entityType: 'JOURNAL_ENTRY' })
170
+ ```
171
+
172
+ ## Operation catalog
173
+
174
+ All operations are reachable as `bridge.<method>(payload)` or `bridge.request('<OPERATION>', payload)`.
175
+
176
+ **Accounting — chart of accounts**
177
+
178
+ | Method | Operation |
179
+ | ---------------------------------- | ------------------------------------- |
180
+ | `getChartOfAccounts` | `GET_CHART_OF_ACCOUNTS` |
181
+ | `getCoaTree` | `GET_COA_TREE` |
182
+ | `getCoaFlatSimple` | `GET_COA_FLAT_SIMPLE` |
183
+ | `getCoaGrouped` | `GET_COA_GROUPED` |
184
+ | `getCoaAccount` | `GET_COA_ACCOUNT` |
185
+ | `getSubsidiaryAvailableCurrencies` | `GET_SUBSIDIARY_AVAILABLE_CURRENCIES` |
186
+ | `getAccounts` | `GET_ACCOUNTS` |
187
+ | `getAccount` | `GET_ACCOUNT` |
188
+
189
+ **Accounting — exchange rates**
190
+
191
+ | Method | Operation |
192
+ | -------------------------- | ----------------------------- |
193
+ | `getConversionRates` | `GET_CONVERSION_RATES` |
194
+ | `getEffectiveExchangeRate` | `GET_EFFECTIVE_EXCHANGE_RATE` |
195
+ | `getExchangeRateByDate` | `GET_EXCHANGE_RATE_BY_DATE` |
196
+
197
+ **Accounting — dimensions**
198
+
199
+ | Method | Operation |
200
+ | -------------------------------- | ----------------------------------- |
201
+ | `getDimensions` | `GET_DIMENSIONS` |
202
+ | `getDimensionValues` | `GET_DIMENSION_VALUES` |
203
+ | `getDimensionValuesHierarchical` | `GET_DIMENSION_VALUES_HIERARCHICAL` |
204
+ | `getDimensionAccountAssignments` | `GET_DIMENSION_ACCOUNT_ASSIGNMENTS` |
205
+
206
+ **Accounting — journal entries**
207
+
208
+ | Method | Operation |
209
+ | ------------------- | --------------------- |
210
+ | `getJournalEntries` | `GET_JOURNAL_ENTRIES` |
211
+ | `getJournalLines` | `GET_JOURNAL_LINES` |
212
+ | `getJournalEntry` | `GET_JOURNAL_ENTRY` |
213
+
214
+ **Activity — period instances**
215
+
216
+ | Method | Operation |
217
+ | ---------------------------- | ------------------------------- |
218
+ | `getPeriods` | `GET_PERIODS` |
219
+ | `getPeriodInstance` | `GET_PERIOD_INSTANCE` |
220
+ | `getPeriodInstanceBySlug` | `GET_PERIOD_INSTANCE_BY_SLUG` |
221
+ | `getPeriodProgressBreakdown` | `GET_PERIOD_PROGRESS_BREAKDOWN` |
222
+
223
+ **Activity — activity definitions & instances**
224
+
225
+ | Method | Operation |
226
+ | ----------------------------- | --------------------------------- |
227
+ | `getActivityDefinitions` | `GET_ACTIVITY_DEFINITIONS` |
228
+ | `getActivityDefinition` | `GET_ACTIVITY_DEFINITION` |
229
+ | `getActivityInstances` | `GET_ACTIVITY_INSTANCES` |
230
+ | `getActivityInstance` | `GET_ACTIVITY_INSTANCE` |
231
+ | `getActivityInstanceByPeriod` | `GET_ACTIVITY_INSTANCE_BY_PERIOD` |
232
+ | `getActivityInstanceTasks` | `GET_ACTIVITY_INSTANCE_TASKS` |
233
+ | `getActivityPeriodTasks` | `GET_ACTIVITY_PERIOD_TASKS` |
234
+
235
+ **Activity — task definitions & instances**
236
+
237
+ | Method | Operation |
238
+ | ---------------------------- | -------------------------------- |
239
+ | `getTaskDefinitions` | `GET_TASK_DEFINITIONS` |
240
+ | `getTaskDefinition` | `GET_TASK_DEFINITION` |
241
+ | `createTaskDefinition` | `CREATE_TASK_DEFINITION` |
242
+ | `updateTaskDefinition` | `UPDATE_TASK_DEFINITION` |
243
+ | `getTaskDefinitionsByFilter` | `GET_TASK_DEFINITIONS_BY_FILTER` |
244
+ | `getTaskInstances` | `GET_TASK_INSTANCES` |
245
+ | `getTaskInstance` | `GET_TASK_INSTANCE` |
246
+ | `getTaskInstancesByFilter` | `GET_TASK_INSTANCES_BY_FILTER` |
247
+ | `postTaskOutput` | `POST_TASK_OUTPUT` |
248
+
249
+ **Audit trail**
250
+
251
+ | Method | Operation |
252
+ | ---------------------- | ------------------------- |
253
+ | `getAuditEvents` | `GET_AUDIT_EVENTS` |
254
+ | `getEntityAuditEvents` | `GET_ENTITY_AUDIT_EVENTS` |
255
+
256
+ **Period manager — fiscal calendars**
257
+
258
+ | Method | Operation |
259
+ | -------------------- | ---------------------- |
260
+ | `getFiscalCalendars` | `GET_FISCAL_CALENDARS` |
261
+ | `getFiscalCalendar` | `GET_FISCAL_CALENDAR` |
262
+
263
+ **Tenancy**
264
+
265
+ | Method | Operation |
266
+ | ------------------------------- | ---------------------------------- |
267
+ | `getSubsidiaries` | `GET_SUBSIDIARIES` |
268
+ | `getSubsidiary` | `GET_SUBSIDIARY` |
269
+ | `getSubsidiaryParentCurrencies` | `GET_SUBSIDIARY_PARENT_CURRENCIES` |
270
+ | `getTenantUsers` | `GET_TENANT_USERS` |
271
+
272
+ > `connect()` (context) and `upload()` (file upload) have dedicated methods and are not in this table.
273
+
274
+ ## How it fits together
275
+
276
+ The host side is [`@nominalso/vibe-host`](https://www.npmjs.com/package/@nominalso/vibe-host), used by the Nominal app (nom-ui). See the [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol, architecture, and `connect()` semantics. Bundled agent docs ship in this package under `docs/` and `AGENTS.md`.
35
277
 
36
278
  ## License
37
279