@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/dist/index.js CHANGED
@@ -1,7 +1,19 @@
1
1
  // package.json
2
- var version = "0.0.1";
2
+ var version = "0.2.0";
3
3
 
4
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
+ }
5
17
  var PROTOCOL_ID = "nominal-vibe-bridge";
6
18
  var PROTOCOL_VERSION = 1;
7
19
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
@@ -18,17 +30,353 @@ function isBridgeMessage(data) {
18
30
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
19
31
  return true;
20
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
+ };
21
47
 
22
- // src/VibeAppBridge.ts
48
+ // src/data-methods.ts
49
+ var BridgeDataMethods = class {
50
+ // ---------------------------------------------------------------------------
51
+ // Operations — Accounting: chart of accounts
52
+ //
53
+ // Each method is a typed wrapper over `request('<OP>', payload)`. The payload
54
+ // and return types come straight from the Nominal API (`RequestRegistry`).
55
+ // For any operation without a named method, call `request('<OP>', payload)`.
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Fetch the flat chart of accounts for a subsidiary.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const ctx = await bridge.connect()
63
+ * const accounts = await bridge.getChartOfAccounts({
64
+ * path: { subsidiary_id: ctx.subsidiaryId },
65
+ * })
66
+ * ```
67
+ */
68
+ getChartOfAccounts(payload) {
69
+ return this.request("GET_CHART_OF_ACCOUNTS", payload);
70
+ }
71
+ /** Fetch the chart of accounts as a hierarchical tree for a subsidiary. */
72
+ getCoaTree(payload) {
73
+ return this.request("GET_COA_TREE", payload);
74
+ }
75
+ /** Fetch a simplified flat chart of accounts for a subsidiary. */
76
+ getCoaFlatSimple(payload) {
77
+ return this.request("GET_COA_FLAT_SIMPLE", payload);
78
+ }
79
+ /** Fetch the flat chart of accounts grouped by account group. */
80
+ getCoaGrouped(payload) {
81
+ return this.request("GET_COA_GROUPED", payload);
82
+ }
83
+ /** Fetch a single chart-of-accounts account by id within a subsidiary. */
84
+ getCoaAccount(payload) {
85
+ return this.request("GET_COA_ACCOUNT", payload);
86
+ }
87
+ /** List the currencies available to a subsidiary's chart of accounts. */
88
+ getSubsidiaryAvailableCurrencies(payload) {
89
+ return this.request("GET_SUBSIDIARY_AVAILABLE_CURRENCIES", payload);
90
+ }
91
+ /** Fetch accounts at the tenant level. */
92
+ getAccounts(payload) {
93
+ return this.request("GET_ACCOUNTS", payload);
94
+ }
95
+ /** Fetch a single account by id. */
96
+ getAccount(payload) {
97
+ return this.request("GET_ACCOUNT", payload);
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Operations — Accounting: exchange rates
101
+ // ---------------------------------------------------------------------------
102
+ /** Fetch currency conversion rates for a currency. */
103
+ getConversionRates(payload) {
104
+ return this.request("GET_CONVERSION_RATES", payload);
105
+ }
106
+ /** Fetch the effective consolidation exchange rate. */
107
+ getEffectiveExchangeRate(payload) {
108
+ return this.request("GET_EFFECTIVE_EXCHANGE_RATE", payload);
109
+ }
110
+ /** Fetch the exchange-rate period covering a given input date. */
111
+ getExchangeRateByDate(payload) {
112
+ return this.request("GET_EXCHANGE_RATE_BY_DATE", payload);
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Operations — Accounting: dimensions
116
+ // ---------------------------------------------------------------------------
117
+ /** List accounting dimensions. */
118
+ getDimensions(payload) {
119
+ return this.request("GET_DIMENSIONS", payload);
120
+ }
121
+ /** List values for accounting dimensions. */
122
+ getDimensionValues(payload) {
123
+ return this.request("GET_DIMENSION_VALUES", payload);
124
+ }
125
+ /** List dimension values in hierarchical form. */
126
+ getDimensionValuesHierarchical(payload) {
127
+ return this.request("GET_DIMENSION_VALUES_HIERARCHICAL", payload);
128
+ }
129
+ /** List account assignments for a dimension/account pair. */
130
+ getDimensionAccountAssignments(payload) {
131
+ return this.request("GET_DIMENSION_ACCOUNT_ASSIGNMENTS", payload);
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Operations — Accounting: journal entries
135
+ // ---------------------------------------------------------------------------
136
+ /**
137
+ * Fetch journal entries for a subsidiary, optionally filtered by date range.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * const entries = await bridge.getJournalEntries({
142
+ * path: { subsidiary_id: ctx.subsidiaryId },
143
+ * query: { from_date: '2026-01-01', to_date: '2026-03-31' },
144
+ * })
145
+ * ```
146
+ */
147
+ getJournalEntries(payload) {
148
+ return this.request("GET_JOURNAL_ENTRIES", payload);
149
+ }
150
+ /** Fetch journal entry lines for a subsidiary. */
151
+ getJournalLines(payload) {
152
+ return this.request("GET_JOURNAL_LINES", payload);
153
+ }
154
+ /** Fetch a single journal entry by id within a subsidiary. */
155
+ getJournalEntry(payload) {
156
+ return this.request("GET_JOURNAL_ENTRY", payload);
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // Operations — Activity: period instances
160
+ // ---------------------------------------------------------------------------
161
+ /** List accounting period instances. */
162
+ getPeriods(payload) {
163
+ return this.request("GET_PERIODS", payload);
164
+ }
165
+ /** Fetch a single period instance by id. */
166
+ getPeriodInstance(payload) {
167
+ return this.request("GET_PERIOD_INSTANCE", payload);
168
+ }
169
+ /** Fetch a period instance by its display slug (e.g. `"mar-2026"`). */
170
+ getPeriodInstanceBySlug(payload) {
171
+ return this.request("GET_PERIOD_INSTANCE_BY_SLUG", payload);
172
+ }
173
+ /** Fetch the close-progress breakdown for a period (by slug). */
174
+ getPeriodProgressBreakdown(payload) {
175
+ return this.request("GET_PERIOD_PROGRESS_BREAKDOWN", payload);
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Operations — Activity: activity definitions
179
+ // ---------------------------------------------------------------------------
180
+ /** List activity definitions. */
181
+ getActivityDefinitions(payload) {
182
+ return this.request("GET_ACTIVITY_DEFINITIONS", payload);
183
+ }
184
+ /** Fetch a single activity definition by id. */
185
+ getActivityDefinition(payload) {
186
+ return this.request("GET_ACTIVITY_DEFINITION", payload);
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // Operations — Activity: activity instances
190
+ // ---------------------------------------------------------------------------
191
+ /** List activity instances. */
192
+ getActivityInstances(payload) {
193
+ return this.request("GET_ACTIVITY_INSTANCES", payload);
194
+ }
195
+ /** Fetch a single activity instance by id. */
196
+ getActivityInstance(payload) {
197
+ return this.request("GET_ACTIVITY_INSTANCE", payload);
198
+ }
199
+ /** Fetch an activity instance for a given period + activity definition. */
200
+ getActivityInstanceByPeriod(payload) {
201
+ return this.request("GET_ACTIVITY_INSTANCE_BY_PERIOD", payload);
202
+ }
203
+ /** List task instances for an activity instance. */
204
+ getActivityInstanceTasks(payload) {
205
+ return this.request("GET_ACTIVITY_INSTANCE_TASKS", payload);
206
+ }
207
+ /** List task instances for an activity in a given period. */
208
+ getActivityPeriodTasks(payload) {
209
+ return this.request("GET_ACTIVITY_PERIOD_TASKS", payload);
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Operations — Activity: task definitions
213
+ // ---------------------------------------------------------------------------
214
+ /** List task definitions. */
215
+ getTaskDefinitions(payload) {
216
+ return this.request("GET_TASK_DEFINITIONS", payload);
217
+ }
218
+ /** Fetch a single task definition by id. */
219
+ getTaskDefinition(payload) {
220
+ return this.request("GET_TASK_DEFINITION", payload);
221
+ }
222
+ /** Create a task definition. */
223
+ createTaskDefinition(payload) {
224
+ return this.request("CREATE_TASK_DEFINITION", payload);
225
+ }
226
+ /** Update a task definition by id. */
227
+ updateTaskDefinition(payload) {
228
+ return this.request("UPDATE_TASK_DEFINITION", payload);
229
+ }
230
+ /** Query task definitions by filter. */
231
+ getTaskDefinitionsByFilter(payload) {
232
+ return this.request("GET_TASK_DEFINITIONS_BY_FILTER", payload);
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Operations — Activity: task instances
236
+ // ---------------------------------------------------------------------------
237
+ /** List task instances. */
238
+ getTaskInstances(payload) {
239
+ return this.request("GET_TASK_INSTANCES", payload);
240
+ }
241
+ /** Fetch a single task instance by id. */
242
+ getTaskInstance(payload) {
243
+ return this.request("GET_TASK_INSTANCE", payload);
244
+ }
245
+ /**
246
+ * Submit a Close-Management task output. This is the **only write path** back
247
+ * into Nominal — Vibe Apps never write Nominal data directly.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * // `body` carries the task-specific output shape.
252
+ * await bridge.postTaskOutput({
253
+ * path: { task_instance_id: 'task-1' },
254
+ * body: {},
255
+ * })
256
+ * ```
257
+ */
258
+ postTaskOutput(payload) {
259
+ return this.request("POST_TASK_OUTPUT", payload);
260
+ }
261
+ /** Query task instances by filter. */
262
+ getTaskInstancesByFilter(payload) {
263
+ return this.request("GET_TASK_INSTANCES_BY_FILTER", payload);
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Operations — Audit trail
267
+ // ---------------------------------------------------------------------------
268
+ /** List audit-trail events. */
269
+ getAuditEvents(payload) {
270
+ return this.request("GET_AUDIT_EVENTS", payload);
271
+ }
272
+ /** List audit-trail events for a specific entity. */
273
+ getEntityAuditEvents(payload) {
274
+ return this.request("GET_ENTITY_AUDIT_EVENTS", payload);
275
+ }
276
+ // ---------------------------------------------------------------------------
277
+ // Operations — Period manager: fiscal calendars
278
+ // ---------------------------------------------------------------------------
279
+ /** List fiscal calendars for a subsidiary. */
280
+ getFiscalCalendars(payload) {
281
+ return this.request("GET_FISCAL_CALENDARS", payload);
282
+ }
283
+ /** Fetch a single fiscal calendar by id. */
284
+ getFiscalCalendar(payload) {
285
+ return this.request("GET_FISCAL_CALENDAR", payload);
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Operations — Tenancy
289
+ // ---------------------------------------------------------------------------
290
+ /** List subsidiaries for the tenant. */
291
+ getSubsidiaries(payload) {
292
+ return this.request("GET_SUBSIDIARIES", payload);
293
+ }
294
+ /** Fetch a single subsidiary by id. */
295
+ getSubsidiary(payload) {
296
+ return this.request("GET_SUBSIDIARY", payload);
297
+ }
298
+ /** List parent currencies for a subsidiary. */
299
+ getSubsidiaryParentCurrencies(payload) {
300
+ return this.request("GET_SUBSIDIARY_PARENT_CURRENCIES", payload);
301
+ }
302
+ /** List users for the tenant. */
303
+ getTenantUsers(payload) {
304
+ return this.request("GET_TENANT_USERS", payload);
305
+ }
306
+ /**
307
+ * Uploads a file through the host. Converts the `File` to an `ArrayBuffer`
308
+ * before sending — sandboxed iframes cannot pass `File` handles across
309
+ * windows. Resolves once Nominal has stored the file.
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * const input = document.querySelector<HTMLInputElement>('#file')!
314
+ * const file = input.files![0]
315
+ * const result = await bridge.upload(file, {
316
+ * entityType: 'JOURNAL_ENTRY',
317
+ * entityId: '123',
318
+ * onProgress: (p) => console.log(`${p.progress}%`),
319
+ * })
320
+ * // result.attachmentId, result.name
321
+ * ```
322
+ */
323
+ async upload(file, options) {
324
+ const buffer = await file.arrayBuffer();
325
+ const payload = {
326
+ buffer,
327
+ fileName: file.name,
328
+ fileType: file.type,
329
+ entityType: options.entityType,
330
+ entityId: options.entityId
331
+ };
332
+ const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
333
+ return this.request("UPLOAD_FILE", payload, onProgress);
334
+ }
335
+ };
336
+
337
+ // src/timeouts.ts
23
338
  var CONNECT_TIMEOUT_MS = 1e4;
24
339
  var CONNECT_POLL_MS = 500;
25
- var VibeAppBridge = class {
26
- constructor({ parentOrigin, requestTimeout = 1e4 }) {
340
+ var DEFAULT_TIMEOUTS = {
341
+ /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
342
+ api: 3e4,
343
+ /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
344
+ context: 5e3,
345
+ /** `INVALIDATE_CACHE`. */
346
+ invalidate: 1e4,
347
+ /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
348
+ upload: 12e4
349
+ };
350
+ function resolveRequestTimeout(type, requestTimeout) {
351
+ if (requestTimeout !== void 0) return requestTimeout;
352
+ switch (type) {
353
+ case "UPLOAD_FILE":
354
+ return DEFAULT_TIMEOUTS.upload;
355
+ case "INVALIDATE_CACHE":
356
+ return DEFAULT_TIMEOUTS.invalidate;
357
+ case "GET_CONTEXT":
358
+ return DEFAULT_TIMEOUTS.context;
359
+ default:
360
+ return DEFAULT_TIMEOUTS.api;
361
+ }
362
+ }
363
+
364
+ // src/VibeAppBridge.ts
365
+ var VibeAppBridge = class extends BridgeDataMethods {
366
+ constructor({ parentOrigin, requestTimeout }) {
367
+ super();
27
368
  this.pendingRequests = /* @__PURE__ */ new Map();
28
369
  this.context = null;
29
370
  this.connectPromise = null;
30
371
  this.connectPollInterval = null;
31
372
  this.onContextReceived = null;
373
+ this.onContextError = null;
374
+ /**
375
+ * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
376
+ * late response from a *previous* connect attempt, which would otherwise
377
+ * resolve/reject the new one (a stale error could reject a healthy reconnect).
378
+ */
379
+ this.connectPollIds = /* @__PURE__ */ new Set();
32
380
  this.lastReportedSubroute = null;
33
381
  this.suppressSubrouteReport = false;
34
382
  this.subrouteCallback = null;
@@ -42,15 +390,38 @@ var VibeAppBridge = class {
42
390
  };
43
391
  this.pushHandlers = {
44
392
  CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
45
- SUBROUTE_PUSH: (payload) => this.withSubrouteSuppressed(() => {
46
- if (this.subrouteCallback) {
47
- this.subrouteCallback(payload.subroute);
48
- } else {
49
- history.pushState(null, "", payload.subroute);
50
- window.dispatchEvent(new PopStateEvent("popstate"));
51
- }
52
- this.lastReportedSubroute = payload.subroute;
53
- })
393
+ SUBROUTE_PUSH: (payload) => {
394
+ const safe = normalizeSubroute(payload.subroute);
395
+ if (!safe) return;
396
+ this.withSubrouteSuppressed(() => {
397
+ if (this.subrouteCallback) {
398
+ this.subrouteCallback(safe);
399
+ } else {
400
+ history.pushState(null, "", safe);
401
+ window.dispatchEvent(new PopStateEvent("popstate"));
402
+ }
403
+ this.lastReportedSubroute = safe;
404
+ });
405
+ }
406
+ };
407
+ /**
408
+ * Commands targeting the host's UI chrome. All fire-and-forget.
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * bridge.ui.setSideNav({ collapsed: true })
413
+ * bridge.ui.switchSubsidiary(42)
414
+ * ```
415
+ */
416
+ this.ui = {
417
+ /** Collapse or expand the host's side navigation. */
418
+ setSideNav: (payload) => {
419
+ this.sendCommand("SET_SIDE_NAV", payload);
420
+ },
421
+ /** Switch the host to a different subsidiary. */
422
+ switchSubsidiary: (subsidiaryId) => {
423
+ this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
424
+ }
54
425
  };
55
426
  this.parentOrigin = parentOrigin;
56
427
  this.requestTimeout = requestTimeout;
@@ -62,16 +433,28 @@ var VibeAppBridge = class {
62
433
  handler(msg.payload);
63
434
  }
64
435
  /**
65
- * Connects to the host and returns the tenant/user context.
66
- * Call once on app init concurrent calls return the same promise.
436
+ * Connects to the host and returns the tenant/user {@link ContextPayload}.
437
+ * **Call this once on app init and await it before any other method** — data
438
+ * methods, `upload()`, and subroute reporting all require an established
439
+ * connection. Concurrent calls return the same promise.
67
440
  *
68
441
  * Actively polls the host every 500ms until a response arrives, which
69
442
  * handles the race condition where the host's initial context push arrives
70
- * before this listener is ready.
443
+ * before this listener is ready. Rejects with `Bridge connect timed out`
444
+ * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
445
+ * or the host hasn't mounted).
71
446
  *
72
447
  * After connecting, if the context includes an initial subroute (e.g. from
73
448
  * a deep link), the bridge navigates to it and starts auto-tracking
74
449
  * internal navigation via the history API.
450
+ *
451
+ * @returns The tenant/user/subsidiary context for this app session.
452
+ * @throws If no context is received within 10 seconds.
453
+ * @example
454
+ * ```ts
455
+ * const ctx = await bridge.connect()
456
+ * // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
457
+ * ```
75
458
  */
76
459
  connect() {
77
460
  if (this.context) return Promise.resolve(this.context);
@@ -80,22 +463,34 @@ var VibeAppBridge = class {
80
463
  const timer = setTimeout(() => {
81
464
  this.stopConnectPolling();
82
465
  this.connectPromise = null;
83
- reject(new Error("Bridge connect timed out"));
466
+ this.onContextReceived = null;
467
+ this.onContextError = null;
468
+ reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
84
469
  }, CONNECT_TIMEOUT_MS);
470
+ this.onContextError = (error) => {
471
+ clearTimeout(timer);
472
+ this.stopConnectPolling();
473
+ this.connectPromise = null;
474
+ this.onContextReceived = null;
475
+ this.onContextError = null;
476
+ reject(error);
477
+ };
85
478
  this.onContextReceived = (ctx) => {
86
479
  clearTimeout(timer);
87
480
  this.stopConnectPolling();
88
481
  this.context = ctx;
89
482
  this.connectPromise = null;
90
483
  this.onContextReceived = null;
484
+ this.onContextError = null;
91
485
  console.log(
92
486
  `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
93
487
  );
94
- if (ctx.subroute && ctx.subroute !== "/") {
488
+ const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
489
+ if (initialSubroute && initialSubroute !== "/") {
95
490
  this.withSubrouteSuppressed(() => {
96
- history.replaceState(null, "", ctx.subroute);
491
+ history.replaceState(null, "", initialSubroute);
97
492
  window.dispatchEvent(new PopStateEvent("popstate"));
98
- this.lastReportedSubroute = ctx.subroute;
493
+ this.lastReportedSubroute = initialSubroute;
99
494
  });
100
495
  } else {
101
496
  this.lastReportedSubroute = window.location.pathname + window.location.search;
@@ -104,12 +499,14 @@ var VibeAppBridge = class {
104
499
  resolve(ctx);
105
500
  };
106
501
  const poll = () => {
502
+ const requestId = crypto.randomUUID();
503
+ this.connectPollIds.add(requestId);
107
504
  const message = {
108
505
  __protocol: PROTOCOL_ID,
109
506
  __version: PROTOCOL_VERSION,
110
507
  kind: "request",
111
508
  type: "GET_CONTEXT",
112
- requestId: crypto.randomUUID(),
509
+ requestId,
113
510
  payload: { bridgeVersion: version }
114
511
  };
115
512
  window.parent.postMessage(message, this.parentOrigin);
@@ -119,47 +516,14 @@ var VibeAppBridge = class {
119
516
  });
120
517
  return this.connectPromise;
121
518
  }
122
- getChartOfAccounts(payload) {
123
- return this.request("GET_CHART_OF_ACCOUNTS", payload);
124
- }
125
- getSubsidiaries(payload) {
126
- return this.request("GET_SUBSIDIARIES", payload);
127
- }
128
- getPeriods(payload) {
129
- return this.request("GET_PERIODS", payload);
130
- }
131
- postTaskOutput(payload) {
132
- return this.request("POST_TASK_OUTPUT", payload);
133
- }
134
- getJournalEntries(payload) {
135
- return this.request("GET_JOURNAL_ENTRIES", payload);
136
- }
137
- getJournalLines(payload) {
138
- return this.request("GET_JOURNAL_LINES", payload);
139
- }
140
- /**
141
- * Uploads a file through the host. Converts the File to an ArrayBuffer
142
- * before sending — sandboxed iframes cannot pass File handles across windows.
143
- */
144
- async upload(file, options) {
145
- const buffer = await file.arrayBuffer();
146
- const payload = {
147
- buffer,
148
- fileName: file.name,
149
- fileType: file.type,
150
- entityType: options.entityType,
151
- entityId: options.entityId
152
- };
153
- const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
154
- return this.request("UPLOAD_FILE", payload, onProgress);
155
- }
156
519
  /**
157
520
  * Manually reports the current subroute to the host.
158
521
  * Usually not needed — navigation is auto-detected via the history API.
159
522
  * Use this for hash-based routers or non-standard navigation patterns.
160
523
  */
161
524
  reportSubroute(subroute, options) {
162
- const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
525
+ const normalized = normalizeSubroute(subroute);
526
+ if (normalized === null) return;
163
527
  this.lastReportedSubroute = normalized;
164
528
  this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
165
529
  }
@@ -175,17 +539,68 @@ var VibeAppBridge = class {
175
539
  this.subrouteCallback = null;
176
540
  };
177
541
  }
542
+ // ---------------------------------------------------------------------------
543
+ // Host navigation & UI commands
544
+ //
545
+ // These drive the *host* (nom-ui) chrome — its router, side nav, and
546
+ // subsidiary switcher — rather than the iframe's own internal routing
547
+ // (`reportSubroute`). Navigation/UI commands are fire-and-forget;
548
+ // `invalidateCache` is awaited.
549
+ // ---------------------------------------------------------------------------
550
+ /**
551
+ * Navigates the host to a raw path relative to this app's tenant/subsidiary
552
+ * base. Fire-and-forget.
553
+ *
554
+ * @example
555
+ * ```ts
556
+ * bridge.navigate('/journal-entries/123')
557
+ * bridge.navigate('/journal-entries/123', 'replace')
558
+ * ```
559
+ */
560
+ navigate(path, action = "push") {
561
+ this.sendCommand("NAVIGATE", { path, action });
562
+ }
563
+ /**
564
+ * Navigates the host to a named Nominal route, with optional params for
565
+ * placeholder segments. Fire-and-forget.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * bridge.navigateTo('CHART_OF_ACCOUNTS')
570
+ * bridge.navigateTo('JOURNAL_ENTRY', { id: '123' }, 'replace')
571
+ * ```
572
+ */
573
+ navigateTo(route, routeParams, action = "push") {
574
+ this.sendCommand("NAVIGATE", { route, routeParams, action });
575
+ }
576
+ /** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
577
+ refresh() {
578
+ this.sendCommand("NAVIGATE", { action: "refresh" });
579
+ }
580
+ /**
581
+ * Asks the host to invalidate cached data by path prefix and/or cache tag so
582
+ * subsequent reads see fresh values. Awaited.
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * await bridge.invalidateCache({ tags: ['SUBSIDIARIES'], paths: ['/accounting/accounts'] })
587
+ * ```
588
+ */
589
+ invalidateCache(payload) {
590
+ return this.request("INVALIDATE_CACHE", payload);
591
+ }
178
592
  destroy() {
179
593
  this.teardownNavigationTracking();
180
594
  this.stopConnectPolling();
181
595
  window.removeEventListener("message", this.boundHandleMessage);
182
596
  for (const pending of this.pendingRequests.values()) {
183
- pending.reject(new Error("Bridge destroyed"));
597
+ pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
184
598
  }
185
599
  this.pendingRequests.clear();
186
600
  this.context = null;
187
601
  this.connectPromise = null;
188
602
  this.onContextReceived = null;
603
+ this.onContextError = null;
189
604
  this.subrouteCallback = null;
190
605
  this.lastReportedSubroute = null;
191
606
  }
@@ -194,6 +609,7 @@ var VibeAppBridge = class {
194
609
  clearInterval(this.connectPollInterval);
195
610
  this.connectPollInterval = null;
196
611
  }
612
+ this.connectPollIds.clear();
197
613
  }
198
614
  /**
199
615
  * Monkey-patches `history.pushState` and `history.replaceState` to
@@ -242,7 +658,8 @@ var VibeAppBridge = class {
242
658
  }
243
659
  reportCurrentSubroute(replace) {
244
660
  if (this.suppressSubrouteReport) return;
245
- const subroute = window.location.pathname + window.location.search;
661
+ const subroute = normalizeSubroute(window.location.pathname + window.location.search);
662
+ if (subroute === null) return;
246
663
  if (subroute === this.lastReportedSubroute) return;
247
664
  this.lastReportedSubroute = subroute;
248
665
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
@@ -253,13 +670,28 @@ var VibeAppBridge = class {
253
670
  this.parentOrigin
254
671
  );
255
672
  }
673
+ /**
674
+ * Low-level typed request for **any** operation in {@link RequestRegistry}.
675
+ * The named methods (e.g. {@link VibeAppBridge.getChartOfAccounts}) wrap this;
676
+ * use it directly for operations without a named method, or to pass an
677
+ * `onProgress` callback. `type` autocompletes to every operation name and
678
+ * narrows `payload`/return to that operation's types.
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })
683
+ * ```
684
+ */
256
685
  request(type, payload, onProgress) {
257
686
  return new Promise((resolve, reject) => {
258
687
  const requestId = crypto.randomUUID();
259
- const timer = setTimeout(() => {
260
- this.pendingRequests.delete(requestId);
261
- reject(new Error(`Vibe bridge request timed out: ${type}`));
262
- }, this.requestTimeout);
688
+ const timer = setTimeout(
689
+ () => {
690
+ this.pendingRequests.delete(requestId);
691
+ reject(new BridgeError("TIMEOUT", `Vibe bridge request timed out: ${type}`));
692
+ },
693
+ resolveRequestTimeout(type, this.requestTimeout)
694
+ );
263
695
  this.pendingRequests.set(requestId, {
264
696
  resolve: (data) => {
265
697
  clearTimeout(timer);
@@ -289,7 +721,12 @@ var VibeAppBridge = class {
289
721
  }
290
722
  handleResponse(msg) {
291
723
  if (msg.type === "GET_CONTEXT") {
292
- if (msg.ok) this.onContextReceived?.(msg.data);
724
+ if (!this.connectPollIds.has(msg.requestId)) return;
725
+ if (msg.ok) {
726
+ this.onContextReceived?.(msg.data);
727
+ } else {
728
+ this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
729
+ }
293
730
  return;
294
731
  }
295
732
  const pending = this.pendingRequests.get(msg.requestId);
@@ -297,6 +734,10 @@ var VibeAppBridge = class {
297
734
  this.pendingRequests.delete(msg.requestId);
298
735
  if (msg.ok) {
299
736
  pending.resolve(msg.data);
737
+ } else if (msg.status !== void 0) {
738
+ pending.reject(new HttpBridgeError(msg.status, msg.error));
739
+ } else if (msg.code) {
740
+ pending.reject(new BridgeError(msg.code, msg.error));
300
741
  } else {
301
742
  pending.reject(new Error(msg.error));
302
743
  }
@@ -308,5 +749,7 @@ var VibeAppBridge = class {
308
749
  };
309
750
  export {
310
751
  version as BRIDGE_VERSION,
752
+ BridgeError,
753
+ HttpBridgeError,
311
754
  VibeAppBridge
312
755
  };