@nominalso/vibe-bridge 0.4.0 → 0.6.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/dist/index.js CHANGED
@@ -1,368 +1,15 @@
1
- // package.json
2
- var version = "0.4.0";
3
-
4
- // ../protocol-types/dist/index.js
5
- function normalizeSubroute(subroute) {
6
- const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
7
- const path = normalized.split(/[?#]/)[0];
8
- let decoded = path;
9
- let prev;
10
- do {
11
- prev = decoded;
12
- decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
13
- } while (decoded !== prev);
14
- if (decoded.split("/").some((segment) => segment === "..")) return null;
15
- return normalized;
16
- }
17
- var PROTOCOL_ID = "nominal-vibe-bridge";
18
- var PROTOCOL_VERSION = 1;
19
- var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
20
- var CORRELATED_KINDS = ["request", "response", "progress"];
21
- var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
22
- var CORRELATED_KINDS_SET = new Set(CORRELATED_KINDS);
23
- function isBridgeMessage(data) {
24
- if (typeof data !== "object" || data === null) return false;
25
- const d = data;
26
- if (d.__protocol !== PROTOCOL_ID) return false;
27
- if (d.__version !== PROTOCOL_VERSION) return false;
28
- if (typeof d.kind !== "string" || !BRIDGE_KINDS_SET.has(d.kind)) return false;
29
- if (typeof d.type !== "string") return false;
30
- if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
31
- return true;
32
- }
33
- var BridgeError = class extends Error {
34
- constructor(code, message) {
35
- super(message);
36
- this.code = code;
37
- this.name = "BridgeError";
38
- }
39
- };
40
- var HttpBridgeError = class extends BridgeError {
41
- constructor(status, message) {
42
- super("REQUEST_FAILED", message);
43
- this.status = status;
44
- this.name = "HttpBridgeError";
45
- }
46
- };
47
- function isOriginPattern(entry) {
48
- return entry.includes("*");
49
- }
50
- var patternCache = /* @__PURE__ */ new Map();
51
- function patternToRegExp(pattern) {
52
- const cached = patternCache.get(pattern);
53
- if (cached) return cached;
54
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
- const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
56
- const compiled = new RegExp(`^${body}$`);
57
- patternCache.set(pattern, compiled);
58
- return compiled;
59
- }
60
- function matchesOrigin(origin, entry) {
61
- if (!isOriginPattern(entry)) return origin === entry;
62
- return patternToRegExp(entry).test(origin);
63
- }
64
- function isOriginAllowed(origin, allowlist, { allowPatterns }) {
65
- for (const entry of allowlist) {
66
- if (isOriginPattern(entry)) {
67
- if (allowPatterns && matchesOrigin(origin, entry)) return true;
68
- } else if (origin === entry) {
69
- return true;
70
- }
71
- }
72
- return false;
73
- }
74
-
75
- // src/VibeAppBridge.ts
76
- import posthogDefault from "posthog-js";
77
-
78
- // src/data-methods.ts
79
- var BridgeDataMethods = class {
80
- // ---------------------------------------------------------------------------
81
- // Operations — Accounting: chart of accounts
82
- //
83
- // Each method is a typed wrapper over `request('<OP>', payload)`. The payload
84
- // and return types come straight from the Nominal API (`RequestRegistry`).
85
- // For any operation without a named method, call `request('<OP>', payload)`.
86
- // ---------------------------------------------------------------------------
87
- /**
88
- * Fetch the flat chart of accounts for a subsidiary.
89
- *
90
- * @example
91
- * ```ts
92
- * const ctx = await bridge.connect()
93
- * const accounts = await bridge.getChartOfAccounts({
94
- * path: { subsidiary_id: ctx.subsidiaryId },
95
- * })
96
- * ```
97
- */
98
- getChartOfAccounts(payload) {
99
- return this.request("GET_CHART_OF_ACCOUNTS", payload);
100
- }
101
- /** Fetch the chart of accounts as a hierarchical tree for a subsidiary. */
102
- getCoaTree(payload) {
103
- return this.request("GET_COA_TREE", payload);
104
- }
105
- /** Fetch a simplified flat chart of accounts for a subsidiary. */
106
- getCoaFlatSimple(payload) {
107
- return this.request("GET_COA_FLAT_SIMPLE", payload);
108
- }
109
- /** Fetch the flat chart of accounts grouped by account group. */
110
- getCoaGrouped(payload) {
111
- return this.request("GET_COA_GROUPED", payload);
112
- }
113
- /** Fetch a single chart-of-accounts account by id within a subsidiary. */
114
- getCoaAccount(payload) {
115
- return this.request("GET_COA_ACCOUNT", payload);
116
- }
117
- /** List the currencies available to a subsidiary's chart of accounts. */
118
- getSubsidiaryAvailableCurrencies(payload) {
119
- return this.request("GET_SUBSIDIARY_AVAILABLE_CURRENCIES", payload);
120
- }
121
- /** Fetch accounts at the tenant level. */
122
- getAccounts(payload) {
123
- return this.request("GET_ACCOUNTS", payload);
124
- }
125
- /** Fetch a single account by id. */
126
- getAccount(payload) {
127
- return this.request("GET_ACCOUNT", payload);
128
- }
129
- // ---------------------------------------------------------------------------
130
- // Operations — Accounting: exchange rates
131
- // ---------------------------------------------------------------------------
132
- /** Fetch currency conversion rates for a currency. */
133
- getConversionRates(payload) {
134
- return this.request("GET_CONVERSION_RATES", payload);
135
- }
136
- /** Fetch the effective consolidation exchange rate. */
137
- getEffectiveExchangeRate(payload) {
138
- return this.request("GET_EFFECTIVE_EXCHANGE_RATE", payload);
139
- }
140
- /** Fetch the exchange-rate period covering a given input date. */
141
- getExchangeRateByDate(payload) {
142
- return this.request("GET_EXCHANGE_RATE_BY_DATE", payload);
143
- }
144
- // ---------------------------------------------------------------------------
145
- // Operations — Accounting: dimensions
146
- // ---------------------------------------------------------------------------
147
- /** List accounting dimensions. */
148
- getDimensions(payload) {
149
- return this.request("GET_DIMENSIONS", payload);
150
- }
151
- /** List values for accounting dimensions. */
152
- getDimensionValues(payload) {
153
- return this.request("GET_DIMENSION_VALUES", payload);
154
- }
155
- /** List dimension values in hierarchical form. */
156
- getDimensionValuesHierarchical(payload) {
157
- return this.request("GET_DIMENSION_VALUES_HIERARCHICAL", payload);
158
- }
159
- /** List account assignments for a dimension/account pair. */
160
- getDimensionAccountAssignments(payload) {
161
- return this.request("GET_DIMENSION_ACCOUNT_ASSIGNMENTS", payload);
162
- }
163
- // ---------------------------------------------------------------------------
164
- // Operations — Accounting: journal entries
165
- // ---------------------------------------------------------------------------
166
- /**
167
- * Fetch journal entries for a subsidiary, optionally filtered by date range.
168
- *
169
- * @example
170
- * ```ts
171
- * const entries = await bridge.getJournalEntries({
172
- * path: { subsidiary_id: ctx.subsidiaryId },
173
- * query: { from_date: '2026-01-01', to_date: '2026-03-31' },
174
- * })
175
- * ```
176
- */
177
- getJournalEntries(payload) {
178
- return this.request("GET_JOURNAL_ENTRIES", payload);
179
- }
180
- /** Fetch journal entry lines for a subsidiary. */
181
- getJournalLines(payload) {
182
- return this.request("GET_JOURNAL_LINES", payload);
183
- }
184
- /** Fetch a single journal entry by id within a subsidiary. */
185
- getJournalEntry(payload) {
186
- return this.request("GET_JOURNAL_ENTRY", payload);
187
- }
188
- // ---------------------------------------------------------------------------
189
- // Operations — Activity: period instances
190
- // ---------------------------------------------------------------------------
191
- /** List accounting period instances. */
192
- getPeriods(payload) {
193
- return this.request("GET_PERIODS", payload);
194
- }
195
- /** Fetch a single period instance by id. */
196
- getPeriodInstance(payload) {
197
- return this.request("GET_PERIOD_INSTANCE", payload);
198
- }
199
- /** Fetch a period instance by its display slug (e.g. `"mar-2026"`). */
200
- getPeriodInstanceBySlug(payload) {
201
- return this.request("GET_PERIOD_INSTANCE_BY_SLUG", payload);
202
- }
203
- /** Fetch the close-progress breakdown for a period (by slug). */
204
- getPeriodProgressBreakdown(payload) {
205
- return this.request("GET_PERIOD_PROGRESS_BREAKDOWN", payload);
206
- }
207
- // ---------------------------------------------------------------------------
208
- // Operations — Activity: activity definitions
209
- // ---------------------------------------------------------------------------
210
- /** List activity definitions. */
211
- getActivityDefinitions(payload) {
212
- return this.request("GET_ACTIVITY_DEFINITIONS", payload);
213
- }
214
- /** Fetch a single activity definition by id. */
215
- getActivityDefinition(payload) {
216
- return this.request("GET_ACTIVITY_DEFINITION", payload);
217
- }
218
- // ---------------------------------------------------------------------------
219
- // Operations — Activity: activity instances
220
- // ---------------------------------------------------------------------------
221
- /** List activity instances. */
222
- getActivityInstances(payload) {
223
- return this.request("GET_ACTIVITY_INSTANCES", payload);
224
- }
225
- /** Fetch a single activity instance by id. */
226
- getActivityInstance(payload) {
227
- return this.request("GET_ACTIVITY_INSTANCE", payload);
228
- }
229
- /** Fetch an activity instance for a given period + activity definition. */
230
- getActivityInstanceByPeriod(payload) {
231
- return this.request("GET_ACTIVITY_INSTANCE_BY_PERIOD", payload);
232
- }
233
- /** List task instances for an activity instance. */
234
- getActivityInstanceTasks(payload) {
235
- return this.request("GET_ACTIVITY_INSTANCE_TASKS", payload);
236
- }
237
- /** List task instances for an activity in a given period. */
238
- getActivityPeriodTasks(payload) {
239
- return this.request("GET_ACTIVITY_PERIOD_TASKS", payload);
240
- }
241
- // ---------------------------------------------------------------------------
242
- // Operations — Activity: task definitions
243
- // ---------------------------------------------------------------------------
244
- /** List task definitions. */
245
- getTaskDefinitions(payload) {
246
- return this.request("GET_TASK_DEFINITIONS", payload);
247
- }
248
- /** Fetch a single task definition by id. */
249
- getTaskDefinition(payload) {
250
- return this.request("GET_TASK_DEFINITION", payload);
251
- }
252
- /** Create a task definition. */
253
- createTaskDefinition(payload) {
254
- return this.request("CREATE_TASK_DEFINITION", payload);
255
- }
256
- /** Update a task definition by id. */
257
- updateTaskDefinition(payload) {
258
- return this.request("UPDATE_TASK_DEFINITION", payload);
259
- }
260
- /** Query task definitions by filter. */
261
- getTaskDefinitionsByFilter(payload) {
262
- return this.request("GET_TASK_DEFINITIONS_BY_FILTER", payload);
263
- }
264
- // ---------------------------------------------------------------------------
265
- // Operations — Activity: task instances
266
- // ---------------------------------------------------------------------------
267
- /** List task instances. */
268
- getTaskInstances(payload) {
269
- return this.request("GET_TASK_INSTANCES", payload);
270
- }
271
- /** Fetch a single task instance by id. */
272
- getTaskInstance(payload) {
273
- return this.request("GET_TASK_INSTANCE", payload);
274
- }
275
- /**
276
- * Submit a Close-Management task output. This is the **only write path** back
277
- * into Nominal — Vibe Apps never write Nominal data directly.
278
- *
279
- * @example
280
- * ```ts
281
- * // `body` carries the task-specific output shape.
282
- * await bridge.postTaskOutput({
283
- * path: { task_instance_id: 'task-1' },
284
- * body: {},
285
- * })
286
- * ```
287
- */
288
- postTaskOutput(payload) {
289
- return this.request("POST_TASK_OUTPUT", payload);
290
- }
291
- /** Query task instances by filter. */
292
- getTaskInstancesByFilter(payload) {
293
- return this.request("GET_TASK_INSTANCES_BY_FILTER", payload);
294
- }
295
- // ---------------------------------------------------------------------------
296
- // Operations — Audit trail
297
- // ---------------------------------------------------------------------------
298
- /** List audit-trail events. */
299
- getAuditEvents(payload) {
300
- return this.request("GET_AUDIT_EVENTS", payload);
301
- }
302
- /** List audit-trail events for a specific entity. */
303
- getEntityAuditEvents(payload) {
304
- return this.request("GET_ENTITY_AUDIT_EVENTS", payload);
305
- }
306
- // ---------------------------------------------------------------------------
307
- // Operations — Period manager: fiscal calendars
308
- // ---------------------------------------------------------------------------
309
- /** List fiscal calendars for a subsidiary. */
310
- getFiscalCalendars(payload) {
311
- return this.request("GET_FISCAL_CALENDARS", payload);
312
- }
313
- /** Fetch a single fiscal calendar by id. */
314
- getFiscalCalendar(payload) {
315
- return this.request("GET_FISCAL_CALENDAR", payload);
316
- }
317
- // ---------------------------------------------------------------------------
318
- // Operations — Tenancy
319
- // ---------------------------------------------------------------------------
320
- /** List subsidiaries for the tenant. */
321
- getSubsidiaries(payload) {
322
- return this.request("GET_SUBSIDIARIES", payload);
323
- }
324
- /** Fetch a single subsidiary by id. */
325
- getSubsidiary(payload) {
326
- return this.request("GET_SUBSIDIARY", payload);
327
- }
328
- /** List parent currencies for a subsidiary. */
329
- getSubsidiaryParentCurrencies(payload) {
330
- return this.request("GET_SUBSIDIARY_PARENT_CURRENCIES", payload);
331
- }
332
- /** List users for the tenant. */
333
- getTenantUsers(payload) {
334
- return this.request("GET_TENANT_USERS", payload);
335
- }
336
- /**
337
- * Uploads a file through the host. Converts the `File` to an `ArrayBuffer`
338
- * before sending — sandboxed iframes cannot pass `File` handles across
339
- * windows. Resolves once Nominal has stored the file.
340
- *
341
- * @example
342
- * ```ts
343
- * const input = document.querySelector<HTMLInputElement>('#file')!
344
- * const file = input.files![0]
345
- * const result = await bridge.upload(file, {
346
- * entityType: 'JOURNAL_ENTRY',
347
- * entityId: '123',
348
- * onProgress: (p) => console.log(`${p.progress}%`),
349
- * })
350
- * // result.attachmentId, result.name
351
- * ```
352
- */
353
- async upload(file, options) {
354
- const buffer = await file.arrayBuffer();
355
- const payload = {
356
- buffer,
357
- fileName: file.name,
358
- fileType: file.type,
359
- entityType: options.entityType,
360
- entityId: options.entityId
361
- };
362
- const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
363
- return this.request("UPLOAD_FILE", payload, onProgress);
364
- }
365
- };
1
+ import {
2
+ BRIDGE_VERSION,
3
+ BridgeDataMethods,
4
+ BridgeError,
5
+ HttpBridgeError,
6
+ PROTOCOL_ID,
7
+ PROTOCOL_VERSION,
8
+ isBridgeMessage,
9
+ isOriginAllowed,
10
+ isOriginPattern,
11
+ normalizeSubroute
12
+ } from "./chunk-AH2BCQVB.js";
366
13
 
367
14
  // src/timeouts.ts
368
15
  var CONNECT_TIMEOUT_MS = 1e4;
@@ -370,8 +17,6 @@ var CONNECT_POLL_MS = 500;
370
17
  var DEFAULT_TIMEOUTS = {
371
18
  /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
372
19
  api: 3e4,
373
- /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
374
- context: 5e3,
375
20
  /** `INVALIDATE_CACHE`. */
376
21
  invalidate: 1e4,
377
22
  /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
@@ -384,8 +29,6 @@ function resolveRequestTimeout(type, requestTimeout) {
384
29
  return DEFAULT_TIMEOUTS.upload;
385
30
  case "INVALIDATE_CACHE":
386
31
  return DEFAULT_TIMEOUTS.invalidate;
387
- case "GET_CONTEXT":
388
- return DEFAULT_TIMEOUTS.context;
389
32
  default:
390
33
  return DEFAULT_TIMEOUTS.api;
391
34
  }
@@ -393,13 +36,23 @@ function resolveRequestTimeout(type, requestTimeout) {
393
36
 
394
37
  // src/VibeAppBridge.ts
395
38
  function resolvePosthog(mod) {
396
- let candidate = mod;
397
- while (candidate && typeof candidate.init !== "function" && candidate.default) {
398
- candidate = candidate.default;
399
- }
400
- return candidate;
39
+ const hasInit = (o) => typeof o?.init === "function";
40
+ const d1 = mod?.default;
41
+ if (hasInit(d1)) return d1;
42
+ const d2 = d1?.default;
43
+ if (hasInit(d2)) return d2;
44
+ if (hasInit(mod)) return mod;
45
+ return null;
46
+ }
47
+ function serverEnvError(method) {
48
+ return new BridgeError(
49
+ "SERVER_ENVIRONMENT",
50
+ `VibeAppBridge.${method} was called outside a browser (no \`window\`). The bridge only runs in the browser \u2014 call it from a client component or effect, not during SSR, in a React Server Component, or in a Worker.`
51
+ );
52
+ }
53
+ function assertBrowser(method) {
54
+ if (typeof window === "undefined") throw serverEnvError(method);
401
55
  }
402
- var posthog = resolvePosthog(posthogDefault);
403
56
  var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
404
57
  var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
405
58
  function isPreviewSelfHost(hostname) {
@@ -459,9 +112,22 @@ function resolveOriginPolicy(configured) {
459
112
  return { targetOrigin, isTrusted };
460
113
  }
461
114
  var VibeAppBridge = class extends BridgeDataMethods {
462
- constructor({ parentOrigin, requestTimeout } = {}) {
115
+ /**
116
+ * Stores options only — **pure and side-effect-free**. No `window`,
117
+ * `document`, `history`, `postMessage`, `crypto`, or `posthog-js` access
118
+ * happens here, so constructing a bridge is safe during SSR / in a Server
119
+ * Component / in a Worker. Browser wiring is deferred to {@link connect} /
120
+ * {@link attach}.
121
+ */
122
+ constructor(options = {}) {
463
123
  super();
124
+ /** Concrete origin to `postMessage` to, or `null` until resolved at {@link attach}. */
125
+ this.targetOrigin = null;
126
+ /** Validates an inbound `event.origin`; rejects everything until {@link attach}. */
127
+ this.isTrustedOrigin = () => false;
464
128
  this.pendingRequests = /* @__PURE__ */ new Map();
129
+ /** Whether {@link attach} has installed the message listener + resolved origin. */
130
+ this.attached = false;
465
131
  this.context = null;
466
132
  /**
467
133
  * Set once {@link initPostHog} has successfully called `posthog.init()`.
@@ -469,17 +135,34 @@ var VibeAppBridge = class extends BridgeDataMethods {
469
135
  * recording is actually live.
470
136
  */
471
137
  this.posthogInitialized = false;
138
+ /** The lazily-imported `posthog-js` singleton, once recording is initialized. */
139
+ this.posthogInstance = null;
140
+ /**
141
+ * Set for the duration of an in-flight {@link initPostHog} (which is async — it
142
+ * awaits `import('posthog-js')`). Serializes initialization so two overlapping
143
+ * `POSTHOG_PUSH`es can't both call `posthog.init()`.
144
+ */
145
+ this.posthogIniting = false;
146
+ /** So the "posthog-js not installed" warning is logged at most once. */
147
+ this.posthogUnavailableWarned = false;
472
148
  this.connectPromise = null;
473
149
  this.connectPollInterval = null;
474
150
  this.connectTimeout = null;
475
151
  this.onContextReceived = null;
476
- this.onContextError = null;
152
+ /** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
153
+ this.rejectConnect = null;
154
+ /**
155
+ * Persistent subscriber for post-connect context changes (tenant/subsidiary
156
+ * switch, period close, …). Distinct from {@link onContextReceived}, which only
157
+ * resolves the connect handshake and is cleared afterward. `null` until
158
+ * {@link onContextChange} is called.
159
+ */
160
+ this.contextChangeCallback = null;
477
161
  /**
478
- * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
479
- * late response from a *previous* connect attempt, which would otherwise
480
- * resolve/reject the new one (a stale error could reject a healthy reconnect).
162
+ * Persistent subscriber for host auth changes (Nominal logout or
163
+ * identity/tenant switch). `null` until {@link onAuthChange} is called.
481
164
  */
482
- this.connectPollIds = /* @__PURE__ */ new Set();
165
+ this.authChangeCallback = null;
483
166
  this.lastReportedSubroute = null;
484
167
  this.suppressSubrouteReport = false;
485
168
  this.subrouteCallback = null;
@@ -492,7 +175,25 @@ var VibeAppBridge = class extends BridgeDataMethods {
492
175
  progress: (msg) => this.handleProgress(msg)
493
176
  };
494
177
  this.pushHandlers = {
495
- CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
178
+ CONTEXT_PUSH: (payload) => {
179
+ if (this.onContextReceived) {
180
+ this.onContextReceived(payload);
181
+ } else if (this.context) {
182
+ this.context = payload;
183
+ this.contextChangeCallback?.(payload);
184
+ }
185
+ },
186
+ // Persistent (not nulled after connect): the host delivers PostHog once at
187
+ // connect via its own push, and initPostHog is idempotent, so this can run
188
+ // whenever the message arrives, independent of how connect() resolved. Fire
189
+ // and forget — initPostHog is async (lazy-imports posthog-js) and swallows
190
+ // its own errors.
191
+ POSTHOG_PUSH: (payload) => {
192
+ void this.initPostHog(payload);
193
+ },
194
+ // Persistent: the host pushes auth changes (logout, identity/tenant
195
+ // switch) at any time after connect.
196
+ AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
496
197
  SUBROUTE_PUSH: (payload) => {
497
198
  const safe = normalizeSubroute(payload.subroute);
498
199
  if (!safe) return;
@@ -519,42 +220,72 @@ var VibeAppBridge = class extends BridgeDataMethods {
519
220
  this.ui = {
520
221
  /** Collapse or expand the host's side navigation. */
521
222
  setSideNav: (payload) => {
223
+ assertBrowser("ui.setSideNav()");
522
224
  this.sendCommand("SET_SIDE_NAV", payload);
523
225
  },
524
226
  /** Switch the host to a different subsidiary. */
525
227
  switchSubsidiary: (subsidiaryId) => {
228
+ assertBrowser("ui.switchSubsidiary()");
526
229
  this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
527
230
  }
528
231
  };
529
- const policy = resolveOriginPolicy(parentOrigin);
530
- this.targetOrigin = policy.targetOrigin;
531
- this.isTrustedOrigin = policy.isTrusted;
532
- this.requestTimeout = requestTimeout;
232
+ this.parentOriginOption = options.parentOrigin;
233
+ this.requestTimeout = options.requestTimeout;
533
234
  this.boundHandleMessage = this.handleMessage.bind(this);
534
- window.addEventListener("message", this.boundHandleMessage);
535
235
  }
536
236
  dispatchPush(msg) {
537
237
  const handler = this.pushHandlers[msg.type];
538
238
  handler(msg.payload);
539
239
  }
240
+ /**
241
+ * Installs the browser wiring: resolves the parent-origin policy and starts
242
+ * listening for host `postMessage`s. **Idempotent** and safe to call before
243
+ * {@link connect} if you want the bridge receiving pushes early; {@link
244
+ * connect} calls it for you, so most apps never call this directly.
245
+ *
246
+ * @throws `BridgeError` code `'SERVER_ENVIRONMENT'` if called where there is no
247
+ * `window`.
248
+ */
249
+ attach() {
250
+ assertBrowser("attach()");
251
+ if (this.attached) return;
252
+ const policy = resolveOriginPolicy(this.parentOriginOption);
253
+ this.targetOrigin = policy.targetOrigin;
254
+ this.isTrustedOrigin = policy.isTrusted;
255
+ window.addEventListener("message", this.boundHandleMessage);
256
+ this.attached = true;
257
+ }
258
+ /**
259
+ * Lazily installs the browser wiring on first transport use. Keeps the
260
+ * constructor pure while ensuring that any method reaching the wire (a data
261
+ * `request`, a fire-and-forget command) works without an explicit prior
262
+ * `attach()`/`connect()` — attach stays idempotent, so `connect()` is
263
+ * unaffected.
264
+ */
265
+ ensureAttached() {
266
+ if (!this.attached) this.attach();
267
+ }
540
268
  /**
541
269
  * Connects to the host and returns the tenant/user {@link ContextPayload}.
542
270
  * **Call this once on app init and await it before any other method** — data
543
271
  * methods, `upload()`, and subroute reporting all require an established
544
272
  * connection. Concurrent calls return the same promise.
545
273
  *
546
- * Actively polls the host every 500ms until a response arrives, which
547
- * handles the race condition where the host's initial context push arrives
548
- * before this listener is ready. Rejects with `Bridge connect timed out`
549
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
550
- * or the host hasn't mounted).
274
+ * First {@link attach | attaches} the browser wiring (message listener +
275
+ * origin resolution), then sends a `CONNECT` handshake, re-sending every 500ms
276
+ * until the host replies by pushing the context (so it works even if the host
277
+ * mounts after the iframe loads). Context is only ever delivered by push.
278
+ * Rejects with `Bridge connect timed out` after 10 seconds if no context
279
+ * arrives (usually a `parentOrigin` mismatch or the host hasn't mounted).
551
280
  *
552
281
  * After connecting, if the context includes an initial subroute (e.g. from
553
282
  * a deep link), the bridge navigates to it and starts auto-tracking
554
283
  * internal navigation via the history API.
555
284
  *
556
285
  * @returns The tenant/user/subsidiary context for this app session.
557
- * @throws If no context is received within 10 seconds.
286
+ * @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
287
+ * where there is no `window`, `'PARENT_ORIGIN_UNRESOLVED'` if no embedding
288
+ * origin could be resolved, or `'TIMEOUT'` if no context arrives in 10s.
558
289
  * @example
559
290
  * ```ts
560
291
  * const ctx = await bridge.connect()
@@ -562,8 +293,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
562
293
  * ```
563
294
  */
564
295
  connect() {
296
+ if (typeof window === "undefined") return Promise.reject(serverEnvError("connect()"));
565
297
  if (this.context) return Promise.resolve(this.context);
566
298
  if (this.connectPromise) return this.connectPromise;
299
+ this.attach();
567
300
  if (this.targetOrigin === null) {
568
301
  return Promise.reject(
569
302
  new BridgeError(
@@ -577,16 +310,16 @@ var VibeAppBridge = class extends BridgeDataMethods {
577
310
  this.stopConnectPolling();
578
311
  this.connectPromise = null;
579
312
  this.onContextReceived = null;
580
- this.onContextError = null;
313
+ this.rejectConnect = null;
581
314
  reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
582
315
  }, CONNECT_TIMEOUT_MS);
583
316
  this.connectTimeout = timer;
584
- this.onContextError = (error) => {
317
+ this.rejectConnect = (error) => {
585
318
  clearTimeout(timer);
586
319
  this.stopConnectPolling();
587
320
  this.connectPromise = null;
588
321
  this.onContextReceived = null;
589
- this.onContextError = null;
322
+ this.rejectConnect = null;
590
323
  reject(error);
591
324
  };
592
325
  this.onContextReceived = (ctx) => {
@@ -595,11 +328,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
595
328
  this.context = ctx;
596
329
  this.connectPromise = null;
597
330
  this.onContextReceived = null;
598
- this.onContextError = null;
331
+ this.rejectConnect = null;
599
332
  console.log(
600
- `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
333
+ `[vibe-bridge] Connected \u2014 bridge: ${BRIDGE_VERSION}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
601
334
  );
602
- this.initPostHog(ctx);
603
335
  const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
604
336
  if (initialSubroute && initialSubroute !== "/") {
605
337
  this.withSubrouteSuppressed(() => {
@@ -611,23 +343,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
611
343
  this.lastReportedSubroute = window.location.pathname + window.location.search;
612
344
  }
613
345
  this.setupNavigationTracking();
346
+ this.warnMissingListeners();
614
347
  resolve(ctx);
615
348
  };
616
- const poll = () => {
617
- const requestId = crypto.randomUUID();
618
- this.connectPollIds.add(requestId);
619
- const message = {
620
- __protocol: PROTOCOL_ID,
621
- __version: PROTOCOL_VERSION,
622
- kind: "request",
623
- type: "GET_CONTEXT",
624
- requestId,
625
- payload: { bridgeVersion: version }
626
- };
627
- this.postToParent(message);
349
+ const sendConnect = () => {
350
+ this.sendCommand("CONNECT", { bridgeVersion: BRIDGE_VERSION });
628
351
  };
629
- poll();
630
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
352
+ sendConnect();
353
+ this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
631
354
  });
632
355
  return this.connectPromise;
633
356
  }
@@ -637,6 +360,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
637
360
  * Use this for hash-based routers or non-standard navigation patterns.
638
361
  */
639
362
  reportSubroute(subroute, options) {
363
+ assertBrowser("reportSubroute()");
640
364
  const normalized = normalizeSubroute(subroute);
641
365
  if (normalized === null) return;
642
366
  this.lastReportedSubroute = normalized;
@@ -647,6 +371,8 @@ var VibeAppBridge = class extends BridgeDataMethods {
647
371
  * If no callback is registered, the SDK uses `history.pushState` + a
648
372
  * `popstate` event, which works for most SPA routers automatically.
649
373
  * Returns an unsubscribe function.
374
+ *
375
+ * Pure — only stores the callback, so it is safe to call before {@link connect}.
650
376
  */
651
377
  onSubrouteRequest(callback) {
652
378
  this.subrouteCallback = callback;
@@ -654,6 +380,58 @@ var VibeAppBridge = class extends BridgeDataMethods {
654
380
  this.subrouteCallback = null;
655
381
  };
656
382
  }
383
+ /**
384
+ * Subscribes to post-connect context changes (tenant/subsidiary switch, period
385
+ * close, …). The callback fires for each {@link ContextPayload} the host pushes
386
+ * *after* connect — not for the initial context, which {@link connect} already
387
+ * returns. Returns an unsubscribe function. Single subscriber: a second call
388
+ * replaces the first. Wire it **before {@link connect}** (the bridge warns at
389
+ * connect if it isn't).
390
+ *
391
+ * Pure — only stores the callback, so it is safe to call before {@link connect}.
392
+ */
393
+ onContextChange(callback) {
394
+ this.contextChangeCallback = callback;
395
+ return () => {
396
+ this.contextChangeCallback = null;
397
+ };
398
+ }
399
+ /**
400
+ * Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
401
+ * sign your backend out) or an identity/tenant switch (`authenticated: true`
402
+ * with a new `userId`/`tenant` — re-authenticate). Fires for each change the
403
+ * host pushes after connect. Returns an unsubscribe function. Single
404
+ * subscriber: a second call replaces the first.
405
+ *
406
+ * **Wire this before {@link connect}** — without it the embedded app won't
407
+ * sign out when the user logs out of Nominal (the bridge warns at connect if
408
+ * it isn't wired). Pure — only stores the callback.
409
+ */
410
+ onAuthChange(callback) {
411
+ this.authChangeCallback = callback;
412
+ return () => {
413
+ this.authChangeCallback = null;
414
+ };
415
+ }
416
+ /**
417
+ * At connect, nudge the app if it hasn't wired the change listeners: every
418
+ * embedded app should react to a Nominal logout ({@link onAuthChange}) and a
419
+ * tenant/subsidiary switch ({@link onContextChange}). Fires once during the
420
+ * connect flow (not speculatively later), so wire both **before** `connect()`.
421
+ * The skill/codegen does this by default; this catches hand-wired apps.
422
+ */
423
+ warnMissingListeners() {
424
+ if (!this.authChangeCallback) {
425
+ console.warn(
426
+ "[vibe-bridge] connected without onAuthChange \u2014 the app will not sign out on Nominal logout or react to an identity/tenant switch. Call bridge.onAuthChange() before connect()."
427
+ );
428
+ }
429
+ if (!this.contextChangeCallback) {
430
+ console.warn(
431
+ "[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
432
+ );
433
+ }
434
+ }
657
435
  // ---------------------------------------------------------------------------
658
436
  // Host navigation & UI commands
659
437
  //
@@ -673,6 +451,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
673
451
  * ```
674
452
  */
675
453
  navigate(path, action = "push") {
454
+ assertBrowser("navigate()");
676
455
  this.sendCommand("NAVIGATE", { path, action });
677
456
  }
678
457
  /**
@@ -686,10 +465,12 @@ var VibeAppBridge = class extends BridgeDataMethods {
686
465
  * ```
687
466
  */
688
467
  navigateTo(route, routeParams, action = "push") {
468
+ assertBrowser("navigateTo()");
689
469
  this.sendCommand("NAVIGATE", { route, routeParams, action });
690
470
  }
691
471
  /** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
692
472
  refresh() {
473
+ assertBrowser("refresh()");
693
474
  this.sendCommand("NAVIGATE", { action: "refresh" });
694
475
  }
695
476
  /**
@@ -711,8 +492,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
711
492
  *
712
493
  * **No-op until {@link connect} has resolved with PostHog enabled by the
713
494
  * host** — if the host didn't send a `posthog` block (older host, or recording
714
- * disabled for this env/app), nothing is sent. App authors don't import
715
- * `posthog-js` themselves; this is the supported event surface.
495
+ * disabled for this env/app), or `posthog-js` isn't installed (it is an
496
+ * optional dependency), nothing is sent. App authors don't import `posthog-js`
497
+ * themselves; this is the supported event surface.
716
498
  *
717
499
  * The event is sent **directly** from the iframe's own PostHog instance (only
718
500
  * the session *recording* is forwarded to the parent), so it carries the
@@ -729,23 +511,38 @@ var VibeAppBridge = class extends BridgeDataMethods {
729
511
  * ```
730
512
  */
731
513
  track(event, properties) {
732
- if (!this.posthogInitialized) return;
733
- posthog.capture(event, properties);
514
+ assertBrowser("track()");
515
+ if (!this.posthogInitialized || !this.posthogInstance) return;
516
+ this.posthogInstance.capture(event, properties);
734
517
  }
518
+ /**
519
+ * Tears down the bridge: stops the connect handshake, removes the message
520
+ * listener, restores patched history methods, and rejects any in-flight calls
521
+ * with a `'DESTROYED'` error. **Idempotent and safe on the server** — calling
522
+ * it when {@link connect}/{@link attach} never ran (or where there is no
523
+ * `window`) is a no-op, so it is safe as a React effect cleanup.
524
+ */
735
525
  destroy() {
736
526
  this.teardownNavigationTracking();
737
- this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
527
+ this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
738
528
  this.stopConnectPolling();
739
- window.removeEventListener("message", this.boundHandleMessage);
529
+ if (this.attached && typeof window !== "undefined") {
530
+ window.removeEventListener("message", this.boundHandleMessage);
531
+ }
532
+ this.attached = false;
740
533
  for (const pending of this.pendingRequests.values()) {
741
534
  pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
742
535
  }
743
536
  this.pendingRequests.clear();
744
537
  this.context = null;
745
538
  this.posthogInitialized = false;
539
+ this.posthogInstance = null;
540
+ this.posthogIniting = false;
746
541
  this.connectPromise = null;
747
542
  this.onContextReceived = null;
748
- this.onContextError = null;
543
+ this.rejectConnect = null;
544
+ this.contextChangeCallback = null;
545
+ this.authChangeCallback = null;
749
546
  this.subrouteCallback = null;
750
547
  this.lastReportedSubroute = null;
751
548
  }
@@ -758,25 +555,35 @@ var VibeAppBridge = class extends BridgeDataMethods {
758
555
  clearTimeout(this.connectTimeout);
759
556
  this.connectTimeout = null;
760
557
  }
761
- this.connectPollIds.clear();
558
+ }
559
+ warnPosthogUnavailable() {
560
+ if (this.posthogUnavailableWarned) return;
561
+ this.posthogUnavailableWarned = true;
562
+ console.warn(
563
+ "[vibe-bridge] The host enabled session recording but `posthog-js` is not installed (it is an optional dependency). Recording and track() are disabled. Run `npm i posthog-js` to enable them."
564
+ );
762
565
  }
763
566
  /**
764
567
  * Initializes PostHog session recording + analytics from the host-supplied
765
- * config, **only when the host opted in** (`ctx.posthog?.enabled`).
568
+ * config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
569
+ * (`config.enabled`).
766
570
  *
767
- * The host is the single source of truth: when it sends no `posthog` block
768
- * (older nom-ui, or recording disabled for this env/app) or `enabled: false`,
769
- * this does nothing `enabled` is the kill-switch even though `posthog-js` is
770
- * bundled into the bridge. Cross-origin replay needs `recordCrossOriginIframes`
771
- * on both sides so the parent receives the iframe's rrweb frames into one
772
- * stitched recording, and `identify()` ties the iframe's recording + events to
773
- * the same person as the host. Wrapped in try/catch so a PostHog failure is
774
- * best-effort and never blocks `connect()`.
571
+ * `posthog-js` is an **optional dependency**, loaded lazily via `await
572
+ * import('posthog-js')` the first time recording is enabled so it stays out
573
+ * of the import graph (and out of any server bundle) for apps that never
574
+ * record. If it isn't installed, this warns once and no-ops (never throws).
575
+ *
576
+ * The host is the single source of truth: when it sends no `POSTHOG_PUSH`
577
+ * (recording disabled for this env/app) or `enabled: false`, this does
578
+ * nothing. Cross-origin replay needs `recordCrossOriginIframes` on both sides
579
+ * so the parent receives the iframe's rrweb frames into one stitched recording,
580
+ * and `identify()` ties the iframe's recording + events to the same person as
581
+ * the host. Idempotent (the `posthogInitialized` guard) and wrapped in
582
+ * try/catch so a duplicate push or a PostHog failure is harmless.
775
583
  */
776
- initPostHog(ctx) {
777
- const config = ctx.posthog;
778
- if (!config?.enabled) return;
779
- if (this.posthogInitialized) return;
584
+ async initPostHog(config) {
585
+ if (!config.enabled) return;
586
+ if (this.posthogInitialized || this.posthogIniting) return;
780
587
  if (typeof window === "undefined") return;
781
588
  if (!config.token || !config.apiHost || !config.distinctId) {
782
589
  console.warn(
@@ -784,7 +591,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
784
591
  );
785
592
  return;
786
593
  }
594
+ this.posthogIniting = true;
787
595
  try {
596
+ let posthog;
597
+ try {
598
+ posthog = resolvePosthog(await import("posthog-js"));
599
+ } catch {
600
+ this.warnPosthogUnavailable();
601
+ return;
602
+ }
603
+ if (!posthog) {
604
+ this.warnPosthogUnavailable();
605
+ return;
606
+ }
607
+ if (!this.attached) return;
788
608
  posthog.init(config.token, {
789
609
  api_host: config.apiHost,
790
610
  // absolute ingestion host, NOT a relative /ingest proxy
@@ -807,16 +627,19 @@ var VibeAppBridge = class extends BridgeDataMethods {
807
627
  }
808
628
  });
809
629
  posthog.identify(config.distinctId);
630
+ this.posthogInstance = posthog;
810
631
  this.posthogInitialized = true;
811
632
  console.log("[vibe-bridge] PostHog session recording initialized");
812
633
  } catch (err) {
813
634
  console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
635
+ } finally {
636
+ this.posthogIniting = false;
814
637
  }
815
638
  }
816
639
  /**
817
640
  * Monkey-patches `history.pushState` and `history.replaceState` to
818
641
  * auto-detect SPA navigation and report it to the host. Called after
819
- * connect() resolves.
642
+ * connect() resolves (so `window`/`history` are guaranteed present).
820
643
  */
821
644
  setupNavigationTracking() {
822
645
  if (this.origPushState) return;
@@ -867,6 +690,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
867
690
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
868
691
  }
869
692
  sendCommand(type, payload) {
693
+ this.ensureAttached();
870
694
  this.postToParent({
871
695
  __protocol: PROTOCOL_ID,
872
696
  __version: PROTOCOL_VERSION,
@@ -882,12 +706,18 @@ var VibeAppBridge = class extends BridgeDataMethods {
882
706
  * `onProgress` callback. `type` autocompletes to every operation name and
883
707
  * narrows `payload`/return to that operation's types.
884
708
  *
709
+ * @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
710
+ * where there is no `window`.
885
711
  * @example
886
712
  * ```ts
887
713
  * const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })
888
714
  * ```
889
715
  */
890
716
  request(type, payload, onProgress) {
717
+ if (typeof window === "undefined") {
718
+ return Promise.reject(serverEnvError(`request(${String(type)})`));
719
+ }
720
+ this.ensureAttached();
891
721
  return new Promise((resolve, reject) => {
892
722
  const requestId = crypto.randomUUID();
893
723
  const timer = setTimeout(
@@ -934,15 +764,6 @@ var VibeAppBridge = class extends BridgeDataMethods {
934
764
  this.kindHandlers[event.data.kind]?.(event.data);
935
765
  }
936
766
  handleResponse(msg) {
937
- if (msg.type === "GET_CONTEXT") {
938
- if (!this.connectPollIds.has(msg.requestId)) return;
939
- if (msg.ok) {
940
- this.onContextReceived?.(msg.data);
941
- } else {
942
- this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
943
- }
944
- return;
945
- }
946
767
  const pending = this.pendingRequests.get(msg.requestId);
947
768
  if (!pending) return;
948
769
  this.pendingRequests.delete(msg.requestId);
@@ -962,8 +783,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
962
783
  }
963
784
  };
964
785
  export {
965
- version as BRIDGE_VERSION,
786
+ BRIDGE_VERSION,
966
787
  BridgeError,
967
788
  HttpBridgeError,
968
789
  VibeAppBridge
969
790
  };
791
+ //# sourceMappingURL=index.js.map