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