@nominalso/vibe-bridge 0.1.0 → 0.3.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.1.0";
31
+ var version = "0.3.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,119 +59,50 @@ function isBridgeMessage(data) {
45
59
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
46
60
  return true;
47
61
  }
48
-
49
- // src/VibeAppBridge.ts
50
- var CONNECT_TIMEOUT_MS = 1e4;
51
- var CONNECT_POLL_MS = 500;
52
- var VibeAppBridge = class {
53
- constructor({ parentOrigin, requestTimeout = 1e4 }) {
54
- this.pendingRequests = /* @__PURE__ */ new Map();
55
- this.context = null;
56
- this.connectPromise = null;
57
- this.connectPollInterval = null;
58
- this.onContextReceived = null;
59
- this.lastReportedSubroute = null;
60
- this.suppressSubrouteReport = false;
61
- this.subrouteCallback = null;
62
- this.origPushState = null;
63
- this.origReplaceState = null;
64
- this.popstateHandler = null;
65
- this.kindHandlers = {
66
- response: (msg) => this.handleResponse(msg),
67
- push: (msg) => this.dispatchPush(msg),
68
- progress: (msg) => this.handleProgress(msg)
69
- };
70
- this.pushHandlers = {
71
- 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
- })
81
- };
82
- this.parentOrigin = parentOrigin;
83
- this.requestTimeout = requestTimeout;
84
- this.boundHandleMessage = this.handleMessage.bind(this);
85
- window.addEventListener("message", this.boundHandleMessage);
62
+ var BridgeError = class extends Error {
63
+ constructor(code, message) {
64
+ super(message);
65
+ this.code = code;
66
+ this.name = "BridgeError";
86
67
  }
87
- dispatchPush(msg) {
88
- const handler = this.pushHandlers[msg.type];
89
- handler(msg.payload);
68
+ };
69
+ var HttpBridgeError = class extends BridgeError {
70
+ constructor(status, message) {
71
+ super("REQUEST_FAILED", message);
72
+ this.status = status;
73
+ this.name = "HttpBridgeError";
90
74
  }
91
- /**
92
- * Connects to the host and returns the tenant/user {@link ContextPayload}.
93
- * **Call this once on app init and await it before any other method** — data
94
- * methods, `upload()`, and subroute reporting all require an established
95
- * connection. Concurrent calls return the same promise.
96
- *
97
- * Actively polls the host every 500ms until a response arrives, which
98
- * handles the race condition where the host's initial context push arrives
99
- * before this listener is ready. Rejects with `Bridge connect timed out`
100
- * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
101
- * or the host hasn't mounted).
102
- *
103
- * After connecting, if the context includes an initial subroute (e.g. from
104
- * a deep link), the bridge navigates to it and starts auto-tracking
105
- * internal navigation via the history API.
106
- *
107
- * @returns The tenant/user/subsidiary context for this app session.
108
- * @throws If no context is received within 10 seconds.
109
- * @example
110
- * ```ts
111
- * const ctx = await bridge.connect()
112
- * // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
113
- * ```
114
- */
115
- connect() {
116
- if (this.context) return Promise.resolve(this.context);
117
- if (this.connectPromise) return this.connectPromise;
118
- this.connectPromise = new Promise((resolve, reject) => {
119
- const timer = setTimeout(() => {
120
- this.stopConnectPolling();
121
- this.connectPromise = null;
122
- reject(new Error("Bridge connect timed out"));
123
- }, CONNECT_TIMEOUT_MS);
124
- this.onContextReceived = (ctx) => {
125
- clearTimeout(timer);
126
- this.stopConnectPolling();
127
- this.context = ctx;
128
- this.connectPromise = null;
129
- this.onContextReceived = null;
130
- console.log(
131
- `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
132
- );
133
- if (ctx.subroute && ctx.subroute !== "/") {
134
- this.withSubrouteSuppressed(() => {
135
- history.replaceState(null, "", ctx.subroute);
136
- window.dispatchEvent(new PopStateEvent("popstate"));
137
- this.lastReportedSubroute = ctx.subroute;
138
- });
139
- } else {
140
- this.lastReportedSubroute = window.location.pathname + window.location.search;
141
- }
142
- this.setupNavigationTracking();
143
- resolve(ctx);
144
- };
145
- const poll = () => {
146
- const message = {
147
- __protocol: PROTOCOL_ID,
148
- __version: PROTOCOL_VERSION,
149
- kind: "request",
150
- type: "GET_CONTEXT",
151
- requestId: crypto.randomUUID(),
152
- payload: { bridgeVersion: version }
153
- };
154
- window.parent.postMessage(message, this.parentOrigin);
155
- };
156
- poll();
157
- this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
158
- });
159
- return this.connectPromise;
75
+ };
76
+ function isOriginPattern(entry) {
77
+ return entry.includes("*");
78
+ }
79
+ var patternCache = /* @__PURE__ */ new Map();
80
+ function patternToRegExp(pattern) {
81
+ const cached = patternCache.get(pattern);
82
+ if (cached) return cached;
83
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
85
+ const compiled = new RegExp(`^${body}$`);
86
+ patternCache.set(pattern, compiled);
87
+ return compiled;
88
+ }
89
+ function matchesOrigin(origin, entry) {
90
+ if (!isOriginPattern(entry)) return origin === entry;
91
+ return patternToRegExp(entry).test(origin);
92
+ }
93
+ function isOriginAllowed(origin, allowlist, { allowPatterns }) {
94
+ for (const entry of allowlist) {
95
+ if (isOriginPattern(entry)) {
96
+ if (allowPatterns && matchesOrigin(origin, entry)) return true;
97
+ } else if (origin === entry) {
98
+ return true;
99
+ }
160
100
  }
101
+ return false;
102
+ }
103
+
104
+ // src/data-methods.ts
105
+ var BridgeDataMethods = class {
161
106
  // ---------------------------------------------------------------------------
162
107
  // Operations — Accounting: chart of accounts
163
108
  //
@@ -443,13 +388,268 @@ var VibeAppBridge = class {
443
388
  const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
444
389
  return this.request("UPLOAD_FILE", payload, onProgress);
445
390
  }
391
+ };
392
+
393
+ // src/timeouts.ts
394
+ var CONNECT_TIMEOUT_MS = 1e4;
395
+ var CONNECT_POLL_MS = 500;
396
+ var DEFAULT_TIMEOUTS = {
397
+ /** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
398
+ api: 3e4,
399
+ /** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
400
+ context: 5e3,
401
+ /** `INVALIDATE_CACHE`. */
402
+ invalidate: 1e4,
403
+ /** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
404
+ upload: 12e4
405
+ };
406
+ function resolveRequestTimeout(type, requestTimeout) {
407
+ if (requestTimeout !== void 0) return requestTimeout;
408
+ switch (type) {
409
+ case "UPLOAD_FILE":
410
+ return DEFAULT_TIMEOUTS.upload;
411
+ case "INVALIDATE_CACHE":
412
+ return DEFAULT_TIMEOUTS.invalidate;
413
+ case "GET_CONTEXT":
414
+ return DEFAULT_TIMEOUTS.context;
415
+ default:
416
+ return DEFAULT_TIMEOUTS.api;
417
+ }
418
+ }
419
+
420
+ // src/VibeAppBridge.ts
421
+ var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
422
+ var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
423
+ function isPreviewSelfHost(hostname) {
424
+ return PREVIEW_HOST_RE.test(hostname);
425
+ }
426
+ function resolveActualParentOrigin() {
427
+ if (typeof window === "undefined") return null;
428
+ try {
429
+ const ancestors = window.location.ancestorOrigins;
430
+ if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
431
+ } catch {
432
+ }
433
+ try {
434
+ if (document.referrer) return new URL(document.referrer).origin;
435
+ } catch {
436
+ }
437
+ return null;
438
+ }
439
+ function resolveOriginPolicy(configured) {
440
+ if (typeof window !== "undefined") {
441
+ const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
442
+ if (typeof override === "string" && override.length > 0) {
443
+ console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
444
+ return { targetOrigin: override, isTrusted: (o) => o === override };
445
+ }
446
+ }
447
+ const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
448
+ const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
449
+ if (allowlist.length === 0) {
450
+ const actual2 = resolveActualParentOrigin();
451
+ if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
452
+ console.warn(
453
+ "[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
454
+ );
455
+ return { targetOrigin: null, isTrusted: () => false };
456
+ }
457
+ const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
458
+ const hasPatterns = exactEntries.length !== allowlist.length;
459
+ if (allowlist.length === 1 && !hasPatterns) {
460
+ const only = allowlist[0];
461
+ return { targetOrigin: only, isTrusted: (o) => o === only };
462
+ }
463
+ const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
464
+ const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
465
+ const actual = resolveActualParentOrigin();
466
+ let targetOrigin = null;
467
+ if (actual && isTrusted(actual)) {
468
+ targetOrigin = actual;
469
+ } else if (actual === null && !allowPatterns && exactEntries.length === 1) {
470
+ targetOrigin = exactEntries[0];
471
+ }
472
+ if (targetOrigin === null) {
473
+ console.warn(
474
+ "[vibe-bridge] Could not resolve a parent origin to post to. Check `parentOrigin` against the embedding host" + (hasPatterns && !allowPatterns ? " \u2014 pattern entries are ignored because this page is not served from a recognised preview host." : ".")
475
+ );
476
+ }
477
+ return { targetOrigin, isTrusted };
478
+ }
479
+ var VibeAppBridge = class extends BridgeDataMethods {
480
+ constructor({ parentOrigin, requestTimeout } = {}) {
481
+ super();
482
+ this.pendingRequests = /* @__PURE__ */ new Map();
483
+ this.context = null;
484
+ this.connectPromise = null;
485
+ this.connectPollInterval = null;
486
+ this.connectTimeout = null;
487
+ this.onContextReceived = null;
488
+ this.onContextError = null;
489
+ /**
490
+ * Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
491
+ * late response from a *previous* connect attempt, which would otherwise
492
+ * resolve/reject the new one (a stale error could reject a healthy reconnect).
493
+ */
494
+ this.connectPollIds = /* @__PURE__ */ new Set();
495
+ this.lastReportedSubroute = null;
496
+ this.suppressSubrouteReport = false;
497
+ this.subrouteCallback = null;
498
+ this.origPushState = null;
499
+ this.origReplaceState = null;
500
+ this.popstateHandler = null;
501
+ this.kindHandlers = {
502
+ response: (msg) => this.handleResponse(msg),
503
+ push: (msg) => this.dispatchPush(msg),
504
+ progress: (msg) => this.handleProgress(msg)
505
+ };
506
+ this.pushHandlers = {
507
+ CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
508
+ SUBROUTE_PUSH: (payload) => {
509
+ const safe = normalizeSubroute(payload.subroute);
510
+ if (!safe) return;
511
+ this.withSubrouteSuppressed(() => {
512
+ if (this.subrouteCallback) {
513
+ this.subrouteCallback(safe);
514
+ } else {
515
+ history.pushState(null, "", safe);
516
+ window.dispatchEvent(new PopStateEvent("popstate"));
517
+ }
518
+ this.lastReportedSubroute = safe;
519
+ });
520
+ }
521
+ };
522
+ /**
523
+ * Commands targeting the host's UI chrome. All fire-and-forget.
524
+ *
525
+ * @example
526
+ * ```ts
527
+ * bridge.ui.setSideNav({ collapsed: true })
528
+ * bridge.ui.switchSubsidiary(42)
529
+ * ```
530
+ */
531
+ this.ui = {
532
+ /** Collapse or expand the host's side navigation. */
533
+ setSideNav: (payload) => {
534
+ this.sendCommand("SET_SIDE_NAV", payload);
535
+ },
536
+ /** Switch the host to a different subsidiary. */
537
+ switchSubsidiary: (subsidiaryId) => {
538
+ this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
539
+ }
540
+ };
541
+ const policy = resolveOriginPolicy(parentOrigin);
542
+ this.targetOrigin = policy.targetOrigin;
543
+ this.isTrustedOrigin = policy.isTrusted;
544
+ this.requestTimeout = requestTimeout;
545
+ this.boundHandleMessage = this.handleMessage.bind(this);
546
+ window.addEventListener("message", this.boundHandleMessage);
547
+ }
548
+ dispatchPush(msg) {
549
+ const handler = this.pushHandlers[msg.type];
550
+ handler(msg.payload);
551
+ }
552
+ /**
553
+ * Connects to the host and returns the tenant/user {@link ContextPayload}.
554
+ * **Call this once on app init and await it before any other method** — data
555
+ * methods, `upload()`, and subroute reporting all require an established
556
+ * connection. Concurrent calls return the same promise.
557
+ *
558
+ * Actively polls the host every 500ms until a response arrives, which
559
+ * handles the race condition where the host's initial context push arrives
560
+ * before this listener is ready. Rejects with `Bridge connect timed out`
561
+ * after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
562
+ * or the host hasn't mounted).
563
+ *
564
+ * After connecting, if the context includes an initial subroute (e.g. from
565
+ * a deep link), the bridge navigates to it and starts auto-tracking
566
+ * internal navigation via the history API.
567
+ *
568
+ * @returns The tenant/user/subsidiary context for this app session.
569
+ * @throws If no context is received within 10 seconds.
570
+ * @example
571
+ * ```ts
572
+ * const ctx = await bridge.connect()
573
+ * // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
574
+ * ```
575
+ */
576
+ connect() {
577
+ if (this.context) return Promise.resolve(this.context);
578
+ if (this.connectPromise) return this.connectPromise;
579
+ if (this.targetOrigin === null) {
580
+ return Promise.reject(
581
+ new BridgeError(
582
+ "PARENT_ORIGIN_UNRESOLVED",
583
+ "Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
584
+ )
585
+ );
586
+ }
587
+ this.connectPromise = new Promise((resolve, reject) => {
588
+ const timer = setTimeout(() => {
589
+ this.stopConnectPolling();
590
+ this.connectPromise = null;
591
+ this.onContextReceived = null;
592
+ this.onContextError = null;
593
+ reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
594
+ }, CONNECT_TIMEOUT_MS);
595
+ this.connectTimeout = timer;
596
+ this.onContextError = (error) => {
597
+ clearTimeout(timer);
598
+ this.stopConnectPolling();
599
+ this.connectPromise = null;
600
+ this.onContextReceived = null;
601
+ this.onContextError = null;
602
+ reject(error);
603
+ };
604
+ this.onContextReceived = (ctx) => {
605
+ clearTimeout(timer);
606
+ this.stopConnectPolling();
607
+ this.context = ctx;
608
+ this.connectPromise = null;
609
+ this.onContextReceived = null;
610
+ this.onContextError = null;
611
+ console.log(
612
+ `[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
613
+ );
614
+ const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
615
+ if (initialSubroute && initialSubroute !== "/") {
616
+ this.withSubrouteSuppressed(() => {
617
+ history.replaceState(null, "", initialSubroute);
618
+ window.dispatchEvent(new PopStateEvent("popstate"));
619
+ this.lastReportedSubroute = initialSubroute;
620
+ });
621
+ } else {
622
+ this.lastReportedSubroute = window.location.pathname + window.location.search;
623
+ }
624
+ this.setupNavigationTracking();
625
+ resolve(ctx);
626
+ };
627
+ const poll = () => {
628
+ const requestId = crypto.randomUUID();
629
+ this.connectPollIds.add(requestId);
630
+ const message = {
631
+ __protocol: PROTOCOL_ID,
632
+ __version: PROTOCOL_VERSION,
633
+ kind: "request",
634
+ type: "GET_CONTEXT",
635
+ requestId,
636
+ payload: { bridgeVersion: version }
637
+ };
638
+ this.postToParent(message);
639
+ };
640
+ poll();
641
+ this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
642
+ });
643
+ return this.connectPromise;
644
+ }
446
645
  /**
447
646
  * Manually reports the current subroute to the host.
448
647
  * Usually not needed — navigation is auto-detected via the history API.
449
648
  * Use this for hash-based routers or non-standard navigation patterns.
450
649
  */
451
650
  reportSubroute(subroute, options) {
452
- const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
651
+ const normalized = normalizeSubroute(subroute);
652
+ if (normalized === null) return;
453
653
  this.lastReportedSubroute = normalized;
454
654
  this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
455
655
  }
@@ -465,17 +665,69 @@ var VibeAppBridge = class {
465
665
  this.subrouteCallback = null;
466
666
  };
467
667
  }
668
+ // ---------------------------------------------------------------------------
669
+ // Host navigation & UI commands
670
+ //
671
+ // These drive the *host* (nom-ui) chrome — its router, side nav, and
672
+ // subsidiary switcher — rather than the iframe's own internal routing
673
+ // (`reportSubroute`). Navigation/UI commands are fire-and-forget;
674
+ // `invalidateCache` is awaited.
675
+ // ---------------------------------------------------------------------------
676
+ /**
677
+ * Navigates the host to a raw path relative to this app's tenant/subsidiary
678
+ * base. Fire-and-forget.
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * bridge.navigate('/journal-entries/123')
683
+ * bridge.navigate('/journal-entries/123', 'replace')
684
+ * ```
685
+ */
686
+ navigate(path, action = "push") {
687
+ this.sendCommand("NAVIGATE", { path, action });
688
+ }
689
+ /**
690
+ * Navigates the host to a named Nominal route, with optional params for
691
+ * placeholder segments. Fire-and-forget.
692
+ *
693
+ * @example
694
+ * ```ts
695
+ * bridge.navigateTo('CHART_OF_ACCOUNTS')
696
+ * bridge.navigateTo('JOURNAL_ENTRY', { id: '123' }, 'replace')
697
+ * ```
698
+ */
699
+ navigateTo(route, routeParams, action = "push") {
700
+ this.sendCommand("NAVIGATE", { route, routeParams, action });
701
+ }
702
+ /** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
703
+ refresh() {
704
+ this.sendCommand("NAVIGATE", { action: "refresh" });
705
+ }
706
+ /**
707
+ * Asks the host to invalidate cached data by path prefix and/or cache tag so
708
+ * subsequent reads see fresh values. Awaited.
709
+ *
710
+ * @example
711
+ * ```ts
712
+ * await bridge.invalidateCache({ tags: ['SUBSIDIARIES'], paths: ['/accounting/accounts'] })
713
+ * ```
714
+ */
715
+ invalidateCache(payload) {
716
+ return this.request("INVALIDATE_CACHE", payload);
717
+ }
468
718
  destroy() {
469
719
  this.teardownNavigationTracking();
720
+ this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
470
721
  this.stopConnectPolling();
471
722
  window.removeEventListener("message", this.boundHandleMessage);
472
723
  for (const pending of this.pendingRequests.values()) {
473
- pending.reject(new Error("Bridge destroyed"));
724
+ pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
474
725
  }
475
726
  this.pendingRequests.clear();
476
727
  this.context = null;
477
728
  this.connectPromise = null;
478
729
  this.onContextReceived = null;
730
+ this.onContextError = null;
479
731
  this.subrouteCallback = null;
480
732
  this.lastReportedSubroute = null;
481
733
  }
@@ -484,6 +736,11 @@ var VibeAppBridge = class {
484
736
  clearInterval(this.connectPollInterval);
485
737
  this.connectPollInterval = null;
486
738
  }
739
+ if (this.connectTimeout !== null) {
740
+ clearTimeout(this.connectTimeout);
741
+ this.connectTimeout = null;
742
+ }
743
+ this.connectPollIds.clear();
487
744
  }
488
745
  /**
489
746
  * Monkey-patches `history.pushState` and `history.replaceState` to
@@ -532,16 +789,20 @@ var VibeAppBridge = class {
532
789
  }
533
790
  reportCurrentSubroute(replace) {
534
791
  if (this.suppressSubrouteReport) return;
535
- const subroute = window.location.pathname + window.location.search;
792
+ const subroute = normalizeSubroute(window.location.pathname + window.location.search);
793
+ if (subroute === null) return;
536
794
  if (subroute === this.lastReportedSubroute) return;
537
795
  this.lastReportedSubroute = subroute;
538
796
  this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
539
797
  }
540
798
  sendCommand(type, payload) {
541
- window.parent.postMessage(
542
- { __protocol: PROTOCOL_ID, __version: PROTOCOL_VERSION, kind: "command", type, payload },
543
- this.parentOrigin
544
- );
799
+ this.postToParent({
800
+ __protocol: PROTOCOL_ID,
801
+ __version: PROTOCOL_VERSION,
802
+ kind: "command",
803
+ type,
804
+ payload
805
+ });
545
806
  }
546
807
  /**
547
808
  * Low-level typed request for **any** operation in {@link RequestRegistry}.
@@ -558,10 +819,13 @@ var VibeAppBridge = class {
558
819
  request(type, payload, onProgress) {
559
820
  return new Promise((resolve, reject) => {
560
821
  const requestId = crypto.randomUUID();
561
- const timer = setTimeout(() => {
562
- this.pendingRequests.delete(requestId);
563
- reject(new Error(`Vibe bridge request timed out: ${type}`));
564
- }, this.requestTimeout);
822
+ const timer = setTimeout(
823
+ () => {
824
+ this.pendingRequests.delete(requestId);
825
+ reject(new BridgeError("TIMEOUT", `Vibe bridge request timed out: ${type}`));
826
+ },
827
+ resolveRequestTimeout(type, this.requestTimeout)
828
+ );
565
829
  this.pendingRequests.set(requestId, {
566
830
  resolve: (data) => {
567
831
  clearTimeout(timer);
@@ -581,17 +845,31 @@ var VibeAppBridge = class {
581
845
  requestId,
582
846
  payload
583
847
  };
584
- window.parent.postMessage(message, this.parentOrigin);
848
+ this.postToParent(message);
585
849
  });
586
850
  }
851
+ /**
852
+ * Posts to the embedding host. No-op when no concrete target origin could be
853
+ * resolved (a config/embedding error) — `connect()` rejects fast in that case
854
+ * so calls never silently hang waiting on a timeout.
855
+ */
856
+ postToParent(message) {
857
+ if (this.targetOrigin === null) return;
858
+ window.parent.postMessage(message, this.targetOrigin);
859
+ }
587
860
  handleMessage(event) {
588
- if (event.origin !== this.parentOrigin) return;
861
+ if (!this.isTrustedOrigin(event.origin)) return;
589
862
  if (!isBridgeMessage(event.data)) return;
590
863
  this.kindHandlers[event.data.kind]?.(event.data);
591
864
  }
592
865
  handleResponse(msg) {
593
866
  if (msg.type === "GET_CONTEXT") {
594
- if (msg.ok) this.onContextReceived?.(msg.data);
867
+ if (!this.connectPollIds.has(msg.requestId)) return;
868
+ if (msg.ok) {
869
+ this.onContextReceived?.(msg.data);
870
+ } else {
871
+ this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
872
+ }
595
873
  return;
596
874
  }
597
875
  const pending = this.pendingRequests.get(msg.requestId);
@@ -599,6 +877,10 @@ var VibeAppBridge = class {
599
877
  this.pendingRequests.delete(msg.requestId);
600
878
  if (msg.ok) {
601
879
  pending.resolve(msg.data);
880
+ } else if (msg.status !== void 0) {
881
+ pending.reject(new HttpBridgeError(msg.status, msg.error));
882
+ } else if (msg.code) {
883
+ pending.reject(new BridgeError(msg.code, msg.error));
602
884
  } else {
603
885
  pending.reject(new Error(msg.error));
604
886
  }
@@ -611,5 +893,7 @@ var VibeAppBridge = class {
611
893
  // Annotate the CommonJS export names for ESM import in node:
612
894
  0 && (module.exports = {
613
895
  BRIDGE_VERSION,
896
+ BridgeError,
897
+ HttpBridgeError,
614
898
  VibeAppBridge
615
899
  });