npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nominalso/vibe-bridge

v0.10.1

Published

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). Safe to import from SSR, React Server Components, and edge/Worker runt

Readme

@nominalso/vibe-bridge

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: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync.

For AI agents / Lovable: 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. The bridge runs in the browser, but the package is safe to import from anywhere (SSR / React Server Components / edge) — see below.

Install

npm install @nominalso/vibe-bridge

Ships ESM + CJS with self-contained TypeScript types. posthog-js is an optional dependency (installed automatically, but the SDK degrades gracefully if it is absent) used only when the host enables in-iframe session recording — see Session recording & custom events.

Safe to import anywhere (SSR, RSC, edge)

import { VibeAppBridge, BridgeError, HttpBridgeError, type ContextPayload } from '@nominalso/vibe-bridge' is safe from any file — an SSR entry, a React Server Component, a Cloudflare Worker (workerd + nodejs_compat), a shared util, or a client component. The package draws a clean server/client boundary:

  • Importing has zero side effects and new VibeAppBridge() is pure — the constructor stores options and touches no window/document/history/crypto/posthog-js. Nothing runs until you call a method.
  • The browser is only needed when a method actually runs. Calling one where there is no window throws a BridgeError with code 'SERVER_ENVIRONMENT' (a clear, coded error — never window is not defined).
  • RSC / edge / Worker runtimes resolve — via the react-server, worker, workerd, edge-light, and deno export conditions — to a server-safe stub with the identical API, so a Server Component can reference the bridge without executing anything. The client build carries a 'use client' directive so RSC bundlers place it on the client side of the boundary.
  • Plain Node (and jsdom/happy-dom test runners like Vitest and Jest) gets the real, self-guarding build — so component tests that import the package by name work against a real window and can call bridge methods normally; on a real Node server (no window) a call throws SERVER_ENVIRONMENT at runtime.
  • posthog-js is optional and loaded lazily only when recording is enabled, so it never lands in a server bundle.

You therefore do not need createClientOnlyFn, dynamic import() wrappers, or typeof window guards around construction. Construct at the top of a client component and connect() in an effect:

// A client component in any SSR framework (Next.js App Router, TanStack Start,
// Remix, SvelteKit). Top-level import is fine; no client-only wrapper needed.
'use client' // Next.js App Router: mark your own component; the SDK is already marked.
import { useEffect, useState } from 'react'
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'

export function useVibeBridge() {
  const [ctx, setCtx] = useState<ContextPayload | null>(null)
  useEffect(() => {
    const bridge = new VibeAppBridge() // pure — safe even if this line runs during SSR
    bridge.connect().then(setCtx).catch(console.error) // only runs in the browser
    return () => bridge.destroy() // idempotent + no-op on the server
  }, [])
  return ctx
}

Migrating from an earlier version? If you wrapped construction in createClientOnlyFn / a dynamic await import('@nominalso/vibe-bridge'), guarded it with typeof window !== 'undefined', or reimplemented instanceof checks by duck-typing err.code/err.status to avoid importing the error classes — you can delete all of it. Import VibeAppBridge, BridgeError, and HttpBridgeError directly, and use BridgeError.isBridgeError(err) / HttpBridgeError.isHttpBridgeError(err) (below).

Quickstart

import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'

// 1. Construct. No config needed — the bridge auto-detects the Nominal app
//    that embeds it. (Pin the host origin explicitly only if you want to;
//    see "Pinning the host origin" below.)
const bridge = new VibeAppBridge()

// 2. Connect ONCE on init and await it before any other call.
//    Resolves with the tenant/user context (or rejects after 10s).
const ctx: ContextPayload = await bridge.connect()
// ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug

// 3. Read Nominal data (any of the ~46 named operations).
const accounts = await bridge.getChartOfAccounts({
  path: { subsidiary_id: ctx.subsidiaryId },
})

// 4. Upload a file through the host.
const uploaded = await bridge.upload(file, {
  entityType: 'JOURNAL_ENTRY',
  entityId: '123',
  onProgress: (p) => console.log(`${p.progress}%`),
})

