@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/AGENTS.md +5 -2
- package/README.md +25 -5
- package/dist/index.cjs +290 -120
- package/dist/index.d.cts +234 -85
- package/dist/index.d.ts +234 -85
- package/dist/index.js +288 -120
- package/docs/connect-lifecycle.md +2 -2
- package/docs/data-fetching.md +7 -2
- package/docs/file-upload.md +8 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -32,15 +32,18 @@ bridge.destroy() // on unmount
|
|
|
32
32
|
|
|
33
33
|
## Core surface
|
|
34
34
|
|
|
35
|
-
- `new VibeAppBridge({ parentOrigin, requestTimeout? })`
|
|
35
|
+
- `new VibeAppBridge({ parentOrigin, requestTimeout? })` — `requestTimeout` is optional; omit it and each op uses a tuned default (API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000` ms).
|
|
36
36
|
- `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `parentOrigin` mismatch / host not mounted.
|
|
37
37
|
- ~46 typed data methods, e.g. `getChartOfAccounts`, `getSubsidiaries`, `getPeriods`, `getJournalEntries`, `getAccounts`, `postTaskOutput`. Each takes the operation's `payload` and returns its `data`.
|
|
38
38
|
- `request('<OPERATION>', payload, onProgress?)` — typed escape hatch for any operation; `'<OPERATION>'` autocompletes and narrows the payload/return types.
|
|
39
39
|
- `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
|
|
40
|
+
- `navigate`/`navigateTo`/`refresh` and `ui.setSideNav`/`ui.switchSubsidiary` — drive the host's chrome (router, side nav, subsidiary switcher); `invalidateCache(...)` busts host caches.
|
|
40
41
|
- `reportSubroute(subroute, { replace? })`, `onSubrouteRequest(cb): () => void` — usually unnecessary; standard SPA navigation is auto-tracked.
|
|
41
42
|
- `destroy()`.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
A failed operation throws a `BridgeError` (`extends Error`) with a machine-readable `code` (`'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`, …); branch on `err.code` rather than the message. A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`) carrying the HTTP `status` — branch on `err.status` (e.g. `404`).
|
|
45
|
+
|
|
46
|
+
Exported values/types: `VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
|
|
44
47
|
|
|
45
48
|
`ContextPayload` shape: `{ tenant, subsidiaryId, subsidiaries: { id, displayName }[], user: { id, displayName }, subroute?, lastClosedPeriodSlug?, hostVersion }`.
|
|
46
49
|
|
package/README.md
CHANGED
|
@@ -61,10 +61,10 @@ VITE_PARENT_ORIGIN=http://localhost:3000
|
|
|
61
61
|
|
|
62
62
|
### `new VibeAppBridge(options)`
|
|
63
63
|
|
|
64
|
-
| Option | Type | Default
|
|
65
|
-
| ---------------- | -------- |
|
|
66
|
-
| `parentOrigin` | `string` | —
|
|
67
|
-
| `requestTimeout` | `number` |
|
|
64
|
+
| Option | Type | Default | Description |
|
|
65
|
+
| ---------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `parentOrigin` | `string` | — | Origin of the Nominal app embedding this iframe. **Must match the host origin exactly.** |
|
|
67
|
+
| `requestTimeout` | `number` | _per-op_ | Global timeout (ms) before a call rejects with `BridgeError` code `'TIMEOUT'`. Omit to use per-operation defaults: API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000`. |
|
|
68
68
|
|
|
69
69
|
### `connect(): Promise<ContextPayload>`
|
|
70
70
|
|
|
@@ -102,7 +102,7 @@ Removes listeners, rejects pending requests, and restores patched history method
|
|
|
102
102
|
|
|
103
103
|
### Exports
|
|
104
104
|
|
|
105
|
-
`VibeAppBridge`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
|
|
105
|
+
`VibeAppBridge`, `BridgeError`, `BRIDGE_VERSION`, and the types `VibeAppBridgeOptions`, `UploadOptions`, `ContextPayload`, `BridgeSubsidiary`, `UploadResponse`, `UploadProgress`, `RequestRegistry`.
|
|
106
106
|
|
|
107
107
|
## Common recipes
|
|
108
108
|
|
|
@@ -138,6 +138,26 @@ await bridge.upload(file, {
|
|
|
138
138
|
const events = await bridge.request('GET_AUDIT_EVENTS', {})
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
**Handle a failure by its code:**
|
|
142
|
+
|
|
143
|
+
A rejected operation throws a `BridgeError` carrying the host's `code` (`'RATE_LIMITED'`, `'FILE_TOO_LARGE'`, `'TIMEOUT'`, …); branch on it instead of string-matching the message. A failed Nominal API call throws the `HttpBridgeError` subtype (`code: 'REQUEST_FAILED'`), which adds the HTTP `status` — branch on `err.status` (e.g. `404`).
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { BridgeError, HttpBridgeError } from '@nominalso/vibe-bridge'
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await bridge.getAccounts({})
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err instanceof HttpBridgeError && err.status === 404) {
|
|
152
|
+
// handle not-found
|
|
153
|
+
}
|
|
154
|
+
if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
|
|
155
|
+
// back off and retry
|
|
156
|
+
}
|
|
157
|
+
throw err
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
141
161
|
## Common mistakes
|
|
142
162
|
|
|
143
163
|
```ts
|
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.2.0";
|
|
30
32
|
|
|
31
33
|
// ../protocol-types/dist/index.js
|
|
34
|
+
function normalizeSubroute(subroute) {
|
|
35
|
+
const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
|
|
36
|
+
const path = normalized.split(/[?#]/)[0];
|
|
37
|
+
let decoded = path;
|
|
38
|
+
let prev;
|
|
39
|
+
do {
|
|
40
|
+
prev = decoded;
|
|
41
|
+
decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
|
|
42
|
+
} while (decoded !== prev);
|
|
43
|
+
if (decoded.split("/").some((segment) => segment === "..")) return null;
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
32
46
|
var PROTOCOL_ID = "nominal-vibe-bridge";
|
|
33
47
|
var PROTOCOL_VERSION = 1;
|
|
34
48
|
var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
|
|
@@ -45,119 +59,23 @@ 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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
68
|
+
};
|
|
69
|
+
var HttpBridgeError = class extends BridgeError {
|
|
70
|
+
constructor(status, message) {
|
|
71
|
+
super("REQUEST_FAILED", message);
|
|
72
|
+
this.status = status;
|
|
73
|
+
this.name = "HttpBridgeError";
|
|
160
74
|
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/data-methods.ts
|
|
78
|
+
var BridgeDataMethods = class {
|
|
161
79
|
// ---------------------------------------------------------------------------
|
|
162
80
|
// Operations — Accounting: chart of accounts
|
|
163
81
|
//
|
|
@@ -443,13 +361,198 @@ var VibeAppBridge = class {
|
|
|
443
361
|
const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
|
|
444
362
|
return this.request("UPLOAD_FILE", payload, onProgress);
|
|
445
363
|
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// src/timeouts.ts
|
|
367
|
+
var CONNECT_TIMEOUT_MS = 1e4;
|
|
368
|
+
var CONNECT_POLL_MS = 500;
|
|
369
|
+
var DEFAULT_TIMEOUTS = {
|
|
370
|
+
/** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
|
|
371
|
+
api: 3e4,
|
|
372
|
+
/** `GET_CONTEXT` re-fetch (the initial fetch is governed by `connect()`). */
|
|
373
|
+
context: 5e3,
|
|
374
|
+
/** `INVALIDATE_CACHE`. */
|
|
375
|
+
invalidate: 1e4,
|
|
376
|
+
/** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
|
|
377
|
+
upload: 12e4
|
|
378
|
+
};
|
|
379
|
+
function resolveRequestTimeout(type, requestTimeout) {
|
|
380
|
+
if (requestTimeout !== void 0) return requestTimeout;
|
|
381
|
+
switch (type) {
|
|
382
|
+
case "UPLOAD_FILE":
|
|
383
|
+
return DEFAULT_TIMEOUTS.upload;
|
|
384
|
+
case "INVALIDATE_CACHE":
|
|
385
|
+
return DEFAULT_TIMEOUTS.invalidate;
|
|
386
|
+
case "GET_CONTEXT":
|
|
387
|
+
return DEFAULT_TIMEOUTS.context;
|
|
388
|
+
default:
|
|
389
|
+
return DEFAULT_TIMEOUTS.api;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/VibeAppBridge.ts
|
|
394
|
+
var VibeAppBridge = class extends BridgeDataMethods {
|
|
395
|
+
constructor({ parentOrigin, requestTimeout }) {
|
|
396
|
+
super();
|
|
397
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
398
|
+
this.context = null;
|
|
399
|
+
this.connectPromise = null;
|
|
400
|
+
this.connectPollInterval = null;
|
|
401
|
+
this.onContextReceived = null;
|
|
402
|
+
this.onContextError = null;
|
|
403
|
+
/**
|
|
404
|
+
* Request ids of the current connect()'s GET_CONTEXT polls. Used to ignore a
|
|
405
|
+
* late response from a *previous* connect attempt, which would otherwise
|
|
406
|
+
* resolve/reject the new one (a stale error could reject a healthy reconnect).
|
|
407
|
+
*/
|
|
408
|
+
this.connectPollIds = /* @__PURE__ */ new Set();
|
|
409
|
+
this.lastReportedSubroute = null;
|
|
410
|
+
this.suppressSubrouteReport = false;
|
|
411
|
+
this.subrouteCallback = null;
|
|
412
|
+
this.origPushState = null;
|
|
413
|
+
this.origReplaceState = null;
|
|
414
|
+
this.popstateHandler = null;
|
|
415
|
+
this.kindHandlers = {
|
|
416
|
+
response: (msg) => this.handleResponse(msg),
|
|
417
|
+
push: (msg) => this.dispatchPush(msg),
|
|
418
|
+
progress: (msg) => this.handleProgress(msg)
|
|
419
|
+
};
|
|
420
|
+
this.pushHandlers = {
|
|
421
|
+
CONTEXT_PUSH: (payload) => this.onContextReceived?.(payload),
|
|
422
|
+
SUBROUTE_PUSH: (payload) => {
|
|
423
|
+
const safe = normalizeSubroute(payload.subroute);
|
|
424
|
+
if (!safe) return;
|
|
425
|
+
this.withSubrouteSuppressed(() => {
|
|
426
|
+
if (this.subrouteCallback) {
|
|
427
|
+
this.subrouteCallback(safe);
|
|
428
|
+
} else {
|
|
429
|
+
history.pushState(null, "", safe);
|
|
430
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
431
|
+
}
|
|
432
|
+
this.lastReportedSubroute = safe;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Commands targeting the host's UI chrome. All fire-and-forget.
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```ts
|
|
441
|
+
* bridge.ui.setSideNav({ collapsed: true })
|
|
442
|
+
* bridge.ui.switchSubsidiary(42)
|
|
443
|
+
* ```
|
|
444
|
+
*/
|
|
445
|
+
this.ui = {
|
|
446
|
+
/** Collapse or expand the host's side navigation. */
|
|
447
|
+
setSideNav: (payload) => {
|
|
448
|
+
this.sendCommand("SET_SIDE_NAV", payload);
|
|
449
|
+
},
|
|
450
|
+
/** Switch the host to a different subsidiary. */
|
|
451
|
+
switchSubsidiary: (subsidiaryId) => {
|
|
452
|
+
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
this.parentOrigin = parentOrigin;
|
|
456
|
+
this.requestTimeout = requestTimeout;
|
|
457
|
+
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
458
|
+
window.addEventListener("message", this.boundHandleMessage);
|
|
459
|
+
}
|
|
460
|
+
dispatchPush(msg) {
|
|
461
|
+
const handler = this.pushHandlers[msg.type];
|
|
462
|
+
handler(msg.payload);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Connects to the host and returns the tenant/user {@link ContextPayload}.
|
|
466
|
+
* **Call this once on app init and await it before any other method** — data
|
|
467
|
+
* methods, `upload()`, and subroute reporting all require an established
|
|
468
|
+
* connection. Concurrent calls return the same promise.
|
|
469
|
+
*
|
|
470
|
+
* Actively polls the host every 500ms until a response arrives, which
|
|
471
|
+
* handles the race condition where the host's initial context push arrives
|
|
472
|
+
* before this listener is ready. Rejects with `Bridge connect timed out`
|
|
473
|
+
* after 10 seconds if no context arrives (usually a `parentOrigin` mismatch
|
|
474
|
+
* or the host hasn't mounted).
|
|
475
|
+
*
|
|
476
|
+
* After connecting, if the context includes an initial subroute (e.g. from
|
|
477
|
+
* a deep link), the bridge navigates to it and starts auto-tracking
|
|
478
|
+
* internal navigation via the history API.
|
|
479
|
+
*
|
|
480
|
+
* @returns The tenant/user/subsidiary context for this app session.
|
|
481
|
+
* @throws If no context is received within 10 seconds.
|
|
482
|
+
* @example
|
|
483
|
+
* ```ts
|
|
484
|
+
* const ctx = await bridge.connect()
|
|
485
|
+
* // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
|
|
486
|
+
* ```
|
|
487
|
+
*/
|
|
488
|
+
connect() {
|
|
489
|
+
if (this.context) return Promise.resolve(this.context);
|
|
490
|
+
if (this.connectPromise) return this.connectPromise;
|
|
491
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
this.stopConnectPolling();
|
|
494
|
+
this.connectPromise = null;
|
|
495
|
+
this.onContextReceived = null;
|
|
496
|
+
this.onContextError = null;
|
|
497
|
+
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
498
|
+
}, CONNECT_TIMEOUT_MS);
|
|
499
|
+
this.onContextError = (error) => {
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
this.stopConnectPolling();
|
|
502
|
+
this.connectPromise = null;
|
|
503
|
+
this.onContextReceived = null;
|
|
504
|
+
this.onContextError = null;
|
|
505
|
+
reject(error);
|
|
506
|
+
};
|
|
507
|
+
this.onContextReceived = (ctx) => {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
this.stopConnectPolling();
|
|
510
|
+
this.context = ctx;
|
|
511
|
+
this.connectPromise = null;
|
|
512
|
+
this.onContextReceived = null;
|
|
513
|
+
this.onContextError = null;
|
|
514
|
+
console.log(
|
|
515
|
+
`[vibe-bridge] Connected \u2014 bridge: ${version}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
|
|
516
|
+
);
|
|
517
|
+
const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
|
|
518
|
+
if (initialSubroute && initialSubroute !== "/") {
|
|
519
|
+
this.withSubrouteSuppressed(() => {
|
|
520
|
+
history.replaceState(null, "", initialSubroute);
|
|
521
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
522
|
+
this.lastReportedSubroute = initialSubroute;
|
|
523
|
+
});
|
|
524
|
+
} else {
|
|
525
|
+
this.lastReportedSubroute = window.location.pathname + window.location.search;
|
|
526
|
+
}
|
|
527
|
+
this.setupNavigationTracking();
|
|
528
|
+
resolve(ctx);
|
|
529
|
+
};
|
|
530
|
+
const poll = () => {
|
|
531
|
+
const requestId = crypto.randomUUID();
|
|
532
|
+
this.connectPollIds.add(requestId);
|
|
533
|
+
const message = {
|
|
534
|
+
__protocol: PROTOCOL_ID,
|
|
535
|
+
__version: PROTOCOL_VERSION,
|
|
536
|
+
kind: "request",
|
|
537
|
+
type: "GET_CONTEXT",
|
|
538
|
+
requestId,
|
|
539
|
+
payload: { bridgeVersion: version }
|
|
540
|
+
};
|
|
541
|
+
window.parent.postMessage(message, this.parentOrigin);
|
|
542
|
+
};
|
|
543
|
+
poll();
|
|
544
|
+
this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
|
|
545
|
+
});
|
|
546
|
+
return this.connectPromise;
|
|
547
|
+
}
|
|
446
548
|
/**
|
|
447
549
|
* Manually reports the current subroute to the host.
|
|
448
550
|
* Usually not needed — navigation is auto-detected via the history API.
|
|
449
551
|
* Use this for hash-based routers or non-standard navigation patterns.
|
|
450
552
|
*/
|
|
451
553
|
reportSubroute(subroute, options) {
|
|
452
|
-
const normalized = subroute
|
|
554
|
+
const normalized = normalizeSubroute(subroute);
|
|
555
|
+
if (normalized === null) return;
|
|
453
556
|
this.lastReportedSubroute = normalized;
|
|
454
557
|
this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
|
|
455
558
|
}
|
|
@@ -465,17 +568,68 @@ var VibeAppBridge = class {
|
|
|
465
568
|
this.subrouteCallback = null;
|
|
466
569
|
};
|
|
467
570
|
}
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Host navigation & UI commands
|
|
573
|
+
//
|
|
574
|
+
// These drive the *host* (nom-ui) chrome — its router, side nav, and
|
|
575
|
+
// subsidiary switcher — rather than the iframe's own internal routing
|
|
576
|
+
// (`reportSubroute`). Navigation/UI commands are fire-and-forget;
|
|
577
|
+
// `invalidateCache` is awaited.
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
/**
|
|
580
|
+
* Navigates the host to a raw path relative to this app's tenant/subsidiary
|
|
581
|
+
* base. Fire-and-forget.
|
|
582
|
+
*
|
|
583
|
+
* @example
|
|
584
|
+
* ```ts
|
|
585
|
+
* bridge.navigate('/journal-entries/123')
|
|
586
|
+
* bridge.navigate('/journal-entries/123', 'replace')
|
|
587
|
+
* ```
|
|
588
|
+
*/
|
|
589
|
+
navigate(path, action = "push") {
|
|
590
|
+
this.sendCommand("NAVIGATE", { path, action });
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Navigates the host to a named Nominal route, with optional params for
|
|
594
|
+
* placeholder segments. Fire-and-forget.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```ts
|
|
598
|
+
* bridge.navigateTo('CHART_OF_ACCOUNTS')
|
|
599
|
+
* bridge.navigateTo('JOURNAL_ENTRY', { id: '123' }, 'replace')
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
navigateTo(route, routeParams, action = "push") {
|
|
603
|
+
this.sendCommand("NAVIGATE", { route, routeParams, action });
|
|
604
|
+
}
|
|
605
|
+
/** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
|
|
606
|
+
refresh() {
|
|
607
|
+
this.sendCommand("NAVIGATE", { action: "refresh" });
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Asks the host to invalidate cached data by path prefix and/or cache tag so
|
|
611
|
+
* subsequent reads see fresh values. Awaited.
|
|
612
|
+
*
|
|
613
|
+
* @example
|
|
614
|
+
* ```ts
|
|
615
|
+
* await bridge.invalidateCache({ tags: ['SUBSIDIARIES'], paths: ['/accounting/accounts'] })
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
invalidateCache(payload) {
|
|
619
|
+
return this.request("INVALIDATE_CACHE", payload);
|
|
620
|
+
}
|
|
468
621
|
destroy() {
|
|
469
622
|
this.teardownNavigationTracking();
|
|
470
623
|
this.stopConnectPolling();
|
|
471
624
|
window.removeEventListener("message", this.boundHandleMessage);
|
|
472
625
|
for (const pending of this.pendingRequests.values()) {
|
|
473
|
-
pending.reject(new
|
|
626
|
+
pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
474
627
|
}
|
|
475
628
|
this.pendingRequests.clear();
|
|
476
629
|
this.context = null;
|
|
477
630
|
this.connectPromise = null;
|
|
478
631
|
this.onContextReceived = null;
|
|
632
|
+
this.onContextError = null;
|
|
479
633
|
this.subrouteCallback = null;
|
|
480
634
|
this.lastReportedSubroute = null;
|
|
481
635
|
}
|
|
@@ -484,6 +638,7 @@ var VibeAppBridge = class {
|
|
|
484
638
|
clearInterval(this.connectPollInterval);
|
|
485
639
|
this.connectPollInterval = null;
|
|
486
640
|
}
|
|
641
|
+
this.connectPollIds.clear();
|
|
487
642
|
}
|
|
488
643
|
/**
|
|
489
644
|
* Monkey-patches `history.pushState` and `history.replaceState` to
|
|
@@ -532,7 +687,8 @@ var VibeAppBridge = class {
|
|
|
532
687
|
}
|
|
533
688
|
reportCurrentSubroute(replace) {
|
|
534
689
|
if (this.suppressSubrouteReport) return;
|
|
535
|
-
const subroute = window.location.pathname + window.location.search;
|
|
690
|
+
const subroute = normalizeSubroute(window.location.pathname + window.location.search);
|
|
691
|
+
if (subroute === null) return;
|
|
536
692
|
if (subroute === this.lastReportedSubroute) return;
|
|
537
693
|
this.lastReportedSubroute = subroute;
|
|
538
694
|
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
@@ -558,10 +714,13 @@ var VibeAppBridge = class {
|
|
|
558
714
|
request(type, payload, onProgress) {
|
|
559
715
|
return new Promise((resolve, reject) => {
|
|
560
716
|
const requestId = crypto.randomUUID();
|
|
561
|
-
const timer = setTimeout(
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
717
|
+
const timer = setTimeout(
|
|
718
|
+
() => {
|
|
719
|
+
this.pendingRequests.delete(requestId);
|
|
720
|
+
reject(new BridgeError("TIMEOUT", `Vibe bridge request timed out: ${type}`));
|
|
721
|
+
},
|
|
722
|
+
resolveRequestTimeout(type, this.requestTimeout)
|
|
723
|
+
);
|
|
565
724
|
this.pendingRequests.set(requestId, {
|
|
566
725
|
resolve: (data) => {
|
|
567
726
|
clearTimeout(timer);
|
|
@@ -591,7 +750,12 @@ var VibeAppBridge = class {
|
|
|
591
750
|
}
|
|
592
751
|
handleResponse(msg) {
|
|
593
752
|
if (msg.type === "GET_CONTEXT") {
|
|
594
|
-
if (
|
|
753
|
+
if (!this.connectPollIds.has(msg.requestId)) return;
|
|
754
|
+
if (msg.ok) {
|
|
755
|
+
this.onContextReceived?.(msg.data);
|
|
756
|
+
} else {
|
|
757
|
+
this.onContextError?.(new BridgeError(msg.code ?? "CONTEXT_ERROR", msg.error));
|
|
758
|
+
}
|
|
595
759
|
return;
|
|
596
760
|
}
|
|
597
761
|
const pending = this.pendingRequests.get(msg.requestId);
|
|
@@ -599,6 +763,10 @@ var VibeAppBridge = class {
|
|
|
599
763
|
this.pendingRequests.delete(msg.requestId);
|
|
600
764
|
if (msg.ok) {
|
|
601
765
|
pending.resolve(msg.data);
|
|
766
|
+
} else if (msg.status !== void 0) {
|
|
767
|
+
pending.reject(new HttpBridgeError(msg.status, msg.error));
|
|
768
|
+
} else if (msg.code) {
|
|
769
|
+
pending.reject(new BridgeError(msg.code, msg.error));
|
|
602
770
|
} else {
|
|
603
771
|
pending.reject(new Error(msg.error));
|
|
604
772
|
}
|
|
@@ -611,5 +779,7 @@ var VibeAppBridge = class {
|
|
|
611
779
|
// Annotate the CommonJS export names for ESM import in node:
|
|
612
780
|
0 && (module.exports = {
|
|
613
781
|
BRIDGE_VERSION,
|
|
782
|
+
BridgeError,
|
|
783
|
+
HttpBridgeError,
|
|
614
784
|
VibeAppBridge
|
|
615
785
|
});
|