@nominalso/vibe-bridge 0.2.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 +5 -7
- package/README.md +39 -19
- package/dist/index.cjs +124 -10
- package/dist/index.d.cts +40 -9
- package/dist/index.d.ts +40 -9
- package/dist/index.js +124 -10
- package/docs/connect-lifecycle.md +1 -1
- package/docs/getting-started.md +12 -10
- package/llms.txt +2 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -15,9 +15,7 @@ The host counterpart is `@nominalso/vibe-host` (used by Nominal's own app — yo
|
|
|
15
15
|
```ts
|
|
16
16
|
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
17
17
|
|
|
18
|
-
const bridge = new VibeAppBridge(
|
|
19
|
-
parentOrigin: import.meta.env.VITE_PARENT_ORIGIN, // exact origin of the embedding Nominal app
|
|
20
|
-
})
|
|
18
|
+
const bridge = new VibeAppBridge() // no config — auto-detects the embedding Nominal app
|
|
21
19
|
|
|
22
20
|
const ctx: ContextPayload = await bridge.connect() // call ONCE, await before anything else
|
|
23
21
|
|
|
@@ -32,8 +30,8 @@ bridge.destroy() // on unmount
|
|
|
32
30
|
|
|
33
31
|
## Core surface
|
|
34
32
|
|
|
35
|
-
- `new VibeAppBridge({ parentOrigin
|
|
36
|
-
- `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on `
|
|
33
|
+
- `new VibeAppBridge({ parentOrigin?, requestTimeout? })` — both optional; `new VibeAppBridge()` auto-detects the host. `requestTimeout` omitted → tuned per-op defaults (API `30000`, `UPLOAD_FILE` `120000`, `INVALIDATE_CACHE` `10000`, `GET_CONTEXT` `5000` ms).
|
|
34
|
+
- `connect(): Promise<ContextPayload>` — call once; rejects after 10 s on a host that never mounts, or immediately with code `PARENT_ORIGIN_UNRESOLVED` if no host could be detected/pinned.
|
|
37
35
|
- ~46 typed data methods, e.g. `getChartOfAccounts`, `getSubsidiaries`, `getPeriods`, `getJournalEntries`, `getAccounts`, `postTaskOutput`. Each takes the operation's `payload` and returns its `data`.
|
|
38
36
|
- `request('<OPERATION>', payload, onProgress?)` — typed escape hatch for any operation; `'<OPERATION>'` autocompletes and narrows the payload/return types.
|
|
39
37
|
- `upload(file: File, { entityType, entityId?, onProgress? }): Promise<{ attachmentId, name }>`.
|
|
@@ -52,7 +50,7 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
|
|
|
52
50
|
## Gotchas (wrong → right)
|
|
53
51
|
|
|
54
52
|
- **Don't call data methods before `connect()` resolves.** Always `await bridge.connect()` first.
|
|
55
|
-
- **`parentOrigin` is an origin
|
|
53
|
+
- **`parentOrigin` is optional** — omit it and the bridge auto-detects the embedding host. If you do pass it, it's an origin or list of origins (not a URL): `https://app.nominal.so` ✅ — not `https://app.nominal.so/app`, no trailing slash. For preview testing pass a list with a glob: `['https://app.nominal.so', 'https://*.vercel.app']` — patterns match only when the app itself runs on a preview host.
|
|
56
54
|
- **`upload()` takes a `File`**, not a path or `FormData`.
|
|
57
55
|
- **Writes go through `postTaskOutput` only.** Vibe Apps never write Nominal data directly.
|
|
58
56
|
- **Call `bridge.destroy()` on unmount** to remove listeners and reject pending requests.
|
|
@@ -60,5 +58,5 @@ The full method/operation list is in `README.md` ("Operation catalog") and in `d
|
|
|
60
58
|
## Don't
|
|
61
59
|
|
|
62
60
|
- Don't poll or busy-wait for context — `await connect()` resolves when it's ready.
|
|
63
|
-
- Don't
|
|
61
|
+
- Don't pass `parentOrigin` unless you mean to pin the host; the default auto-detects it. If you pin, read it from an env var (and add a preview pattern to the list when testing deploy previews).
|
|
64
62
|
- Don't import from `@nominalso/vibe-protocol-types` / `vibe-api-types` — they are not published; all public types are re-exported from `@nominalso/vibe-bridge`.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Iframe-side SDK for building **Nominal Vibe Apps** — standalone web apps (typically built with [Lovable](https://lovable.dev)) embedded in the Nominal platform via a cross-origin `<iframe>`. The bridge connects your app to its Nominal host over a typed `postMessage` protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync.
|
|
4
4
|
|
|
5
|
-
> **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same —
|
|
5
|
+
> **For AI agents / Lovable:** this is a browser-only SDK. The wiring is always the same — `new VibeAppBridge()` (no config — it auto-detects the Nominal host), `await bridge.connect()` **once**, then call data methods. Copy the quickstart below verbatim; it is the complete happy path.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -17,11 +17,10 @@ Zero runtime dependencies. Ships ESM + CJS and self-contained TypeScript types.
|
|
|
17
17
|
```ts
|
|
18
18
|
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
19
19
|
|
|
20
|
-
// 1. Construct
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
20
|
+
// 1. Construct. No config needed — the bridge auto-detects the Nominal app
|
|
21
|
+
// that embeds it. (Pin the host origin explicitly only if you want to;
|
|
22
|
+
// see "Pinning the host origin" below.)
|
|
23
|
+
const bridge = new VibeAppBridge()
|
|
25
24
|
|
|
26
25
|
// 2. Connect ONCE on init and await it before any other call.
|
|
27
26
|
// Resolves with the tenant/user context (or rejects after 10s).
|
|
@@ -47,28 +46,47 @@ await bridge.postTaskOutput({ path: { task_instance_id: 'task-1' }, body: {} })
|
|
|
47
46
|
bridge.destroy()
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
### Pinning the host origin (optional)
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
By default you pass **nothing** — the bridge talks only to the Nominal page that
|
|
52
|
+
actually embeds it. That's safe because Nominal serves Vibe Apps behind a
|
|
53
|
+
`frame-ancestors` CSP, so only nom-ui can frame your app. Most apps need nothing
|
|
54
|
+
more.
|
|
55
|
+
|
|
56
|
+
Pass `parentOrigin` to pin explicitly — defense in depth, or when running
|
|
57
|
+
outside Nominal's edge. Drive it from an env var, or use a list / **glob
|
|
58
|
+
patterns** to accept dynamic preview deployments:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
|
|
62
|
+
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
|
|
63
|
+
```
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
```sh
|
|
66
|
+
# .env.local
|
|
57
67
|
VITE_PARENT_ORIGIN=http://localhost:3000
|
|
58
68
|
```
|
|
59
69
|
|
|
70
|
+
A pattern's `*` matches exactly one DNS label or port (anchored, scheme
|
|
71
|
+
literal): `https://*.vercel.app` matches `https://pr-7.vercel.app` but not
|
|
72
|
+
`https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. **Patterns are
|
|
73
|
+
honoured only when this app's own page is served from a recognised preview host**
|
|
74
|
+
(`*.vercel.app`, `*.lovable.app`, `localhost`, …) — on a production custom
|
|
75
|
+
domain pattern entries are ignored and only exact origins match. Always include
|
|
76
|
+
at least one exact origin alongside any patterns.
|
|
77
|
+
|
|
60
78
|
## API
|
|
61
79
|
|
|
62
80
|
### `new VibeAppBridge(options)`
|
|
63
81
|
|
|
64
|
-
| Option | Type
|
|
65
|
-
| ---------------- |
|
|
66
|
-
| `parentOrigin` | `string` |
|
|
67
|
-
| `requestTimeout` | `number`
|
|
82
|
+
| Option | Type | Default | Description |
|
|
83
|
+
| ---------------- | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `parentOrigin` | `string \| string[]` | _auto_ | **Optional.** Omit to auto-detect the embedding Nominal host. Pass to pin: exact origins always match; glob patterns (`https://*.vercel.app`) match only when this app runs on a preview host. See above. |
|
|
85
|
+
| `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
86
|
|
|
69
87
|
### `connect(): Promise<ContextPayload>`
|
|
70
88
|
|
|
71
|
-
Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). Concurrent calls return the same promise.
|
|
89
|
+
Call **once** on init and `await` it before anything else. Polls the host every 500 ms until context arrives; rejects with `Bridge connect timed out` after 10 s (usually a `parentOrigin` mismatch or the host hasn't mounted). If no concrete parent origin can be resolved (e.g. a pattern-only `parentOrigin` on a non-preview host), it rejects immediately with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`. Concurrent calls return the same promise.
|
|
72
90
|
|
|
73
91
|
### Data operations
|
|
74
92
|
|
|
@@ -115,7 +133,7 @@ import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
|
115
133
|
function useVibeBridge() {
|
|
116
134
|
const [ctx, setCtx] = useState<ContextPayload | null>(null)
|
|
117
135
|
useEffect(() => {
|
|
118
|
-
const bridge = new VibeAppBridge(
|
|
136
|
+
const bridge = new VibeAppBridge()
|
|
119
137
|
bridge.connect().then(setCtx).catch(console.error)
|
|
120
138
|
return () => bridge.destroy()
|
|
121
139
|
}, [])
|
|
@@ -172,13 +190,15 @@ const accounts = await bridge.getChartOfAccounts({ path: { subsidiary_id: ctx.su
|
|
|
172
190
|
```
|
|
173
191
|
|
|
174
192
|
```ts
|
|
175
|
-
// ❌ WRONG —
|
|
193
|
+
// ❌ WRONG — each entry must be an ORIGIN (scheme + host + port), not a URL with a path.
|
|
176
194
|
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so/some/path' })
|
|
177
195
|
// ❌ a trailing slash or wrong port also fails → connect() times out after 10s.
|
|
178
196
|
new VibeAppBridge({ parentOrigin: 'http://localhost:3000/' })
|
|
179
197
|
|
|
180
|
-
// ✅ CORRECT — scheme + host + port only,
|
|
198
|
+
// ✅ CORRECT — scheme + host + port only, matching the embedding app.
|
|
181
199
|
new VibeAppBridge({ parentOrigin: 'https://app.nominal.so' })
|
|
200
|
+
// ✅ CORRECT — a list with a preview pattern (honoured only on a preview host).
|
|
201
|
+
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
|
|
182
202
|
```
|
|
183
203
|
|
|
184
204
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -28,7 +28,7 @@ __export(index_exports, {
|
|
|
28
28
|
module.exports = __toCommonJS(index_exports);
|
|
29
29
|
|
|
30
30
|
// package.json
|
|
31
|
-
var version = "0.
|
|
31
|
+
var version = "0.3.0";
|
|
32
32
|
|
|
33
33
|
// ../protocol-types/dist/index.js
|
|
34
34
|
function normalizeSubroute(subroute) {
|
|
@@ -73,6 +73,33 @@ var HttpBridgeError = class extends BridgeError {
|
|
|
73
73
|
this.name = "HttpBridgeError";
|
|
74
74
|
}
|
|
75
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
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
76
103
|
|
|
77
104
|
// src/data-methods.ts
|
|
78
105
|
var BridgeDataMethods = class {
|
|
@@ -391,13 +418,72 @@ function resolveRequestTimeout(type, requestTimeout) {
|
|
|
391
418
|
}
|
|
392
419
|
|
|
393
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
|
+
}
|
|
394
479
|
var VibeAppBridge = class extends BridgeDataMethods {
|
|
395
|
-
constructor({ parentOrigin, requestTimeout }) {
|
|
480
|
+
constructor({ parentOrigin, requestTimeout } = {}) {
|
|
396
481
|
super();
|
|
397
482
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
398
483
|
this.context = null;
|
|
399
484
|
this.connectPromise = null;
|
|
400
485
|
this.connectPollInterval = null;
|
|
486
|
+
this.connectTimeout = null;
|
|
401
487
|
this.onContextReceived = null;
|
|
402
488
|
this.onContextError = null;
|
|
403
489
|
/**
|
|
@@ -452,7 +538,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
452
538
|
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
453
539
|
}
|
|
454
540
|
};
|
|
455
|
-
|
|
541
|
+
const policy = resolveOriginPolicy(parentOrigin);
|
|
542
|
+
this.targetOrigin = policy.targetOrigin;
|
|
543
|
+
this.isTrustedOrigin = policy.isTrusted;
|
|
456
544
|
this.requestTimeout = requestTimeout;
|
|
457
545
|
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
458
546
|
window.addEventListener("message", this.boundHandleMessage);
|
|
@@ -488,6 +576,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
488
576
|
connect() {
|
|
489
577
|
if (this.context) return Promise.resolve(this.context);
|
|
490
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
|
+
}
|
|
491
587
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
492
588
|
const timer = setTimeout(() => {
|
|
493
589
|
this.stopConnectPolling();
|
|
@@ -496,6 +592,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
496
592
|
this.onContextError = null;
|
|
497
593
|
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
498
594
|
}, CONNECT_TIMEOUT_MS);
|
|
595
|
+
this.connectTimeout = timer;
|
|
499
596
|
this.onContextError = (error) => {
|
|
500
597
|
clearTimeout(timer);
|
|
501
598
|
this.stopConnectPolling();
|
|
@@ -538,7 +635,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
538
635
|
requestId,
|
|
539
636
|
payload: { bridgeVersion: version }
|
|
540
637
|
};
|
|
541
|
-
|
|
638
|
+
this.postToParent(message);
|
|
542
639
|
};
|
|
543
640
|
poll();
|
|
544
641
|
this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
|
|
@@ -620,6 +717,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
620
717
|
}
|
|
621
718
|
destroy() {
|
|
622
719
|
this.teardownNavigationTracking();
|
|
720
|
+
this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
623
721
|
this.stopConnectPolling();
|
|
624
722
|
window.removeEventListener("message", this.boundHandleMessage);
|
|
625
723
|
for (const pending of this.pendingRequests.values()) {
|
|
@@ -638,6 +736,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
638
736
|
clearInterval(this.connectPollInterval);
|
|
639
737
|
this.connectPollInterval = null;
|
|
640
738
|
}
|
|
739
|
+
if (this.connectTimeout !== null) {
|
|
740
|
+
clearTimeout(this.connectTimeout);
|
|
741
|
+
this.connectTimeout = null;
|
|
742
|
+
}
|
|
641
743
|
this.connectPollIds.clear();
|
|
642
744
|
}
|
|
643
745
|
/**
|
|
@@ -694,10 +796,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
694
796
|
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
695
797
|
}
|
|
696
798
|
sendCommand(type, payload) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
799
|
+
this.postToParent({
|
|
800
|
+
__protocol: PROTOCOL_ID,
|
|
801
|
+
__version: PROTOCOL_VERSION,
|
|
802
|
+
kind: "command",
|
|
803
|
+
type,
|
|
804
|
+
payload
|
|
805
|
+
});
|
|
701
806
|
}
|
|
702
807
|
/**
|
|
703
808
|
* Low-level typed request for **any** operation in {@link RequestRegistry}.
|
|
@@ -740,11 +845,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
740
845
|
requestId,
|
|
741
846
|
payload
|
|
742
847
|
};
|
|
743
|
-
|
|
848
|
+
this.postToParent(message);
|
|
744
849
|
});
|
|
745
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
|
+
}
|
|
746
860
|
handleMessage(event) {
|
|
747
|
-
if (event.origin
|
|
861
|
+
if (!this.isTrustedOrigin(event.origin)) return;
|
|
748
862
|
if (!isBridgeMessage(event.data)) return;
|
|
749
863
|
this.kindHandlers[event.data.kind]?.(event.data);
|
|
750
864
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var version = "0.
|
|
1
|
+
var version = "0.3.0";
|
|
2
2
|
|
|
3
3
|
type AccountingAccountDimensionValues = {
|
|
4
4
|
account_id: string;
|
|
@@ -3659,13 +3659,34 @@ declare abstract class BridgeDataMethods {
|
|
|
3659
3659
|
|
|
3660
3660
|
interface VibeAppBridgeOptions {
|
|
3661
3661
|
/**
|
|
3662
|
-
* Origin of the Nominal app embedding this iframe
|
|
3663
|
-
*
|
|
3664
|
-
*
|
|
3665
|
-
*
|
|
3666
|
-
*
|
|
3662
|
+
* Origin(s) of the Nominal app embedding this iframe. **Optional.**
|
|
3663
|
+
*
|
|
3664
|
+
* **Omit it** (the recommended default) and the bridge auto-detects the
|
|
3665
|
+
* Nominal page that actually embeds it and talks only to that origin. This is
|
|
3666
|
+
* safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so
|
|
3667
|
+
* only nom-ui can frame the app — a hostile site can't embed it to intercept
|
|
3668
|
+
* what the iframe sends out (`postTaskOutput` bodies, file uploads). Most apps
|
|
3669
|
+
* need nothing here.
|
|
3670
|
+
*
|
|
3671
|
+
* Provide a value to **pin explicitly** — defense in depth, or when deploying
|
|
3672
|
+
* outside Nominal's edge:
|
|
3673
|
+
*
|
|
3674
|
+
* ```ts
|
|
3675
|
+
* parentOrigin: 'https://app.nominal.so' // one exact origin
|
|
3676
|
+
* parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] // exact + preview pattern
|
|
3677
|
+
* ```
|
|
3678
|
+
*
|
|
3679
|
+
* A pattern's `*` matches exactly one DNS label or port — anchored, scheme
|
|
3680
|
+
* literal — so `https://*.vercel.app` matches `https://pr-7.vercel.app` but
|
|
3681
|
+
* not `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. Patterns
|
|
3682
|
+
* are honoured only when this app's own page is served from a recognised
|
|
3683
|
+
* preview host (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a
|
|
3684
|
+
* production custom domain only exact origins match.
|
|
3685
|
+
*
|
|
3686
|
+
* If neither an explicit value nor an auto-detected parent is available,
|
|
3687
|
+
* `connect()` rejects with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`.
|
|
3667
3688
|
*/
|
|
3668
|
-
parentOrigin
|
|
3689
|
+
parentOrigin?: string | string[];
|
|
3669
3690
|
/**
|
|
3670
3691
|
* Global per-request timeout in milliseconds before a call rejects with a
|
|
3671
3692
|
* `BridgeError` whose `code` is `'TIMEOUT'`.
|
|
@@ -3702,13 +3723,17 @@ interface VibeAppBridgeOptions {
|
|
|
3702
3723
|
* ```
|
|
3703
3724
|
*/
|
|
3704
3725
|
declare class VibeAppBridge extends BridgeDataMethods {
|
|
3705
|
-
|
|
3726
|
+
/** Concrete origin to `postMessage` to, or `null` if none could be resolved. */
|
|
3727
|
+
private readonly targetOrigin;
|
|
3728
|
+
/** Validates an inbound `event.origin` against the configured allowlist. */
|
|
3729
|
+
private readonly isTrustedOrigin;
|
|
3706
3730
|
private readonly requestTimeout;
|
|
3707
3731
|
private readonly pendingRequests;
|
|
3708
3732
|
private readonly boundHandleMessage;
|
|
3709
3733
|
private context;
|
|
3710
3734
|
private connectPromise;
|
|
3711
3735
|
private connectPollInterval;
|
|
3736
|
+
private connectTimeout;
|
|
3712
3737
|
private onContextReceived;
|
|
3713
3738
|
private onContextError;
|
|
3714
3739
|
/**
|
|
@@ -3726,7 +3751,7 @@ declare class VibeAppBridge extends BridgeDataMethods {
|
|
|
3726
3751
|
private readonly kindHandlers;
|
|
3727
3752
|
private dispatchPush;
|
|
3728
3753
|
private readonly pushHandlers;
|
|
3729
|
-
constructor({ parentOrigin, requestTimeout }
|
|
3754
|
+
constructor({ parentOrigin, requestTimeout }?: VibeAppBridgeOptions);
|
|
3730
3755
|
/**
|
|
3731
3756
|
* Connects to the host and returns the tenant/user {@link ContextPayload}.
|
|
3732
3757
|
* **Call this once on app init and await it before any other method** — data
|
|
@@ -3842,6 +3867,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
|
|
|
3842
3867
|
* ```
|
|
3843
3868
|
*/
|
|
3844
3869
|
request<K extends keyof RequestRegistry>(type: K, payload: RequestRegistry[K]['payload'], onProgress?: (payload: unknown) => void): Promise<RequestRegistry[K]['data']>;
|
|
3870
|
+
/**
|
|
3871
|
+
* Posts to the embedding host. No-op when no concrete target origin could be
|
|
3872
|
+
* resolved (a config/embedding error) — `connect()` rejects fast in that case
|
|
3873
|
+
* so calls never silently hang waiting on a timeout.
|
|
3874
|
+
*/
|
|
3875
|
+
private postToParent;
|
|
3845
3876
|
private handleMessage;
|
|
3846
3877
|
private handleResponse;
|
|
3847
3878
|
private handleProgress;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var version = "0.
|
|
1
|
+
var version = "0.3.0";
|
|
2
2
|
|
|
3
3
|
type AccountingAccountDimensionValues = {
|
|
4
4
|
account_id: string;
|
|
@@ -3659,13 +3659,34 @@ declare abstract class BridgeDataMethods {
|
|
|
3659
3659
|
|
|
3660
3660
|
interface VibeAppBridgeOptions {
|
|
3661
3661
|
/**
|
|
3662
|
-
* Origin of the Nominal app embedding this iframe
|
|
3663
|
-
*
|
|
3664
|
-
*
|
|
3665
|
-
*
|
|
3666
|
-
*
|
|
3662
|
+
* Origin(s) of the Nominal app embedding this iframe. **Optional.**
|
|
3663
|
+
*
|
|
3664
|
+
* **Omit it** (the recommended default) and the bridge auto-detects the
|
|
3665
|
+
* Nominal page that actually embeds it and talks only to that origin. This is
|
|
3666
|
+
* safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so
|
|
3667
|
+
* only nom-ui can frame the app — a hostile site can't embed it to intercept
|
|
3668
|
+
* what the iframe sends out (`postTaskOutput` bodies, file uploads). Most apps
|
|
3669
|
+
* need nothing here.
|
|
3670
|
+
*
|
|
3671
|
+
* Provide a value to **pin explicitly** — defense in depth, or when deploying
|
|
3672
|
+
* outside Nominal's edge:
|
|
3673
|
+
*
|
|
3674
|
+
* ```ts
|
|
3675
|
+
* parentOrigin: 'https://app.nominal.so' // one exact origin
|
|
3676
|
+
* parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] // exact + preview pattern
|
|
3677
|
+
* ```
|
|
3678
|
+
*
|
|
3679
|
+
* A pattern's `*` matches exactly one DNS label or port — anchored, scheme
|
|
3680
|
+
* literal — so `https://*.vercel.app` matches `https://pr-7.vercel.app` but
|
|
3681
|
+
* not `https://a.b.vercel.app` or `https://pr-7.vercel.app.evil.com`. Patterns
|
|
3682
|
+
* are honoured only when this app's own page is served from a recognised
|
|
3683
|
+
* preview host (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a
|
|
3684
|
+
* production custom domain only exact origins match.
|
|
3685
|
+
*
|
|
3686
|
+
* If neither an explicit value nor an auto-detected parent is available,
|
|
3687
|
+
* `connect()` rejects with `BridgeError` code `PARENT_ORIGIN_UNRESOLVED`.
|
|
3667
3688
|
*/
|
|
3668
|
-
parentOrigin
|
|
3689
|
+
parentOrigin?: string | string[];
|
|
3669
3690
|
/**
|
|
3670
3691
|
* Global per-request timeout in milliseconds before a call rejects with a
|
|
3671
3692
|
* `BridgeError` whose `code` is `'TIMEOUT'`.
|
|
@@ -3702,13 +3723,17 @@ interface VibeAppBridgeOptions {
|
|
|
3702
3723
|
* ```
|
|
3703
3724
|
*/
|
|
3704
3725
|
declare class VibeAppBridge extends BridgeDataMethods {
|
|
3705
|
-
|
|
3726
|
+
/** Concrete origin to `postMessage` to, or `null` if none could be resolved. */
|
|
3727
|
+
private readonly targetOrigin;
|
|
3728
|
+
/** Validates an inbound `event.origin` against the configured allowlist. */
|
|
3729
|
+
private readonly isTrustedOrigin;
|
|
3706
3730
|
private readonly requestTimeout;
|
|
3707
3731
|
private readonly pendingRequests;
|
|
3708
3732
|
private readonly boundHandleMessage;
|
|
3709
3733
|
private context;
|
|
3710
3734
|
private connectPromise;
|
|
3711
3735
|
private connectPollInterval;
|
|
3736
|
+
private connectTimeout;
|
|
3712
3737
|
private onContextReceived;
|
|
3713
3738
|
private onContextError;
|
|
3714
3739
|
/**
|
|
@@ -3726,7 +3751,7 @@ declare class VibeAppBridge extends BridgeDataMethods {
|
|
|
3726
3751
|
private readonly kindHandlers;
|
|
3727
3752
|
private dispatchPush;
|
|
3728
3753
|
private readonly pushHandlers;
|
|
3729
|
-
constructor({ parentOrigin, requestTimeout }
|
|
3754
|
+
constructor({ parentOrigin, requestTimeout }?: VibeAppBridgeOptions);
|
|
3730
3755
|
/**
|
|
3731
3756
|
* Connects to the host and returns the tenant/user {@link ContextPayload}.
|
|
3732
3757
|
* **Call this once on app init and await it before any other method** — data
|
|
@@ -3842,6 +3867,12 @@ declare class VibeAppBridge extends BridgeDataMethods {
|
|
|
3842
3867
|
* ```
|
|
3843
3868
|
*/
|
|
3844
3869
|
request<K extends keyof RequestRegistry>(type: K, payload: RequestRegistry[K]['payload'], onProgress?: (payload: unknown) => void): Promise<RequestRegistry[K]['data']>;
|
|
3870
|
+
/**
|
|
3871
|
+
* Posts to the embedding host. No-op when no concrete target origin could be
|
|
3872
|
+
* resolved (a config/embedding error) — `connect()` rejects fast in that case
|
|
3873
|
+
* so calls never silently hang waiting on a timeout.
|
|
3874
|
+
*/
|
|
3875
|
+
private postToParent;
|
|
3845
3876
|
private handleMessage;
|
|
3846
3877
|
private handleResponse;
|
|
3847
3878
|
private handleProgress;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// package.json
|
|
2
|
-
var version = "0.
|
|
2
|
+
var version = "0.3.0";
|
|
3
3
|
|
|
4
4
|
// ../protocol-types/dist/index.js
|
|
5
5
|
function normalizeSubroute(subroute) {
|
|
@@ -44,6 +44,33 @@ var HttpBridgeError = class extends BridgeError {
|
|
|
44
44
|
this.name = "HttpBridgeError";
|
|
45
45
|
}
|
|
46
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
47
74
|
|
|
48
75
|
// src/data-methods.ts
|
|
49
76
|
var BridgeDataMethods = class {
|
|
@@ -362,13 +389,72 @@ function resolveRequestTimeout(type, requestTimeout) {
|
|
|
362
389
|
}
|
|
363
390
|
|
|
364
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
|
+
}
|
|
365
450
|
var VibeAppBridge = class extends BridgeDataMethods {
|
|
366
|
-
constructor({ parentOrigin, requestTimeout }) {
|
|
451
|
+
constructor({ parentOrigin, requestTimeout } = {}) {
|
|
367
452
|
super();
|
|
368
453
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
369
454
|
this.context = null;
|
|
370
455
|
this.connectPromise = null;
|
|
371
456
|
this.connectPollInterval = null;
|
|
457
|
+
this.connectTimeout = null;
|
|
372
458
|
this.onContextReceived = null;
|
|
373
459
|
this.onContextError = null;
|
|
374
460
|
/**
|
|
@@ -423,7 +509,9 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
423
509
|
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
424
510
|
}
|
|
425
511
|
};
|
|
426
|
-
|
|
512
|
+
const policy = resolveOriginPolicy(parentOrigin);
|
|
513
|
+
this.targetOrigin = policy.targetOrigin;
|
|
514
|
+
this.isTrustedOrigin = policy.isTrusted;
|
|
427
515
|
this.requestTimeout = requestTimeout;
|
|
428
516
|
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
429
517
|
window.addEventListener("message", this.boundHandleMessage);
|
|
@@ -459,6 +547,14 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
459
547
|
connect() {
|
|
460
548
|
if (this.context) return Promise.resolve(this.context);
|
|
461
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
|
+
}
|
|
462
558
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
463
559
|
const timer = setTimeout(() => {
|
|
464
560
|
this.stopConnectPolling();
|
|
@@ -467,6 +563,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
467
563
|
this.onContextError = null;
|
|
468
564
|
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
469
565
|
}, CONNECT_TIMEOUT_MS);
|
|
566
|
+
this.connectTimeout = timer;
|
|
470
567
|
this.onContextError = (error) => {
|
|
471
568
|
clearTimeout(timer);
|
|
472
569
|
this.stopConnectPolling();
|
|
@@ -509,7 +606,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
509
606
|
requestId,
|
|
510
607
|
payload: { bridgeVersion: version }
|
|
511
608
|
};
|
|
512
|
-
|
|
609
|
+
this.postToParent(message);
|
|
513
610
|
};
|
|
514
611
|
poll();
|
|
515
612
|
this.connectPollInterval = setInterval(poll, CONNECT_POLL_MS);
|
|
@@ -591,6 +688,7 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
591
688
|
}
|
|
592
689
|
destroy() {
|
|
593
690
|
this.teardownNavigationTracking();
|
|
691
|
+
this.onContextError?.(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
594
692
|
this.stopConnectPolling();
|
|
595
693
|
window.removeEventListener("message", this.boundHandleMessage);
|
|
596
694
|
for (const pending of this.pendingRequests.values()) {
|
|
@@ -609,6 +707,10 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
609
707
|
clearInterval(this.connectPollInterval);
|
|
610
708
|
this.connectPollInterval = null;
|
|
611
709
|
}
|
|
710
|
+
if (this.connectTimeout !== null) {
|
|
711
|
+
clearTimeout(this.connectTimeout);
|
|
712
|
+
this.connectTimeout = null;
|
|
713
|
+
}
|
|
612
714
|
this.connectPollIds.clear();
|
|
613
715
|
}
|
|
614
716
|
/**
|
|
@@ -665,10 +767,13 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
665
767
|
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
666
768
|
}
|
|
667
769
|
sendCommand(type, payload) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
770
|
+
this.postToParent({
|
|
771
|
+
__protocol: PROTOCOL_ID,
|
|
772
|
+
__version: PROTOCOL_VERSION,
|
|
773
|
+
kind: "command",
|
|
774
|
+
type,
|
|
775
|
+
payload
|
|
776
|
+
});
|
|
672
777
|
}
|
|
673
778
|
/**
|
|
674
779
|
* Low-level typed request for **any** operation in {@link RequestRegistry}.
|
|
@@ -711,11 +816,20 @@ var VibeAppBridge = class extends BridgeDataMethods {
|
|
|
711
816
|
requestId,
|
|
712
817
|
payload
|
|
713
818
|
};
|
|
714
|
-
|
|
819
|
+
this.postToParent(message);
|
|
715
820
|
});
|
|
716
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
|
+
}
|
|
717
831
|
handleMessage(event) {
|
|
718
|
-
if (event.origin
|
|
832
|
+
if (!this.isTrustedOrigin(event.origin)) return;
|
|
719
833
|
if (!isBridgeMessage(event.data)) return;
|
|
720
834
|
this.kindHandlers[event.data.kind]?.(event.data);
|
|
721
835
|
}
|
|
@@ -34,7 +34,7 @@ The host pushes context to the iframe on load, but the iframe's listener may not
|
|
|
34
34
|
4. The bridge begins auto-tracking SPA navigation (see below).
|
|
35
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -15,9 +15,7 @@ No runtime dependencies. Ships ESM + CJS with self-contained TypeScript types.
|
|
|
15
15
|
```ts
|
|
16
16
|
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
17
17
|
|
|
18
|
-
const bridge = new VibeAppBridge(
|
|
19
|
-
parentOrigin: import.meta.env.VITE_PARENT_ORIGIN,
|
|
20
|
-
})
|
|
18
|
+
const bridge = new VibeAppBridge() // auto-detects the embedding Nominal host
|
|
21
19
|
|
|
22
20
|
async function main() {
|
|
23
21
|
const ctx: ContextPayload = await bridge.connect()
|
|
@@ -32,20 +30,24 @@ async function main() {
|
|
|
32
30
|
main().catch(console.error)
|
|
33
31
|
```
|
|
34
32
|
|
|
35
|
-
## `parentOrigin`
|
|
33
|
+
## `parentOrigin` (optional)
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
By default you pass **nothing** — the bridge auto-detects the Nominal app that embeds your iframe and talks only to it. This is safe because Nominal serves Vibe Apps behind a `frame-ancestors` CSP, so only nom-ui can frame the app. Most apps need nothing here.
|
|
38
36
|
|
|
39
|
-
Drive it from an
|
|
37
|
+
Pass `parentOrigin` to **pin the host origin** explicitly (defense in depth, or when running outside Nominal's edge). It's an **origin** — scheme + host + port, no path, no trailing slash. Drive it from an env var, or use a list / **glob patterns** for dynamic preview deployments:
|
|
40
38
|
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
```ts
|
|
40
|
+
new VibeAppBridge({ parentOrigin: import.meta.env.VITE_PARENT_ORIGIN })
|
|
41
|
+
new VibeAppBridge({ parentOrigin: ['https://app.nominal.so', 'https://*.vercel.app'] })
|
|
42
|
+
```
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
```sh
|
|
45
|
+
# .env.local — when pinning explicitly while testing inside nom-ui locally
|
|
46
46
|
VITE_PARENT_ORIGIN=http://localhost:3000
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
A pattern's `*` matches exactly one DNS label or port (anchored, scheme literal). Patterns are honoured **only when the app's own page is served from a recognised preview host** (`*.vercel.app`, `*.lovable.app`, `localhost`, …); on a production custom domain they are ignored and only exact origins match. Keep at least one exact origin in the list.
|
|
50
|
+
|
|
49
51
|
## Next
|
|
50
52
|
|
|
51
53
|
- [`connect-lifecycle.md`](./connect-lifecycle.md) — how `connect()` works and the connection lifecycle.
|
package/llms.txt
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> Iframe-side TypeScript SDK for building Nominal Vibe Apps — standalone browser apps (often built with Lovable) embedded in the Nominal platform via a cross-origin iframe. Connects the app to its Nominal host over a typed postMessage protocol: read Nominal data, submit Close-Management task outputs, upload files, and keep deep-link routing in sync. Browser-only, zero runtime dependencies.
|
|
4
4
|
|
|
5
|
-
The integration is always the same:
|
|
5
|
+
The integration is always the same: `new VibeAppBridge()` (no config — `parentOrigin` is optional and the bridge auto-detects the embedding Nominal host; pass it only to pin explicitly or to allow preview origins), `await bridge.connect()` once (it returns the tenant/user `ContextPayload`), then call any of the ~46 typed data methods, `upload(file, options)`, or `postTaskOutput(...)`. Tear down with `bridge.destroy()`. The only write path into Nominal is `postTaskOutput`.
|
|
6
6
|
|
|
7
7
|
## Docs
|
|
8
8
|
|
|
9
9
|
- [README](./README.md): install, quickstart, full API, common mistakes, and the operation catalog.
|
|
10
|
-
- [Getting started](./docs/getting-started.md): install + minimal app + `parentOrigin`
|
|
10
|
+
- [Getting started](./docs/getting-started.md): install + minimal app + optional `parentOrigin` pinning.
|
|
11
11
|
- [Connection lifecycle](./docs/connect-lifecycle.md): `connect()` semantics, `ContextPayload`, timeout, subroute sync, teardown.
|
|
12
12
|
- [Fetching data](./docs/data-fetching.md): named methods vs the typed `request()` escape hatch, payload shapes, errors/timeouts, operation list.
|
|
13
13
|
- [File upload](./docs/file-upload.md): `upload(file, options)`, progress events, constraints.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nominalso/vibe-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Iframe-side SDK for building Nominal Vibe Apps — connects an embedded app to its Nominal host over a typed postMessage bridge (context, data fetch, file upload, subroute deep-linking).",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",
|