@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.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
// package.json
|
|
2
|
-
var version = "0.
|
|
2
|
+
var version = "0.3.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,50 @@ 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
39
|
+
};
|
|
40
|
+
var HttpBridgeError = class extends BridgeError {
|
|
41
|
+
constructor(status, message) {
|
|
42
|
+
super("REQUEST_FAILED", message);
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.name = "HttpBridgeError";
|
|
63
45
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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;
|
|
46
|
+
};
|
|
47
|
+
function isOriginPattern(entry) {
|
|
48
|
+
return entry.includes("*");
|
|
49
|
+
}
|
|
50
|
+
var patternCache = /* @__PURE__ */ new Map();
|
|
51
|
+
function patternToRegExp(pattern) {
|
|
52
|
+
const cached = patternCache.get(pattern);
|
|
53
|
+
if (cached) return cached;
|
|
54
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
|
|
56
|
+
const compiled = new RegExp(`^${body}$`);
|
|
57
|
+
patternCache.set(pattern, compiled);
|
|
58
|
+
return compiled;
|
|
59
|
+
}
|
|
60
|
+
function matchesOrigin(origin, entry) {
|
|
61
|
+
if (!isOriginPattern(entry)) return origin === entry;
|
|
62
|
+
return patternToRegExp(entry).test(origin);
|
|
63
|
+
}
|
|
64
|
+
function isOriginAllowed(origin, allowlist, { allowPatterns }) {
|
|
65
|
+
for (const entry of allowlist) {
|
|
66
|
+
if (isOriginPattern(entry)) {
|
|
67
|
+
if (allowPatterns && matchesOrigin(origin, entry)) return true;
|
|
68
|
+
} else if (origin === entry) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
133
71
|
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/data-methods.ts
|
|
76
|
+
var BridgeDataMethods = class {
|
|
134
77
|
// ---------------------------------------------------------------------------
|
|
135
78
|
// Operations — Accounting: chart of accounts
|
|
136
79
|
//
|
|
@@ -416,13 +359,268 @@ var VibeAppBridge = class {
|
|
|
416
359
|
const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
|
|
417
360
|
return this.request("UPLOAD_FILE", payload, onProgress);
|
|
418
361
|
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// src/timeouts.ts
|
|
365
|
+
var CONNECT_TIMEOUT_MS = 1e4;
|
|
366
|
+
var CONNECT_POLL_MS = 500;
|
|
367
|
+
var DEFAULT_TIMEOUTS = {
|
|
368
|
+
/** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
|
|
369
|
+
api: 3e4,
|
|
370
|
+
/** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
|
|
371
|
+
context: 5e3,
|
|
372
|
+
/** `INVALIDATE_CACHE`. */
|
|
373
|
+
invalidate: 1e4,
|
|
374
|
+
/** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
|
|
375
|
+
upload: 12e4
|
|
376
|
+
};
|
|
377
|
+
function resolveRequestTimeout(type, requestTimeout) {
|
|
378
|
+
if (requestTimeout !== void 0) return requestTimeout;
|
|
379
|
+
switch (type) {
|
|
380
|
+
case "UPLOAD_FILE":
|
|
381
|
+
return DEFAULT_TIMEOUTS.upload;
|
|
382
|
+
case "INVALIDATE_CACHE":
|
|
383
|
+
return DEFAULT_TIMEOUTS.invalidate;
|
|
384
|
+
case "GET_CONTEXT":
|
|
385
|
+
return DEFAULT_TIMEOUTS.context;
|
|
386
|
+
default:
|
|
387
|
+
return DEFAULT_TIMEOUTS.api;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/VibeAppBridge.ts
|
|
392
|
+
var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
|
|
393
|
+
var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
|
|
394
|
+
function isPreviewSelfHost(hostname) {
|
|
395
|
+
return PREVIEW_HOST_RE.test(hostname);
|
|
396
|
+
}
|
|
397
|
+
function resolveActualParentOrigin() {
|
|
398
|
+
if (typeof window === "undefined") return null;
|
|
399
|
+
try {
|
|
400
|
+
const ancestors = window.location.ancestorOrigins;
|
|
401
|
+
if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
if (document.referrer) return new URL(document.referrer).origin;
|
|
406
|
+
} catch {
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function resolveOriginPolicy(configured) {
|
|
411
|
+
if (typeof window !== "undefined") {
|
|
412
|
+
const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
|
|
413
|
+
if (typeof override === "string" && override.length > 0) {
|
|
414
|
+
console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
|
|
415
|
+
return { targetOrigin: override, isTrusted: (o) => o === override };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
|
|
419
|
+
const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
420
|
+
if (allowlist.length === 0) {
|
|
421
|
+
const actual2 = resolveActualParentOrigin();
|
|
422
|
+
if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
|
|
423
|
+
console.warn(
|
|
424
|
+
"[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
|
|
425
|
+
);
|
|
426
|
+
return { targetOrigin: null, isTrusted: () => false };
|
|
427
|
+
}
|
|
428
|
+
const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
|
|
429
|
+
const hasPatterns = exactEntries.length !== allowlist.length;
|
|
430
|
+
if (allowlist.length === 1 && !hasPatterns) {
|
|
431
|
+
const only = allowlist[0];
|
|
432
|
+
return { targetOrigin: only, isTrusted: (o) => o === only };
|
|
433
|
+
}
|
|
434
|
+
const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
|
|
435
|
+
const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
|
|
436
|
+
const actual = resolveActualParentOrigin();
|
|
437
|
+
let targetOrigin = null;
|
|
438
|
+
if (actual && isTrusted(actual)) {
|
|
439
|
+
targetOrigin = actual;
|
|
440
|
+
} else if (actual === null && !allowPatterns && exactEntries.length === 1) {
|
|
441
|
+
targetOrigin = exactEntries[0];
|
|
442
|
+
}
|
|
443
|
+
if (targetOrigin === null) {
|
|
444
|
+
console.warn(
|
|
445
|
+
"[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." : ".")
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return { targetOrigin, isTrusted };
|
|
449
|
+
}
|
|
450
|
+
var VibeAppBridge = class extends BridgeDataMethods {
|
|
451
|
+
constructor({ parentOrigin, requestTimeout } = {}) {
|
|
452
|
+
super();
|
|
453
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
454
|
+
this.context = null;
|
|
455
|
+
this.connectPromise = null;
|
|
456
|
+
this.connectPollInterval = null;
|
|
457
|
+
this.connectTimeout = null;
|
|
458
|
+
this.onContextReceived = null;
|
|
459
|
+
this.onContextError = null;
|
|
460
|
+
/**
|
|
461
|
+
* Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
|
|
462
|
+
* late response from a *previous* connect attempt, which would otherwise
|
|
463
|
+
* resolve/reject the new one (a stale error could reject a healthy reconnect).
|
|
464
|
+
*/
|
|
465
|
+
this.connectPollIds = /* @__PURE__ */ new Set();
|
|
466
|
+
this.lastReportedSubroute = null;
|
|
467
|
+
this.suppressSubrouteReport = false;
|
|
468
|
+
this.subrouteCallback = null;
|
|
469
|
+
this.origPushState = null;
|
|
470
|
+
this.origReplaceState = null;
|
|
471
|
+
this.popstateHandler = null;
|
|
472
|
+
this.kindHandlers = {
|
|
473
|
+
response: (msg) => this.handleResponse(msg),
|
|
474
|
+
push: (msg) => this.dispatchPush(msg),
|
|
475
|
+
progress: (msg) => this.handleProgress(msg)
|
|
476
|
+
};
|
|
477
|
+
this.pushHandlers = {
|
|
478
|
+
CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
|
|
479
|
+
SUBROUTE_PUSH: (payload) => {
|
|
480
|
+
const safe = normalizeSubroute(payload.subroute);
|
|
481
|
+
if (!safe) return;
|
|
482
|
+
this.withSubrouteSuppressed(() => {
|
|
483
|
+
if (this.subrouteCallback) {
|
|
484
|
+
this.subrouteCallback(safe);
|
|
485
|
+
} else {
|
|
486
|
+
history.pushState(null, "", safe);
|
|
487
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
488
|
+
}
|
|
489
|
+
this.lastReportedSubroute = safe;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
/**
|
|
494
|
+
* Commands targeting the host's UI chrome. All fire-and-forget.
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* ```ts
|
|
498
|
+
* bridge.ui.setSideNav({ collapsed: true })
|
|
499
|
+
* bridge.ui.switchSubsidiary(42)
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
this.ui = {
|
|
503
|
+
/** Collapse or expand the host's side navigation. */
|
|
504
|
+
setSideNav: (payload) => {
|
|
505
|
+
this.sendCommand("SET_SIDE_NAV", payload);
|
|
506
|
+
},
|
|
507
|
+
/** Switch the host to a different subsidiary. */
|
|
508
|
+
switchSubsidiary: (subsidiaryId) => {
|
|
509
|
+
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const policy = resolveOriginPolicy(parentOrigin);
|
|
513
|
+
this.targetOrigin = policy.targetOrigin;
|
|
514
|
+
this.isTrustedOrigin = policy.isTrusted;
|
|
515
|
+
this.requestTimeout = requestTimeout;
|
|
516
|
+
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
517
|
+
window.addEventListener("message", this.boundHandleMessage);
|
|
518
|
+
}
|
|
519
|
+
dispatchPush(msg) {
|
|
520
|
+
const handler = this.pushHandlers[msg.type];
|
|
521
|
+
handler(msg.payload);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Connects to the host and returns the tenant/user {@link ContextPayload}.
|
|
525
|
+
* **Call this once on app init and await it before any other method** — data
|
|
526
|
+
* methods, `upload()`, and subroute reporting all require an established
|
|
527
|
+
* connection. Concurrent calls return the same promise.
|
|
528
|
+
*
|
|
529
|
+
* Actively polls the host every 500ms until a response arrives, which
|
|
530
|
+
* handles the race condition where the host's initial context push arrives
|
|
531
|
+
* before this listener is ready. Rejects with `Bridge connect timed out`
|
|
532
|
+
* after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
|
|
533
|
+
* or the host hasn't mounted).
|
|
534
|
+
*
|
|
535
|
+
* After connecting, if the context includes an initial subroute (e.g. from
|
|
536
|
+
* a deep link), the bridge navigates to it and starts auto-tracking
|
|
537
|
+
* internal navigation via the history API.
|
|
538
|
+
*
|
|
539
|
+
* @returns The tenant/user/subsidiary context for this app session.
|
|
540
|
+
* @throws If no context is received within 10 seconds.
|
|
541
|
+
* @example
|
|
542
|
+
* ```ts
|
|
543
|
+
* const ctx = await bridge.connect()
|
|
544
|
+
* // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
connect() {
|
|
548
|
+
if (this.context) return Promise.resolve(this.context);
|
|
549
|
+
if (this.connectPromise) return this.connectPromise;
|
|
550
|
+
if (this.targetOrigin === null) {
|
|
551
|
+
return Promise.reject(
|
|
552
|
+
new BridgeError(
|
|
553
|
+
"PARENT_ORIGIN_UNRESOLVED",
|
|
554
|
+
"Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
|
|
555
|
+
)
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
559
|
+
const timer = setTimeout(() => {
|
|
560
|
+
this.stopConnectPolling();
|
|
561
|
+
this.connectPromise = null;
|
|
562
|
+
this.onContextReceived = null;
|
|
563
|
+
this.onContextError = null;
|
|
564
|
+
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
565
|
+
}, CONNECT_TIMEOUT_MS);
|
|
566
|
+
this.connectTimeout = timer;
|
|
567
|
+
this.onContextError = (error) => {
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
this.stopConnectPolling();
|
|
570
|
+
this.connectPromise = null;
|
|
571
|
+
this.onContextReceived = null;
|
|
572
|
+
this.onContextError = null;
|
|
573
|
+
reject(error);
|
|
574
|
+
};
|
|
575
|
+
this.onContextReceived = (ctx) => {
|
|
576
|
+
clearTimeout(timer);
|
|
577
|
+
this.stopConnectPolling();
|
|
578
|
+
this.context = ctx;
|
|
579
|
+
this.connectPromise = null;
|
|
580
|
+
this.onContextReceived = null;
|
|
581
|
+
this.onContextError = null;
|
|
582
|
+
console.log(
|
|
583
|
+
`[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
|
|
584
|
+
);
|
|
585
|
+
const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
|
|
586
|
+
if (initialSubroute && initialSubroute !== "/") {
|
|
587
|
+
this.withSubrouteSuppressed(() => {
|
|
588
|
+
history.replaceState(null, "", initialSubroute);
|
|
589
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
590
|
+
this.lastReportedSubroute = initialSubroute;
|
|
591
|
+
});
|
|
592
|
+
} else {
|
|
593
|
+
this.lastReportedSubroute = window.location.pathname + window.location.search;
|
|
594
|
+
}
|
|
595
|
+
this.setupNavigationTracking();
|
|
596
|
+
resolve(ctx);
|
|
597
|
+
};
|
|
598
|
+
const poll = () => {
|
|
599
|
+
const requestId = crypto.randomUUID();
|
|
600
|
+
this.connectPollIds.add(requestId);
|
|
601
|
+
const message = {
|
|
602
|
+
__protocol: PROTOCOL_ID,
|
|
603
|
+
__version: PROTOCOL_VERSION,
|
|
604
|
+
kind: "request",
|
|
605
|
+
type: "GET_CONTEXT",
|
|
606
|
+
requestId,
|
|
607
|
+
payload: { bridgeVersion: version }
|
|
608
|
+
};
|
|
609
|
+
this.postToParent(message);
|
|
610
|
+
};
|
|
611
|
+
poll();
|
|
612
|
+
this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
|
|
613
|
+
});
|
|
614
|
+
return this.connectPromise;
|
|
615
|
+
}
|
|
419
616
|
/**
|
|
420
617
|
* Manually reports the current subroute to the host.
|
|
421
618
|
* Usually not needed — navigation is auto-detected via the history API.
|
|
422
619
|
* Use this for hash-based routers or non-standard navigation patterns.
|
|
423
620
|
*/
|
|
424
621
|
reportSubroute(subroute, options) {
|
|
425
|
-
const normalized = subroute
|
|
622
|
+
const normalized = normalizeSubroute(subroute);
|
|
623
|
+
if (normalized === null) return;
|
|
426
624
|
this.lastReportedSubroute = normalized;
|
|
427
625
|
this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
|
|
428
626
|
}
|
|
@@ -438,17 +636,69 @@ var VibeAppBridge = class {
|
|
|
438
636
|
this.subrouteCallback = null;
|
|
439
637
|
};
|
|
440
638
|
}
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// Host navigation & UI commands
|
|
641
|
+
//
|
|
642
|
+
// These drive the *host* (nom-ui) chrome — its router, side nav, and
|
|
643
|
+
// subsidiary switcher — rather than the iframe's own internal routing
|
|
644
|
+
// (`reportSubroute`). Navigation/UI commands are fire-and-forget;
|
|
645
|
+
// `invalidateCache` is awaited.
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
/**
|
|
648
|
+
* Navigates the host to a raw path relative to this app's tenant/subsidiary
|
|
649
|
+
* base. Fire-and-forget.
|
|
650
|
+
*
|
|
651
|
+
* @example
|
|
652
|
+
* ```ts
|
|
653
|
+
* bridge.navigate('/journal-entries/123')
|
|
654
|
+
* bridge.navigate('/journal-entries/123', 'replace')
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
navigate(path, action = "push") {
|
|
658
|
+
this.sendCommand("NAVIGATE", { path, action });
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Navigates the host to a named Nominal route, with optional params for
|
|
662
|
+
* placeholder segments. Fire-and-forget.
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* ```ts
|
|
666
|
+
* bridge.navigateTo('CHART_OF_ACCOUNTS')
|
|
667
|
+
* bridge.navigateTo('JOURNAL_ENTRY', { id: '123' }, 'replace')
|
|
668
|
+
* ```
|
|
669
|
+
*/
|
|
670
|
+
navigateTo(route, routeParams, action = "push") {
|
|
671
|
+
this.sendCommand("NAVIGATE", { route, routeParams, action });
|
|
672
|
+
}
|
|
673
|
+
/** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
|
|
674
|
+
refresh() {
|
|
675
|
+
this.sendCommand("NAVIGATE", { action: "refresh" });
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Asks the host to invalidate cached data by path prefix and/or cache tag so
|
|
679
|
+
* subsequent reads see fresh values. Awaited.
|
|
680
|
+
*
|
|
681
|
+
* @example
|
|
682
|
+
* ```ts
|
|
683
|
+
* await bridge.invalidateCache({ tags: ['SUBSIDIARIES'], paths: ['/accounting/accounts'] })
|
|
684
|
+
* ```
|
|
685
|
+
*/
|
|
686
|
+
invalidateCache(payload) {
|
|
687
|
+
return this.request("INVALIDATE_CACHE", payload);
|
|
688
|
+
}
|
|
441
689
|
destroy() {
|
|
442
690
|
this.teardownNavigationTracking();
|
|
691
|
+
this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
443
692
|
this.stopConnectPolling();
|
|
444
693
|
window.removeEventListener("message", this.boundHandleMessage);
|
|
445
694
|
for (const pending of this.pendingRequests.values()) {
|
|
446
|
-
pending.reject(new
|
|
695
|
+
pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
447
696
|
}
|
|
448
697
|
this.pendingRequests.clear();
|
|
449
698
|
this.context = null;
|
|
450
699
|
this.connectPromise = null;
|
|
451
700
|
this.onContextReceived = null;
|
|
701
|
+
this.onContextError = null;
|
|
452
702
|
this.subrouteCallback = null;
|
|
453
703
|
this.lastReportedSubroute = null;
|
|
454
704
|
}
|
|
@@ -457,6 +707,11 @@ var VibeAppBridge = class {
|
|
|
457
707
|
clearInterval(this.connectPollInterval);
|
|
458
708
|
this.connectPollInterval = null;
|
|
459
709
|
}
|
|
710
|
+
if (this.connectTimeout !== null) {
|
|
711
|
+
clearTimeout(this.connectTimeout);
|
|
712
|
+
this.connectTimeout = null;
|
|
713
|
+
}
|
|
714
|
+
this.connectPollIds.clear();
|
|
460
715
|
}
|
|
461
716
|
/**
|
|
462
717
|
* Monkey-patches `history.pushState` and `history.replaceState` to
|
|
@@ -505,16 +760,20 @@ var VibeAppBridge = class {
|
|
|
505
760
|
}
|
|
506
761
|
reportCurrentSubroute(replace) {
|
|
507
762
|
if (this.suppressSubrouteReport) return;
|
|
508
|
-
const subroute = window.location.pathname + window.location.search;
|
|
763
|
+
const subroute = normalizeSubroute(window.location.pathname + window.location.search);
|
|
764
|
+
if (subroute === null) return;
|
|
509
765
|
if (subroute === this.lastReportedSubroute) return;
|
|
510
766
|
this.lastReportedSubroute = subroute;
|
|
511
767
|
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
512
768
|
}
|
|
513
769
|
sendCommand(type, payload) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
770
|
+
this.postToParent({
|
|
771
|
+
__protocol: PROTOCOL_ID,
|
|
772
|
+
__version: PROTOCOL_VERSION,
|
|
773
|
+
kind: "command",
|
|
774
|
+
type,
|
|
775
|
+
payload
|
|
776
|
+
});
|
|
518
777
|
}
|
|
519
778
|
/**
|
|
520
779
|
* Low-level typed request for **any** operation in {@link RequestRegistry}.
|
|
@@ -531,10 +790,13 @@ var VibeAppBridge = class {
|
|
|
531
790
|
request(type, payload, onProgress) {
|
|
532
791
|
return new Promise((resolve, reject) => {
|
|
533
792
|
const requestId = crypto.randomUUID();
|
|
534
|
-
const timer = setTimeout(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
793
|
+
const timer = setTimeout(
|
|
794
|
+
() => {
|
|
795
|
+
this.pendingRequests.delete(requestId);
|
|
796
|
+
reject(new BridgeError("TIMEOUT", `Vibe bridge request timed out: ${type}`));
|
|
797
|
+
},
|
|
798
|
+
resolveRequestTimeout(type, this.requestTimeout)
|
|
799
|
+
);
|
|
538
800
|
this.pendingRequests.set(requestId, {
|
|
539
801
|
resolve: (data) => {
|
|
540
802
|
clearTimeout(timer);
|
|
@@ -554,17 +816,31 @@ var VibeAppBridge = class {
|
|
|
554
816
|
requestId,
|
|
555
817
|
payload
|
|
556
818
|
};
|
|
557
|
-
|
|
819
|
+
this.postToParent(message);
|
|
558
820
|
});
|
|
559
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* Posts to the embedding host. No-op when no concrete target origin could be
|
|
824
|
+
* resolved (a config/embedding error) — `connect()` rejects fast in that case
|
|
825
|
+
* so calls never silently hang waiting on a timeout.
|
|
826
|
+
*/
|
|
827
|
+
postToParent(message) {
|
|
828
|
+
if (this.targetOrigin === null) return;
|
|
829
|
+
window.parent.postMessage(message, this.targetOrigin);
|
|
830
|
+
}
|
|
560
831
|
handleMessage(event) {
|
|
561
|
-
if (event.origin
|
|
832
|
+
if (!this.isTrustedOrigin(event.origin)) return;
|
|
562
833
|
if (!isBridgeMessage(event.data)) return;
|
|
563
834
|
this.kindHandlers[event.data.kind]?.(event.data);
|
|
564
835
|
}
|
|
565
836
|
handleResponse(msg) {
|
|
566
837
|
if (msg.type === "GET_CONTEXT") {
|
|
567
|
-
if (
|
|
838
|
+
if (!this.connectPollIds.has(msg.requestId)) return;
|
|
839
|
+
if (msg.ok) {
|
|
840
|
+
this.onContextReceived?.(msg.data);
|
|
841
|
+
} else {
|
|
842
|
+
this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
|
|
843
|
+
}
|
|
568
844
|
return;
|
|
569
845
|
}
|
|
570
846
|
const pending = this.pendingRequests.get(msg.requestId);
|
|
@@ -572,6 +848,10 @@ var VibeAppBridge = class {
|
|
|
572
848
|
this.pendingRequests.delete(msg.requestId);
|
|
573
849
|
if (msg.ok) {
|
|
574
850
|
pending.resolve(msg.data);
|
|
851
|
+
} else if (msg.status !== void 0) {
|
|
852
|
+
pending.reject(new HttpBridgeError(msg.status, msg.error));
|
|
853
|
+
} else if (msg.code) {
|
|
854
|
+
pending.reject(new BridgeError(msg.code, msg.error));
|
|
575
855
|
} else {
|
|
576
856
|
pending.reject(new Error(msg.error));
|
|
577
857
|
}
|
|
@@ -583,5 +863,7 @@ var VibeAppBridge = class {
|
|
|
583
863
|
};
|
|
584
864
|
export {
|
|
585
865
|
version as BRIDGE_VERSION,
|
|
866
|
+
BridgeError,
|
|
867
|
+
HttpBridgeError,
|
|
586
868
|
VibeAppBridge
|
|
587
869
|
};
|
|
@@ -32,9 +32,9 @@ 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
|
-
A timeout almost always means a `parentOrigin` mismatch or the host never mounted.
|
|
37
|
+
A timeout almost always means a `parentOrigin` mismatch or the host never mounted. A separate, immediate rejection — `BridgeError` code `PARENT_ORIGIN_UNRESOLVED` — means no concrete parent origin could be resolved at all (e.g. a pattern-only `parentOrigin` on a non-preview host); fix `parentOrigin` rather than waiting on the timeout.
|
|
38
38
|
|
|
39
39
|
## Initial deep link
|
|
40
40
|
|
|
@@ -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
|
|
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.
|
package/docs/data-fetching.md
CHANGED
|
@@ -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
|
|
43
|
-
-
|
|
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
|
```
|