@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/AGENTS.md +9 -8
- package/README.md +60 -20
- package/dist/index.cjs +409 -125
- package/dist/index.d.cts +265 -85
- package/dist/index.d.ts +265 -85
- package/dist/index.js +407 -125
- package/docs/connect-lifecycle.md +3 -3
- package/docs/data-fetching.md +7 -2
- package/docs/file-upload.md +8 -1
- package/docs/getting-started.md +12 -10
- package/llms.txt +2 -2
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
});
|