// 5. Submit a Close-Management task output — the ONLY write path into Nominal.
await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })

// 6. Tear down on unmount.
bridge.destroy()

Pinning the host origin (optional)

By default you pass nothing — the bridge talks only to the Nominal page that actually embeds it. That's safe because Nominal serves Vibe Apps behind a frame-ancestors CSP, so only nom-ui can frame your app. Most apps need nothing more.

Pass parentOrigin to pin explicitly — defense in depth, or when running outside Nominal's edge. Drive it from an env var, or use a list / glob patterns to accept dynamic preview deployments:

new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
# .env.local
VITE_PARENT_ORIGIN=http://localhost:3000

A pattern's * matches exactly one DNS label or port (anchored, scheme literal): https://*.vercel.app matches https://pr-7.vercel.app but not https://a.b.vercel.app or https://pr-7.vercel.app.evil.com. Patterns are honoured only when this app's own page is served from a recognised preview host (*.vercel.app, *.lovable.app, localhost, …) — on a production custom domain pattern entries are ignored and only exact origins match. Always include at least one exact origin alongside any patterns.

API

new VibeAppBridge(options)

| Option | Type | Default | Description | | ---------------- | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 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. | | 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. |

connect(): Promise<ContextPayload>

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.

Data operations

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:

// `type` autocompletes to every operation name and narrows payload + return.
const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })

See the operation catalog for the full list.

upload(file, options): Promise<UploadResponse>

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.

| Option | Type | Description | | ------------ | ----------------------------- | ----------------------------------------------------------- | | entityType | string | Domain entity the file attaches to, e.g. 'JOURNAL_ENTRY'. | | entityId | string \| number (optional) | Id of the entity, when applicable. | | onProgress | (p: UploadProgress) => void | Called with incremental progress (0100). |

Subroute deep-linking

  • 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.
  • 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.

destroy()

Removes listeners, rejects pending requests, and restores patched history methods. Call on unmount. Idempotent and server-safe — calling it when connect()/attach() never ran (or where there is no window) is a harmless no-op, so it is safe as a React effect cleanup.

attach() (advanced, optional)

Installs the browser wiring (message listener + origin resolution) without starting the handshake. You almost never need thisconnect() calls it for you, and any transport method lazily attaches on first use. Use it only if you want the bridge receiving host pushes before you call connect(). Throws BridgeError code 'SERVER_ENVIRONMENT' if there is no window.

Session recording & custom events

The cross-origin iframe is invisible to the host's own session replay (same-origin policy), so the bridge drives its own posthog-js — but only when the host opts in. posthog-js is an optional dependency, loaded lazily (await import('posthog-js')) the first time the host enables recording, so it is never bundled into a server build and adds nothing to apps that don't record. If it isn't installed, recording and track() degrade to a no-op (the bridge warns once, never throws). When the host pushes a posthog block with enabled: true, the bridge:

  • 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);
  • calls identify() with the host-resolved distinctId, so the recording and your events attribute to the same PostHog person as nom-ui;
  • 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.

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 nothingenabled is the kill-switch even though posthog-js is bundled. You configure nothing in the app.

track(event, properties?)

Capture a custom product event. No-op until connect() has resolved with PostHog enabled by the host — so it's always safe to call.

bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })

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).

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.

Exports

VibeAppBridge, BridgeError, HttpBridgeError, BRIDGE_VERSION, and the types VibeAppBridgeOptions, UploadOptions, ContextPayload, AuthPayload, BridgeSubsidiary, PostHogContext, UploadResponse, UploadProgress, RequestRegistry, NavigateAction, SetSideNavPayload, InvalidateCachePayload, InvalidateResult. The same public surface (types identical) is available at the @nominalso/vibe-bridge/server subpath, which always resolves to the server-safe stub.

Common recipes

React — connect on mount, tear down on unmount:

import { useEffect, useState } from 'react'
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'

function useVibeBridge() {
  const [ctx, setCtx] = useState<ContextPayload | null>(null)
  useEffect(() => {
    const bridge = new VibeAppBridge()
    bridge.connect().then(setCtx).catch(console.error)
    return () => bridge.destroy()
  }, [])
  return ctx
}

