@nominalso/vibe-bridge 0.1.0 → 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.1.0";
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,119 +30,23 @@ function isBridgeMessage(data) {
18
30
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
19
31
  return true;
20
32
  }
21
-
22
- // src/VibeAppBridge.ts
23
- var CONNECT_TIMEOUT_MS = 1e4;
24
- var CONNECT_POLL_MS = 500;
25
- var VibeAppBridge = class {
26
- constructor({ parentOrigin, requestTimeout = 1e4 }) {
27
- this.pendingRequests = /* @__PURE__ */ new Map();
28
- this.context = null;
29
- this.connectPromise = null;
30
- this.connectPollInterval = null;
31
- this.onContextReceived = null;
32
- this.lastReportedSubroute = null;
33
- this.suppressSubrouteReport = false;
34
- this.subrouteCallback = null;
35
- this.origPushState = null;
36
- this.origReplaceState = null;
37
- this.popstateHandler = null;
38
- this.kindHandlers = {
39
- response: (msg) => this.handleResponse(msg),
40
- push: (msg) => this.dispatchPush(msg),
41
- progress: (msg) => this.handleProgress(msg)
42
- };
43
- this.pushHandlers = {
44
- 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
- })
54
- };
55
- this.parentOrigin = parentOrigin;
56
- this.requestTimeout = requestTimeout;
57
- this.boundHandleMessage = this.handleMessage.bind(this);
58
- window.addEventListener("message", this.boundHandleMessage);
33
+ var BridgeError = class extends Error {
34
+ constructor(code, message) {
35
+ super(message);
36
+ this.code = code;
37
+ this.name = "BridgeError";
59
38
  }
60
- dispatchPush(msg) {
61
- const handler = this.pushHandlers[msg.type];
62
- handler(msg.payload);
63
- }
64
- /**
65
- * Connects to the host and returns the tenant/user {@link ContextPayload}.
66
- * **Call this once on app init and await it before any other method** — data
67
- * methods, `upload()`, and subroute reporting all require an established
68
- * connection. Concurrent calls return the same promise.
69
- *
70
- * Actively polls the host every 500ms until a response arrives, which
71
- * handles the race condition where the host's initial context push arrives
72
- * before this listener is ready. Rejects with `Bridge connect timed out`
73
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
74
- * or the host hasn't mounted).
75
- *
76
- * After connecting, if the context includes an initial subroute (e.g. from
77
- * a deep link), the bridge navigates to it and starts auto-tracking
78
- * internal navigation via the history API.
79
- *
80
- * @returns The tenant/user/subsidiary context for this app session.
81
- * @throws If no context is received within 10 seconds.
82
- * @example
83
- * ```ts
84
- * const ctx = await bridge.connect()
85
- * // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
86
- * ```
87
- */
88
- connect() {
89
- if (this.context) return Promise.resolve(this.context);
90
- if (this.connectPromise) return this.connectPromise;
91
- this.connectPromise = new Promise((resolve, reject) => {
92
- const timer = setTimeout(() => {
93
- this.stopConnectPolling();
94
- this.connectPromise = null;
95
- reject(new Error("Bridge connect timed out"));
96
- }, CONNECT_TIMEOUT_MS);
97
- this.onContextReceived = (ctx) => {
98
- clearTimeout(timer);
99
- this.stopConnectPolling();
100
- this.context = ctx;
101
- this.connectPromise = null;
102
- this.onContextReceived = null;
103
- console.log(
104
- `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
105
- );
106
- if (ctx.subroute && ctx.subroute !== "/") {
107
- this.withSubrouteSuppressed(() => {
108
- history.replaceState(null, "", ctx.subroute);
109
- window.dispatchEvent(new PopStateEvent("popstate"));
110
- this.lastReportedSubroute = ctx.subroute;
111
- });
112
- } else {
113
- this.lastReportedSubroute = window.location.pathname + window.location.search;
114
- }
115
- this.setupNavigationTracking();
116
- resolve(ctx);
117
- };
118
- const poll = () => {
119
- const message = {
120
- __protocol: PROTOCOL_ID,
121
- __version: PROTOCOL_VERSION,
122
- kind: "request",
123
- type: "GET_CONTEXT",
124
- requestId: crypto.randomUUID(),
125
- payload: { bridgeVersion: version }
126
- };
127
- window.parent.postMessage(message, this.parentOrigin);
128
- };
129
- poll();
130
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
131
- });
132
- return this.connectPromise;
39
+ };
40
+ var HttpBridgeError = class extends BridgeError {
41
+ constructor(status, message) {
42
+ super("REQUEST_FAILED", message);
43
+ this.status = status;
44
+ this.name = "HttpBridgeError";
133
45
  }
46
+ };
47
+
48
+ // src/data-methods.ts
49
+ var BridgeDataMethods = class {
134
50
  // ---------------------------------------------------------------------------
135
51
  // Operations — Accounting: chart of accounts
136
52
  //
@@ -416,13 +332,198 @@ var VibeAppBridge = class {
416
332
  const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
417
333
  return this.request("UPLOAD_FILE", payload, onProgress);
418
334
  }
335
+ };
336
+
337
+ // src/timeouts.ts
338
+ var CONNECT_TIMEOUT_MS = 1e4;
339
+ var CONNECT_POLL_MS = 500;
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();
368
+ this.pendingRequests = /* @__PURE__ */ new Map();
369
+ this.context = null;
370
+ this.connectPromise = null;
371
+ this.connectPollInterval = null;
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();
380
+ this.lastReportedSubroute = null;
381
+ this.suppressSubrouteReport = false;
382
+ this.subrouteCallback = null;
383
+ this.origPushState = null;
384
+ this.origReplaceState = null;
385
+ this.popstateHandler = null;
386
+ this.kindHandlers = {
387
+ response: (msg) => this.handleResponse(msg),
388
+ push: (msg) => this.dispatchPush(msg),
389
+ progress: (msg) => this.handleProgress(msg)
390
+ };
391
+ this.pushHandlers = {
392
+ CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
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
+ }
425
+ };
426
+ this.parentOrigin = parentOrigin;
427
+ this.requestTimeout = requestTimeout;
428
+ this.boundHandleMessage = this.handleMessage.bind(this);
429
+ window.addEventListener("message", this.boundHandleMessage);
430
+ }
431
+ dispatchPush(msg) {
432
+ const handler = this.pushHandlers[msg.type];
433
+ handler(msg.payload);
434
+ }
435
+ /**
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.
440
+ *
441
+ * Actively polls the host every 500ms until a response arrives, which
442
+ * handles the race condition where the host's initial context push arrives
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).
446
+ *
447
+ * After connecting, if the context includes an initial subroute (e.g. from
448
+ * a deep link), the bridge navigates to it and starts auto-tracking
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
+ * ```
458
+ */
459
+ connect() {
460
+ if (this.context) return Promise.resolve(this.context);
461
+ if (this.connectPromise) return this.connectPromise;
462
+ this.connectPromise = new Promise((resolve, reject) => {
463
+ const timer = setTimeout(() => {
464
+ this.stopConnectPolling();
465
+ this.connectPromise = null;
466
+ this.onContextReceived = null;
467
+ this.onContextError = null;
468
+ reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
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
+ };
478
+ this.onContextReceived = (ctx) => {
479
+ clearTimeout(timer);
480
+ this.stopConnectPolling();
481
+ this.context = ctx;
482
+ this.connectPromise = null;
483
+ this.onContextReceived = null;
484
+ this.onContextError = null;
485
+ console.log(
486
+ `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
487
+ );
488
+ const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
489
+ if (initialSubroute && initialSubroute !== "/") {
490
+ this.withSubrouteSuppressed(() => {
491
+ history.replaceState(null, "", initialSubroute);
492
+ window.dispatchEvent(new PopStateEvent("popstate"));
493
+ this.lastReportedSubroute = initialSubroute;
494
+ });
495
+ } else {
496
+ this.lastReportedSubroute = window.location.pathname + window.location.search;
497
+ }
498
+ this.setupNavigationTracking();
499
+ resolve(ctx);
500
+ };
501
+ const poll = () => {
502
+ const requestId = crypto.randomUUID();
503
+ this.connectPollIds.add(requestId);
504
+ const message = {
505
+ __protocol: PROTOCOL_ID,
506
+ __version: PROTOCOL_VERSION,
507
+ kind: "request",
508
+ type: "GET_CONTEXT",
509
+ requestId,
510
+ payload: { bridgeVersion: version }
511
+ };
512
+ window.parent.postMessage(message, this.parentOrigin);
513
+ };
514
+ poll();
515
+ this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
516
+ });
517
+ return this.connectPromise;
518
+ }
419
519
  /**
420
520
  * Manually reports the current subroute to the host.
421
521
  * Usually not needed — navigation is auto-detected via the history API.
422
522
  * Use this for hash-based routers or non-standard navigation patterns.
423
523
  */
424
524
  reportSubroute(subroute, options) {
425
- const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
525
+ const normalized = normalizeSubroute(subroute);
526
+ if (normalized === null) return;
426
527
  this.lastReportedSubroute = normalized;
427
528
  this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
428
529
  }
@@ -438,17 +539,68 @@ var VibeAppBridge = class {
438
539
  this.subrouteCallback = null;
439
540
  };
440
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
+ }
441
592
  destroy() {
442
593
  this.teardownNavigationTracking();
443
594
  this.stopConnectPolling();
444
595
  window.removeEventListener("message", this.boundHandleMessage);
445
596
  for (const pending of this.pendingRequests.values()) {
446
- pending.reject(new Error("Bridge destroyed"));
597
+ pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
447
598
  }
448
599
  this.pendingRequests.clear();
449
600
  this.context = null;
450
601
  this.connectPromise = null;
451
602
  this.onContextReceived = null;
603
+ this.onContextError = null;
452
604
  this.subrouteCallback = null;
453
605
  this.lastReportedSubroute = null;
454
606
  }
@@ -457,6 +609,7 @@ var VibeAppBridge = class {
457
609
  clearInterval(this.connectPollInterval);
458
610
  this.connectPollInterval = null;
459
611
  }
612
+ this.connectPollIds.clear();
460
613
  }
461
614
  /**
462
615
  * Monkey-patches `history.pushState` and `history.replaceState` to
@@ -505,7 +658,8 @@ var VibeAppBridge = class {
505
658
  }
506
659
  reportCurrentSubroute(replace) {
507
660
  if (this.suppressSubrouteReport) return;
508
- const subroute = window.location.pathname + window.location.search;
661
+ const subroute = normalizeSubroute(window.location.pathname + window.location.search);
662
+ if (subroute === null) return;
509
663
  if (subroute === this.lastReportedSubroute) return;
510
664
  this.lastReportedSubroute = subroute;
511
665
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
@@ -531,10 +685,13 @@ var VibeAppBridge = class {
531
685
  request(type, payload, onProgress) {
532
686
  return new Promise((resolve, reject) => {
533
687
  const requestId = crypto.randomUUID();
534
- const timer = setTimeout(() => {
535
- this.pendingRequests.delete(requestId);
536
- reject(new Error(`Vibe bridge request timed out: ${type}`));
537
- }, 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
+ );
538
695
  this.pendingRequests.set(requestId, {
539
696
  resolve: (data) => {
540
697
  clearTimeout(timer);
@@ -564,7 +721,12 @@ var VibeAppBridge = class {
564
721
  }
565
722
  handleResponse(msg) {
566
723
  if (msg.type === "GET_CONTEXT") {
567
- 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
+ }
568
730
  return;
569
731
  }
570
732
  const pending = this.pendingRequests.get(msg.requestId);
@@ -572,6 +734,10 @@ var VibeAppBridge = class {
572
734
  this.pendingRequests.delete(msg.requestId);
573
735
  if (msg.ok) {
574
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));
575
741
  } else {
576
742
  pending.reject(new Error(msg.error));
577
743
  }
@@ -583,5 +749,7 @@ var VibeAppBridge = class {
583
749
  };
584
750
  export {
585
751
  version as BRIDGE_VERSION,
752
+ BridgeError,
753
+ HttpBridgeError,
586
754
  VibeAppBridge
587
755
  };
@@ -32,7 +32,7 @@ The host pushes context to the iframe on load, but the iframe's listener may not
32
32
  2. The host responds as soon as its listener is mounted, **or** proactively pushes `CONTEXT_PUSH` once it has the iframe's window.
33
33
  3. Whichever arrives first resolves `connect()`. Polling stops.
34
34
  4. The bridge begins auto-tracking SPA navigation (see below).
35
- 5. If nothing arrives within **10 seconds**, `connect()` rejects with `Bridge connect timed out`.
35
+ 5. If nothing arrives within **10 seconds**, `connect()` rejects with a `BridgeError` (code `'TIMEOUT'`, message `Bridge connect timed out`).
36
36
 
37
37
  A timeout almost always means a `parentOrigin` mismatch or the host never mounted.
38
38
 
@@ -53,4 +53,4 @@ After connecting, the bridge monkey-patches `history.pushState`/`replaceState` t
53
53
  bridge.destroy()
54
54
  ```
55
55
 
56
- Removes listeners, rejects pending requests with `Bridge destroyed`, and restores the original history methods. Call it on unmount.
56
+ Removes listeners, rejects any pending requests with a `BridgeError` (code `'DESTROYED'`, message `Bridge destroyed`), and restores the original history methods. Call it on unmount.
@@ -39,15 +39,20 @@ Vibe Apps never write Nominal data directly; everything else is read-only.
39
39
 
40
40
  ## Errors & timeouts
41
41
 
42
- - A rejected operation throws an `Error` whose `message` is the host's error string. Wrap calls in `try/catch`.
43
- - Requests time out after `requestTimeout` ms (default 10 000) with `Vibe bridge request timed out: <OPERATION>`. Override per-bridge: `new VibeAppBridge({ parentOrigin, requestTimeout: 30000 })`.
42
+ - A rejected operation throws a `BridgeError` (`extends Error`) carrying a machine-readable `code` (e.g. `'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`) alongside the host's `message`. Branch on `error.code` rather than string-matching the message. (A failure the host sends without a code falls back to a plain `Error`.) A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`), which adds the HTTP `status` — branch on `err.status` (e.g. `404`).
43
+ - Each operation times out on a default tuned to how long it realistically takes: API reads/writes **30 000 ms**, `UPLOAD_FILE` **120 000 ms**, `INVALIDATE_CACHE` **10 000 ms**, `GET_CONTEXT` **5 000 ms**. On timeout the call rejects with a `BridgeError` whose `code` is `'TIMEOUT'` (message `Vibe bridge request timed out: <OPERATION>`). Override all of them with one value via `new VibeAppBridge({ parentOrigin, requestTimeout: 45000 })`.
44
44
 
45
45
  ```ts
46
+ import { BridgeError } from '@nominalso/vibe-bridge'
47
+
46
48
  try {
47
49
  const entry = await bridge.getJournalEntry({
48
50
  path: { subsidiary_id: ctx.subsidiaryId, journal_entry_id: '42' },
49
51
  })
50
52
  } catch (err) {
53
+ if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
54
+ // back off and retry
55
+ }
51
56
  console.error('Fetch failed:', err instanceof Error ? err.message : err)
52
57
  }
53
58
  ```
@@ -25,6 +25,8 @@ Sandboxed cross-origin iframes cannot pass `File` handles across windows, so the
25
25
  ## Example — file input with a progress bar
26
26
 
27
27
  ```ts
28
+ import { BridgeError } from '@nominalso/vibe-bridge'
29
+
28
30
  const input = document.querySelector<HTMLInputElement>('#file-input')!
29
31
 
30
32
  input.addEventListener('change', async () => {
@@ -40,9 +42,14 @@ input.addEventListener('change', async () => {
40
42
  })
41
43
  console.log('Uploaded:', result.attachmentId, result.name)
42
44
  } catch (err) {
45
+ // A failed upload throws a BridgeError with a code, e.g. 'FILE_TOO_LARGE'
46
+ // (host limit is 50 MB), 'RATE_LIMITED', or 'TIMEOUT' (uploads allow 120 s).
47
+ if (err instanceof BridgeError && err.code === 'FILE_TOO_LARGE') {
48
+ alert('That file is too large (50 MB max).')
49
+ }
43
50
  console.error('Upload failed:', err instanceof Error ? err.message : err)
44
51
  }
45
52
  })
46
53
  ```
47
54
 
48
- `upload()` resolves only after the host has fully stored the file; progress ticks arrive before it resolves.
55
+ `upload()` resolves only after the host has fully stored the file; progress ticks arrive before it resolves. Import `BridgeError` from `@nominalso/vibe-bridge` to branch on `err.code`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",