@nominalso/vibe-bridge 0.0.1 → 0.2.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 +64 -0
- package/README.md +275 -13
- package/dist/index.cjs +508 -63
- package/dist/index.d.cts +367 -29
- package/dist/index.d.ts +367 -29
- package/dist/index.js +506 -63
- package/docs/connect-lifecycle.md +56 -0
- package/docs/data-fetching.md +71 -0
- package/docs/file-upload.md +55 -0
- package/docs/getting-started.md +53 -0
- package/llms.txt +20 -0
- package/package.json +5 -12
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Connection lifecycle
|
|
2
|
+
|
|
3
|
+
## Always connect first
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const ctx = await bridge.connect()
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Call `connect()` **once** on app init and `await` it before any data method, `upload()`, or subroute call. Concurrent calls return the same promise, so it's safe to call from multiple init paths.
|
|
10
|
+
|
|
11
|
+
## What `connect()` returns
|
|
12
|
+
|
|
13
|
+
A `ContextPayload`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
interface ContextPayload {
|
|
17
|
+
tenant: string
|
|
18
|
+
subsidiaryId: number
|
|
19
|
+
subsidiaries: { id: number; displayName: string }[]
|
|
20
|
+
user: { id: string; displayName: string }
|
|
21
|
+
subroute?: string // initial deep-link path, if any
|
|
22
|
+
lastClosedPeriodSlug?: string // e.g. "mar-2026"
|
|
23
|
+
hostVersion: string // version of @nominalso/vibe-host running in the host
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
The host pushes context to the iframe on load, but the iframe's listener may not be ready when that push arrives. `connect()` solves the race:
|
|
30
|
+
|
|
31
|
+
1. The bridge starts a 500 ms polling loop, sending `GET_CONTEXT` requests to the host.
|
|
32
|
+
2. The host responds as soon as its listener is mounted, **or** proactively pushes `CONTEXT_PUSH` once it has the iframe's window.
|
|
33
|
+
3. Whichever arrives first resolves `connect()`. Polling stops.
|
|
34
|
+
4. The bridge begins auto-tracking SPA navigation (see below).
|
|
35
|
+
5. If nothing arrives within **10 seconds**, `connect()` rejects with a `BridgeError` (code `'TIMEOUT'`, message `Bridge connect timed out`).
|
|
36
|
+
|
|
37
|
+
A timeout almost always means a `parentOrigin` mismatch or the host never mounted.
|
|
38
|
+
|
|
39
|
+
## Initial deep link
|
|
40
|
+
|
|
41
|
+
If `ctx.subroute` is present (e.g. the user opened a bookmarked deep link), the bridge navigates to it via `history.replaceState` right after connecting, so your router lands on the correct view.
|
|
42
|
+
|
|
43
|
+
## Subroute sync (usually automatic)
|
|
44
|
+
|
|
45
|
+
After connecting, the bridge monkey-patches `history.pushState`/`replaceState` to auto-report internal navigation to the host, keeping the Nominal browser URL in sync. For standard SPA routers you need no code.
|
|
46
|
+
|
|
47
|
+
- **Non-standard / hash routers:** call `bridge.reportSubroute('/assets/123', { replace })` manually.
|
|
48
|
+
- **React to host back/forward:** `const off = bridge.onSubrouteRequest((subroute) => router.navigate(subroute))`. Returns an unsubscribe function. Without one, the SDK uses `history.pushState` + a `popstate` event, which most routers pick up.
|
|
49
|
+
|
|
50
|
+
## Teardown
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
bridge.destroy()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Removes listeners, rejects any pending requests with a `BridgeError` (code `'DESTROYED'`, message `Bridge destroyed`), and restores the original history methods. Call it on unmount.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Fetching data & submitting task outputs
|
|
2
|
+
|
|
3
|
+
All data operations run over the bridge. Two equivalent ways to call them:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// Named method (preferred — discoverable via autocomplete):
|
|
7
|
+
const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.subsidiaryId } })
|
|
8
|
+
|
|
9
|
+
// Typed generic escape hatch (same types; works for every operation):
|
|
10
|
+
const accounts = await bridge.request('GET_CHART_OF_ACCOUNTS', {
|
|
11
|
+
path: { subsidiary_id: ctx.subsidiaryId },
|
|
12
|
+
})
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The named methods simply wrap `request('<OPERATION>', payload)`. Both are fully typed: the payload and return type come from the Nominal API for that operation.
|
|
16
|
+
|
|
17
|
+
## Payload shape
|
|
18
|
+
|
|
19
|
+
Payloads follow the generated Nominal API client convention — typically an object with some of:
|
|
20
|
+
|
|
21
|
+
- `path` — path parameters, e.g. `{ subsidiary_id: 1 }`
|
|
22
|
+
- `query` — query-string parameters, e.g. `{ from_date: '2026-01-01' }`
|
|
23
|
+
- `body` — request body (for writes like `postTaskOutput`)
|
|
24
|
+
|
|
25
|
+
Let TypeScript guide you: each method's parameter type names exactly which of `path`/`query`/`body` it needs. Pass `{}` when an operation has only optional parameters (e.g. `bridge.getSubsidiaries({})`).
|
|
26
|
+
|
|
27
|
+
## Writing back: `postTaskOutput`
|
|
28
|
+
|
|
29
|
+
The **only** write path into Nominal is submitting a Close-Management task output:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
await bridge.postTaskOutput({
|
|
33
|
+
path: { task_instance_id: 'task-1' },
|
|
34
|
+
body: {}, // task-specific output shape
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Vibe Apps never write Nominal data directly; everything else is read-only.
|
|
39
|
+
|
|
40
|
+
## Errors & timeouts
|
|
41
|
+
|
|
42
|
+
- A rejected operation throws a `BridgeError` (`extends Error`) carrying a machine-readable `code` (e.g. `'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`) alongside the host's `message`. Branch on `error.code` rather than string-matching the message. (A failure the host sends without a code falls back to a plain `Error`.) A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`), which adds the HTTP `status` — branch on `err.status` (e.g. `404`).
|
|
43
|
+
- Each operation times out on a default tuned to how long it realistically takes: API reads/writes **30 000 ms**, `UPLOAD_FILE` **120 000 ms**, `INVALIDATE_CACHE` **10 000 ms**, `GET_CONTEXT` **5 000 ms**. On timeout the call rejects with a `BridgeError` whose `code` is `'TIMEOUT'` (message `Vibe bridge request timed out: <OPERATION>`). Override all of them with one value via `new VibeAppBridge({ parentOrigin, requestTimeout: 45000 })`.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { BridgeError } from '@nominalso/vibe-bridge'
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const entry = await bridge.getJournalEntry({
|
|
50
|
+
path: { subsidiary_id: ctx.subsidiaryId, journal_entry_id: '42' },
|
|
51
|
+
})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
|
|
54
|
+
// back off and retry
|
|
55
|
+
}
|
|
56
|
+
console.error('Fetch failed:', err instanceof Error ? err.message : err)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Operation catalog
|
|
61
|
+
|
|
62
|
+
The full list of ~46 named methods and their `GET_*`/`POST_*` operation names is in the package [`README.md`](../README.md#operation-catalog). Highlights:
|
|
63
|
+
|
|
64
|
+
- **Chart of accounts:** `getChartOfAccounts`, `getCoaTree`, `getCoaFlatSimple`, `getCoaGrouped`, `getCoaAccount`, `getAccounts`, `getAccount`.
|
|
65
|
+
- **Journal entries:** `getJournalEntries`, `getJournalLines`, `getJournalEntry`.
|
|
66
|
+
- **Periods & activities:** `getPeriods`, `getPeriodInstanceBySlug`, `getActivityInstances`, `getActivityInstanceTasks`.
|
|
67
|
+
- **Tasks:** `getTaskDefinitions`, `getTaskInstances`, `postTaskOutput`, `getTaskInstancesByFilter`.
|
|
68
|
+
- **Tenancy:** `getSubsidiaries`, `getSubsidiary`, `getTenantUsers`.
|
|
69
|
+
- **Dimensions, exchange rates, fiscal calendars, audit trail** — see the catalog.
|
|
70
|
+
|
|
71
|
+
For any operation without a named method, use `bridge.request('<OPERATION>', payload)`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Uploading files
|
|
2
|
+
|
|
3
|
+
Upload a file through the host with `bridge.upload`:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const result = await bridge.upload(file, {
|
|
7
|
+
entityType: 'JOURNAL_ENTRY',
|
|
8
|
+
entityId: '123',
|
|
9
|
+
onProgress: (p) => console.log(`${p.progress}%`),
|
|
10
|
+
})
|
|
11
|
+
// result: { attachmentId: string, name: string }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Arguments
|
|
15
|
+
|
|
16
|
+
- `file: File` — a browser `File` object (e.g. from `<input type="file">` or a drop event). **Not** a path or `FormData`.
|
|
17
|
+
- `options.entityType: string` — the domain entity the file attaches to, e.g. `'JOURNAL_ENTRY'`.
|
|
18
|
+
- `options.entityId?: string | number` — id of that entity, when applicable.
|
|
19
|
+
- `options.onProgress?: (p: { attachmentId: string; progress: number }) => void` — called with incremental progress from `0` to `100`.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
Sandboxed cross-origin iframes cannot pass `File` handles across windows, so the bridge reads the file into an `ArrayBuffer` and sends that to the host. The host performs the actual storage and streams progress events back, then resolves with the stored attachment's id and name. Any maximum file size is enforced by the host/Nominal backend, not by the bridge.
|
|
24
|
+
|
|
25
|
+
## Example — file input with a progress bar
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { BridgeError } from '@nominalso/vibe-bridge'
|
|
29
|
+
|
|
30
|
+
const input = document.querySelector<HTMLInputElement>('#file-input')!
|
|
31
|
+
|
|
32
|
+
input.addEventListener('change', async () => {
|
|
33
|
+
const file = input.files?.[0]
|
|
34
|
+
if (!file) return
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await bridge.upload(file, {
|
|
38
|
+
entityType: 'JOURNAL_ENTRY',
|
|
39
|
+
onProgress: (p) => {
|
|
40
|
+
progressBar.style.width = `${p.progress}%`
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
console.log('Uploaded:', result.attachmentId, result.name)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// A failed upload throws a BridgeError with a code, e.g. 'FILE_TOO_LARGE'
|
|
46
|
+
// (host limit is 50 MB), 'RATE_LIMITED', or 'TIMEOUT' (uploads allow 120 s).
|
|
47
|
+
if (err instanceof BridgeError && err.code === 'FILE_TOO_LARGE') {
|
|
48
|
+
alert('That file is too large (50 MB max).')
|
|
49
|
+
}
|
|
50
|
+
console.error('Upload failed:', err instanceof Error ? err.message : err)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`upload()` resolves only after the host has fully stored the file; progress ticks arrive before it resolves. Import `BridgeError` from `@nominalso/vibe-bridge` to branch on `err.code`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
`@nominalso/vibe-bridge` is the iframe-side SDK for a Nominal Vibe App. Install it in your standalone browser app (e.g. a Lovable project) that will be embedded in Nominal via an `<iframe>`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @nominalso/vibe-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No runtime dependencies. Ships ESM + CJS with self-contained TypeScript types.
|
|
12
|
+
|
|
13
|
+
## Minimal app
|
|
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,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const ctx: ContextPayload = await bridge.connect()
|
|
24
|
+
console.log(`Connected as ${ctx.user.displayName} · tenant ${ctx.tenant}`)
|
|
25
|
+
|
|
26
|
+
const accounts = await bridge.getChartOfAccounts({
|
|
27
|
+
path: { subsidiary_id: ctx.subsidiaryId },
|
|
28
|
+
})
|
|
29
|
+
console.log(accounts)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
main().catch(console.error)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## `parentOrigin`
|
|
36
|
+
|
|
37
|
+
`parentOrigin` is the **origin** (scheme + host + port, no path, no trailing slash) of the Nominal app that embeds your iframe. It must match exactly — the bridge ignores messages from any other origin and only posts to this one.
|
|
38
|
+
|
|
39
|
+
Drive it from an environment variable so the same code works locally and in production:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# .env (committed — standalone/dev default)
|
|
43
|
+
VITE_PARENT_ORIGIN=http://localhost:5173
|
|
44
|
+
|
|
45
|
+
# .env.local (git-ignored — when testing inside nom-ui locally)
|
|
46
|
+
VITE_PARENT_ORIGIN=http://localhost:3000
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Next
|
|
50
|
+
|
|
51
|
+
- [`connect-lifecycle.md`](./connect-lifecycle.md) — how `connect()` works and the connection lifecycle.
|
|
52
|
+
- [`data-fetching.md`](./data-fetching.md) — reading Nominal data and submitting task outputs.
|
|
53
|
+
- [`file-upload.md`](./file-upload.md) — uploading files with progress.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @nominalso/vibe-bridge
|
|
2
|
+
|
|
3
|
+
> Iframe-side TypeScript SDK for building Nominal Vibe Apps — standalone browser apps (often built with Lovable) embedded in the Nominal platform via a cross-origin iframe. Connects the app to its Nominal host over a typed postMessage protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync. Browser-only, zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
The integration is always the same: construct `VibeAppBridge` with the host `parentOrigin`, `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`.
|
|
6
|
+
|
|
7
|
+
## Docs
|
|
8
|
+
|
|
9
|
+
- [README](./README.md): install, quickstart, full API, common mistakes, and the operation catalog.
|
|
10
|
+
- [Getting started](./docs/getting-started.md): install + minimal app + `parentOrigin` setup.
|
|
11
|
+
- [Connection lifecycle](./docs/connect-lifecycle.md): `connect()` semantics, `ContextPayload`, timeout, subroute sync, teardown.
|
|
12
|
+
- [Fetching data](./docs/data-fetching.md): named methods vs the typed `request()` escape hatch, payload shapes, errors/timeouts, operation list.
|
|
13
|
+
- [File upload](./docs/file-upload.md): `upload(file, options)`, progress events, constraints.
|
|
14
|
+
- [AGENTS.md](./AGENTS.md): condensed integration guide for AI coding agents.
|
|
15
|
+
|
|
16
|
+
## Optional
|
|
17
|
+
|
|
18
|
+
- [Types](./dist/index.d.ts): self-contained TypeScript declarations (the full typed surface, Nominal API types inlined).
|
|
19
|
+
- [Host SDK](https://www.npmjs.com/package/@nominalso/vibe-host): `@nominalso/vibe-host`, the Nominal-side counterpart (you do not install this in a Vibe App).
|
|
20
|
+
- [Repository](https://github.com/nominalso/vibe-apps-sdk#readme): protocol, architecture, and contribution docs.
|
package/package.json
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nominalso/vibe-bridge",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking).",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"nominal",
|
|
8
|
-
"vibe-apps",
|
|
9
|
-
"vibe-app",
|
|
10
|
-
"sdk",
|
|
11
|
-
"iframe",
|
|
12
|
-
"postmessage",
|
|
13
|
-
"bridge",
|
|
14
|
-
"embed"
|
|
15
|
-
],
|
|
16
6
|
"homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",
|
|
17
7
|
"bugs": {
|
|
18
8
|
"url": "https://github.com/nominalso/vibe-apps-sdk/issues"
|
|
@@ -34,7 +24,10 @@
|
|
|
34
24
|
"module": "./dist/index.js",
|
|
35
25
|
"types": "./dist/index.d.ts",
|
|
36
26
|
"files": [
|
|
37
|
-
"dist"
|
|
27
|
+
"dist",
|
|
28
|
+
"docs",
|
|
29
|
+
"AGENTS.md",
|
|
30
|
+
"llms.txt"
|
|
38
31
|
],
|
|
39
32
|
"publishConfig": {
|
|
40
33
|
"registry": "https://registry.npmjs.org/",
|