Upload with a progress bar:

await bridge.upload(file, {
  entityType: 'JOURNAL_ENTRY',
  onProgress: (p) => (progressBar.style.width = `${p.progress}%`),
})

An operation without a named method — use the typed request():

const events = await bridge.request('GET_AUDIT_EVENTS', {})

Handle a failure by its code:

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) — and the API's parsed error response as err.body.

import { BridgeError, HttpBridgeError } from '@nominalso/vibe-bridge'

try {
  await bridge.getAccounts({})
} catch (err) {
  if (HttpBridgeError.isHttpBridgeError(err) && err.status === 404) {
    // handle not-found
  }
  if (BridgeError.isBridgeError(err) && err.code === 'RATE_LIMITED') {
    // back off and retry
  }
  throw err
}

Show a rejected write on the right field (err.body):

err.body is the API's own error response, forwarded for every failed status so your app has the whole failure to act on. It is typed unknown because the shape belongs to the API rather than the protocol, and it is undefined against a host that predates the field — so narrow and guard before reading. Nominal sends { status, message, details }, where details maps each rejected field to its message:

try {
  await bridge.postTaskOutput({ body: draft })
} catch (err) {
  if (HttpBridgeError.isHttpBridgeError(err) && err.status === 422) {
    const { details } = (err.body ?? {}) as { details?: Record<string, unknown> }
    setFieldErrors(details ?? {}) // → { account_id: 'Field required' }
    return
  }
  throw err
}

Don't read err.message for this — it is a generic API request failed with status 422. A 5xx body can carry server-internal text, so log it rather than rendering it verbatim to a user.

instanceof BridgeError also works within a single bundle. Prefer the static BridgeError.isBridgeError(err) / HttpBridgeError.isHttpBridgeError(err) guards when an error might cross independently-built bundles (e.g. a server bundle catching an error thrown by the client bundle) — they match a process-global brand rather than class identity, so they survive bundler realm boundaries.

Common mistakes

// ❌ WRONG — calling a data method before connect() resolves.
const bridge = new VibeAppBridge({ parentOrigin })
const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: 1 } })

// ✅ CORRECT — await connect() first; it establishes the session context.
const bridge = new VibeAppBridge({ parentOrigin })
const ctx = await bridge.connect()
const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.subsidiaryId } })
// ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
// ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })

// ✅ CORRECT — scheme + host + port only, matching the embedding app.
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
// ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
// ❌ WRONG — upload takes a File object, not a path or FormData.
await bridge.upload('/tmp/report.pdf', { entityType: 'JOURNAL_ENTRY' })

// ✅ CORRECT — pass the File (e.g. from an <input type="file">).
await bridge.upload(fileInput.files![0], { entityType: 'JOURNAL_ENTRY' })

Operation catalog

All operations are reachable as bridge.<method>(payload) or bridge.request('<OPERATION>', payload).

Accounting — chart of accounts

| Method | Operation | | ---------------------------------- | ------------------------------------- | | getChartOfAccounts | GET_CHART_OF_ACCOUNTS | | getCoaTree | GET_COA_TREE | | getCoaFlatSimple | GET_COA_FLAT_SIMPLE | | getCoaGrouped | GET_COA_GROUPED | | getCoaAccount | GET_COA_ACCOUNT | | getSubsidiaryAvailableCurrencies | GET_SUBSIDIARY_AVAILABLE_CURRENCIES | | getAccounts | GET_ACCOUNTS | | getAccount | GET_ACCOUNT |

Accounting — exchange rates

| Method | Operation | | -------------------------- | ----------------------------- | | getConversionRates | GET_CONVERSION_RATES | | getEffectiveExchangeRate | GET_EFFECTIVE_EXCHANGE_RATE | | getExchangeRateByDate | GET_EXCHANGE_RATE_BY_DATE |

Accounting — dimensions

| Method | Operation | | -------------------------------- | ----------------------------------- | | getDimensions | GET_DIMENSIONS | | getDimensionValues | GET_DIMENSION_VALUES | | getDimensionValuesHierarchical | GET_DIMENSION_VALUES_HIERARCHICAL | | getDimensionAccountAssignments | GET_DIMENSION_ACCOUNT_ASSIGNMENTS |

Accounting — journal entries

| Method | Operation | | ------------------- | --------------------- | | getJournalEntries | GET_JOURNAL_ENTRIES | | getJournalLines | GET_JOURNAL_LINES | | getJournalEntry | GET_JOURNAL_ENTRY |

Activity — period instances

| Method | Operation | | ---------------------------- | ------------------------------- | | getPeriods | GET_PERIODS | | getPeriodInstance | GET_PERIOD_INSTANCE | | getPeriodInstanceBySlug | GET_PERIOD_INSTANCE_BY_SLUG | | getPeriodProgressBreakdown | GET_PERIOD_PROGRESS_BREAKDOWN |

Activity — activity definitions & instances

| Method | Operation | | ----------------------------- | --------------------------------- | | getActivityDefinitions | GET_ACTIVITY_DEFINITIONS | | getActivityDefinition | GET_ACTIVITY_DEFINITION | | getActivityInstances | GET_ACTIVITY_INSTANCES | | getActivityInstance | GET_ACTIVITY_INSTANCE | | getActivityInstanceByPeriod | GET_ACTIVITY_INSTANCE_BY_PERIOD | | getActivityInstanceTasks | GET_ACTIVITY_INSTANCE_TASKS | | getActivityPeriodTasks | GET_ACTIVITY_PERIOD_TASKS |

Activity — task definitions & instances

| Method | Operation | | ---------------------------- | -------------------------------- | | getTaskDefinitions | GET_TASK_DEFINITIONS | | getTaskDefinition | GET_TASK_DEFINITION | | createTaskDefinition | CREATE_TASK_DEFINITION | | updateTaskDefinition | UPDATE_TASK_DEFINITION | | getTaskDefinitionsByFilter | GET_TASK_DEFINITIONS_BY_FILTER | | getTaskInstances | GET_TASK_INSTANCES | | getTaskInstance | GET_TASK_INSTANCE | | getTaskInstancesByFilter | GET_TASK_INSTANCES_BY_FILTER | | postTaskOutput | POST_TASK_OUTPUT |

Accounting — account reconciliation

| Method | Operation | | -------------------------------- | ----------------------------------- | | uploadReconciliationSource | UPLOAD_RECONCILIATION_SOURCE | | previewReconciliationSource | PREVIEW_RECONCILIATION_SOURCE | | refreshReconciliationSource | REFRESH_RECONCILIATION_SOURCE | | getLatestReconciliationCapture | GET_LATEST_RECONCILIATION_CAPTURE | | listReconciliationSources | LIST_RECONCILIATION_SOURCES | | getReconciliationTasks | GET_RECONCILIATION_TASKS |

Audit trail

| Method | Operation | | ---------------------- | ------------------------- | | getAuditEvents | GET_AUDIT_EVENTS | | getEntityAuditEvents | GET_ENTITY_AUDIT_EVENTS |

Period manager — fiscal calendars

| Method | Operation | | -------------------- | ---------------------- | | getFiscalCalendars | GET_FISCAL_CALENDARS | | getFiscalCalendar | GET_FISCAL_CALENDAR |

Tenancy

| Method | Operation | | ------------------------------- | ---------------------------------- | | getSubsidiaries | GET_SUBSIDIARIES | | getSubsidiary | GET_SUBSIDIARY | | getSubsidiaryParentCurrencies | GET_SUBSIDIARY_PARENT_CURRENCIES | | getTenantUsers | GET_TENANT_USERS |

File helpers uploadReconciliationSourceFile / previewReconciliationSourceFile wrap the upload/preview ops for browser File inputs. connect() (context) and upload() (generic attachments) have dedicated methods and are not in this table.

How it fits together

The host side is @nominalso/vibe-host, used by the Nominal app (nom-ui). See the repository for the full protocol, architecture, and connect() semantics. Bundled agent docs ship in this package under docs/ and AGENTS.md.

License

UNLICENSED — proprietary. © Nominal. All rights reserved.