@nominalso/vibe-bridge 0.5.0 → 0.7.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 +13 -4
- package/README.md +45 -7
- package/dist/chunk-QAMP6I2C.js +405 -0
- package/dist/chunk-QAMP6I2C.js.map +1 -0
- package/dist/index.browser.cjs +1211 -0
- package/dist/index.browser.cjs.map +1 -0
- package/dist/index.browser.js +1172 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.cjs +198 -46
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +94 -3782
- package/dist/index.d.ts +94 -3782
- package/dist/index.js +176 -403
- package/dist/index.js.map +1 -0
- package/dist/index.server.cjs +448 -0
- package/dist/index.server.cjs.map +1 -0
- package/dist/index.server.d.cts +51 -0
- package/dist/index.server.d.ts +51 -0
- package/dist/index.server.js +84 -0
- package/dist/index.server.js.map +1 -0
- package/dist/version-Cufn3-b0.d.cts +3990 -0
- package/dist/version-Cufn3-b0.d.ts +3990 -0
- package/docs/getting-started.md +3 -1
- package/llms.txt +3 -1
- package/package.json +27 -30
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ../protocol-types/dist/index.js
|
|
4
|
+
function normalizeSubroute(subroute) {
|
|
5
|
+
const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
|
|
6
|
+
const path = normalized.split(/[?#]/)[0];
|
|
7
|
+
let decoded = path;
|
|
8
|
+
let prev;
|
|
9
|
+
do {
|
|
10
|
+
prev = decoded;
|
|
11
|
+
decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
|
|
12
|
+
} while (decoded !== prev);
|
|
13
|
+
if (decoded.split("/").some((segment) => segment === "..")) return null;
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
var PROTOCOL_ID = "nominal-vibe-bridge";
|
|
17
|
+
var PROTOCOL_VERSION = 2;
|
|
18
|
+
var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
|
|
19
|
+
var CORRELATED_KINDS = ["request", "response", "progress"];
|
|
20
|
+
var BRIDGE_KINDS_SET = new Set(BRIDGE_KINDS);
|
|
21
|
+
var CORRELATED_KINDS_SET = new Set(CORRELATED_KINDS);
|
|
22
|
+
function isBridgeMessage(data) {
|
|
23
|
+
if (typeof data !== "object" || data === null) return false;
|
|
24
|
+
const d = data;
|
|
25
|
+
if (d.__protocol !== PROTOCOL_ID) return false;
|
|
26
|
+
if (d.__version !== PROTOCOL_VERSION) return false;
|
|
27
|
+
if (typeof d.kind !== "string" || !BRIDGE_KINDS_SET.has(d.kind)) return false;
|
|
28
|
+
if (typeof d.type !== "string") return false;
|
|
29
|
+
if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
var BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:BridgeError");
|
|
33
|
+
var HTTP_BRIDGE_ERROR_BRAND = /* @__PURE__ */ Symbol.for("@nominalso/vibe-bridge:HttpBridgeError");
|
|
34
|
+
var BridgeError = class extends Error {
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "BridgeError";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Cross-realm-safe type guard. Prefer this over `instanceof BridgeError` when
|
|
42
|
+
* an error may have been thrown by a differently-built copy of the SDK (e.g.
|
|
43
|
+
* server bundle vs client bundle) — it matches on a process-global brand
|
|
44
|
+
* rather than class identity.
|
|
45
|
+
*/
|
|
46
|
+
static isBridgeError(err) {
|
|
47
|
+
return typeof err === "object" && err !== null && err[BRIDGE_ERROR_BRAND] === true;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
Object.defineProperty(BridgeError.prototype, BRIDGE_ERROR_BRAND, {
|
|
51
|
+
value: true,
|
|
52
|
+
enumerable: false
|
|
53
|
+
});
|
|
54
|
+
var HttpBridgeError = class extends BridgeError {
|
|
55
|
+
constructor(status, message) {
|
|
56
|
+
super("REQUEST_FAILED", message);
|
|
57
|
+
this.status = status;
|
|
58
|
+
this.name = "HttpBridgeError";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Cross-realm-safe type guard (see {@link BridgeError.isBridgeError}). Returns
|
|
62
|
+
* `true` only for `HttpBridgeError` instances, not plain `BridgeError`s.
|
|
63
|
+
*/
|
|
64
|
+
static isHttpBridgeError(err) {
|
|
65
|
+
return typeof err === "object" && err !== null && err[HTTP_BRIDGE_ERROR_BRAND] === true;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
Object.defineProperty(HttpBridgeError.prototype, HTTP_BRIDGE_ERROR_BRAND, {
|
|
69
|
+
value: true,
|
|
70
|
+
enumerable: false
|
|
71
|
+
});
|
|
72
|
+
function isOriginPattern(entry) {
|
|
73
|
+
return entry.includes("*");
|
|
74
|
+
}
|
|
75
|
+
var patternCache = /* @__PURE__ */ new Map();
|
|
76
|
+
function patternToRegExp(pattern) {
|
|
77
|
+
const cached = patternCache.get(pattern);
|
|
78
|
+
if (cached) return cached;
|
|
79
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
const body = escaped.replace(/\\\*/g, "[^./:@?#\\s]+");
|
|
81
|
+
const compiled = new RegExp(`^${body}$`);
|
|
82
|
+
patternCache.set(pattern, compiled);
|
|
83
|
+
return compiled;
|
|
84
|
+
}
|
|
85
|
+
function matchesOrigin(origin, entry) {
|
|
86
|
+
if (!isOriginPattern(entry)) return origin === entry;
|
|
87
|
+
return patternToRegExp(entry).test(origin);
|
|
88
|
+
}
|
|
89
|
+
function isOriginAllowed(origin, allowlist, { allowPatterns }) {
|
|
90
|
+
for (const entry of allowlist) {
|
|
91
|
+
if (isOriginPattern(entry)) {
|
|
92
|
+
if (allowPatterns && matchesOrigin(origin, entry)) return true;
|
|
93
|
+
} else if (origin === entry) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// package.json
|
|
101
|
+
var version = "0.7.0";
|
|
102
|
+
|
|
103
|
+
// src/version.ts
|
|
104
|
+
var BRIDGE_VERSION = version;
|
|
105
|
+
|
|
106
|
+
// src/data-methods.ts
|
|
107
|
+
var BridgeDataMethods = class {
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Operations — Accounting: chart of accounts
|
|
110
|
+
//
|
|
111
|
+
// Each method is a typed wrapper over `request('<OP>', payload)`. The payload
|
|
112
|
+
// and return types come straight from the Nominal API (`RequestRegistry`).
|
|
113
|
+
// For any operation without a named method, call `request('<OP>', payload)`.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/**
|
|
116
|
+
* Fetch the flat chart of accounts for a subsidiary.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* const ctx = await bridge.connect()
|
|
121
|
+
* const accounts = await bridge.getChartOfAccounts({
|
|
122
|
+
* path: { subsidiary_id: ctx.subsidiaryId },
|
|
123
|
+
* })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
getChartOfAccounts(payload) {
|
|
127
|
+
return this.request("GET_CHART_OF_ACCOUNTS", payload);
|
|
128
|
+
}
|
|
129
|
+
/** Fetch the chart of accounts as a hierarchical tree for a subsidiary. */
|
|
130
|
+
getCoaTree(payload) {
|
|
131
|
+
return this.request("GET_COA_TREE", payload);
|
|
132
|
+
}
|
|
133
|
+
/** Fetch a simplified flat chart of accounts for a subsidiary. */
|
|
134
|
+
getCoaFlatSimple(payload) {
|
|
135
|
+
return this.request("GET_COA_FLAT_SIMPLE", payload);
|
|
136
|
+
}
|
|
137
|
+
/** Fetch the flat chart of accounts grouped by account group. */
|
|
138
|
+
getCoaGrouped(payload) {
|
|
139
|
+
return this.request("GET_COA_GROUPED", payload);
|
|
140
|
+
}
|
|
141
|
+
/** Fetch a single chart-of-accounts account by id within a subsidiary. */
|
|
142
|
+
getCoaAccount(payload) {
|
|
143
|
+
return this.request("GET_COA_ACCOUNT", payload);
|
|
144
|
+
}
|
|
145
|
+
/** List the currencies available to a subsidiary's chart of accounts. */
|
|
146
|
+
getSubsidiaryAvailableCurrencies(payload) {
|
|
147
|
+
return this.request("GET_SUBSIDIARY_AVAILABLE_CURRENCIES", payload);
|
|
148
|
+
}
|
|
149
|
+
/** Fetch accounts at the tenant level. */
|
|
150
|
+
getAccounts(payload) {
|
|
151
|
+
return this.request("GET_ACCOUNTS", payload);
|
|
152
|
+
}
|
|
153
|
+
/** Fetch a single account by id. */
|
|
154
|
+
getAccount(payload) {
|
|
155
|
+
return this.request("GET_ACCOUNT", payload);
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Operations — Accounting: exchange rates
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/** Fetch currency conversion rates for a currency. */
|
|
161
|
+
getConversionRates(payload) {
|
|
162
|
+
return this.request("GET_CONVERSION_RATES", payload);
|
|
163
|
+
}
|
|
164
|
+
/** Fetch the effective consolidation exchange rate. */
|
|
165
|
+
getEffectiveExchangeRate(payload) {
|
|
166
|
+
return this.request("GET_EFFECTIVE_EXCHANGE_RATE", payload);
|
|
167
|
+
}
|
|
168
|
+
/** Fetch the exchange-rate period covering a given input date. */
|
|
169
|
+
getExchangeRateByDate(payload) {
|
|
170
|
+
return this.request("GET_EXCHANGE_RATE_BY_DATE", payload);
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Operations — Accounting: dimensions
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/** List accounting dimensions. */
|
|
176
|
+
getDimensions(payload) {
|
|
177
|
+
return this.request("GET_DIMENSIONS", payload);
|
|
178
|
+
}
|
|
179
|
+
/** List values for accounting dimensions. */
|
|
180
|
+
getDimensionValues(payload) {
|
|
181
|
+
return this.request("GET_DIMENSION_VALUES", payload);
|
|
182
|
+
}
|
|
183
|
+
/** List dimension values in hierarchical form. */
|
|
184
|
+
getDimensionValuesHierarchical(payload) {
|
|
185
|
+
return this.request("GET_DIMENSION_VALUES_HIERARCHICAL", payload);
|
|
186
|
+
}
|
|
187
|
+
/** List account assignments for a dimension/account pair. */
|
|
188
|
+
getDimensionAccountAssignments(payload) {
|
|
189
|
+
return this.request("GET_DIMENSION_ACCOUNT_ASSIGNMENTS", payload);
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Operations — Accounting: journal entries
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
/**
|
|
195
|
+
* Fetch journal entries for a subsidiary, optionally filtered by date range.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* const entries = await bridge.getJournalEntries({
|
|
200
|
+
* path: { subsidiary_id: ctx.subsidiaryId },
|
|
201
|
+
* query: { from_date: '2026-01-01', to_date: '2026-03-31' },
|
|
202
|
+
* })
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
getJournalEntries(payload) {
|
|
206
|
+
return this.request("GET_JOURNAL_ENTRIES", payload);
|
|
207
|
+
}
|
|
208
|
+
/** Fetch journal entry lines for a subsidiary. */
|
|
209
|
+
getJournalLines(payload) {
|
|
210
|
+
return this.request("GET_JOURNAL_LINES", payload);
|
|
211
|
+
}
|
|
212
|
+
/** Fetch a single journal entry by id within a subsidiary. */
|
|
213
|
+
getJournalEntry(payload) {
|
|
214
|
+
return this.request("GET_JOURNAL_ENTRY", payload);
|
|
215
|
+
}
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Operations — Activity: period instances
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
/** List accounting period instances. */
|
|
220
|
+
getPeriods(payload) {
|
|
221
|
+
return this.request("GET_PERIODS", payload);
|
|
222
|
+
}
|
|
223
|
+
/** Fetch a single period instance by id. */
|
|
224
|
+
getPeriodInstance(payload) {
|
|
225
|
+
return this.request("GET_PERIOD_INSTANCE", payload);
|
|
226
|
+
}
|
|
227
|
+
/** Fetch a period instance by its display slug (e.g. `"mar-2026"`). */
|
|
228
|
+
getPeriodInstanceBySlug(payload) {
|
|
229
|
+
return this.request("GET_PERIOD_INSTANCE_BY_SLUG", payload);
|
|
230
|
+
}
|
|
231
|
+
/** Fetch the close-progress breakdown for a period (by slug). */
|
|
232
|
+
getPeriodProgressBreakdown(payload) {
|
|
233
|
+
return this.request("GET_PERIOD_PROGRESS_BREAKDOWN", payload);
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Operations — Activity: activity definitions
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/** List activity definitions. */
|
|
239
|
+
getActivityDefinitions(payload) {
|
|
240
|
+
return this.request("GET_ACTIVITY_DEFINITIONS", payload);
|
|
241
|
+
}
|
|
242
|
+
/** Fetch a single activity definition by id. */
|
|
243
|
+
getActivityDefinition(payload) {
|
|
244
|
+
return this.request("GET_ACTIVITY_DEFINITION", payload);
|
|
245
|
+
}
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Operations — Activity: activity instances
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
/** List activity instances. */
|
|
250
|
+
getActivityInstances(payload) {
|
|
251
|
+
return this.request("GET_ACTIVITY_INSTANCES", payload);
|
|
252
|
+
}
|
|
253
|
+
/** Fetch a single activity instance by id. */
|
|
254
|
+
getActivityInstance(payload) {
|
|
255
|
+
return this.request("GET_ACTIVITY_INSTANCE", payload);
|
|
256
|
+
}
|
|
257
|
+
/** Fetch an activity instance for a given period + activity definition. */
|
|
258
|
+
getActivityInstanceByPeriod(payload) {
|
|
259
|
+
return this.request("GET_ACTIVITY_INSTANCE_BY_PERIOD", payload);
|
|
260
|
+
}
|
|
261
|
+
/** List task instances for an activity instance. */
|
|
262
|
+
getActivityInstanceTasks(payload) {
|
|
263
|
+
return this.request("GET_ACTIVITY_INSTANCE_TASKS", payload);
|
|
264
|
+
}
|
|
265
|
+
/** List task instances for an activity in a given period. */
|
|
266
|
+
getActivityPeriodTasks(payload) {
|
|
267
|
+
return this.request("GET_ACTIVITY_PERIOD_TASKS", payload);
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Operations — Activity: task definitions
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
/** List task definitions. */
|
|
273
|
+
getTaskDefinitions(payload) {
|
|
274
|
+
return this.request("GET_TASK_DEFINITIONS", payload);
|
|
275
|
+
}
|
|
276
|
+
/** Fetch a single task definition by id. */
|
|
277
|
+
getTaskDefinition(payload) {
|
|
278
|
+
return this.request("GET_TASK_DEFINITION", payload);
|
|
279
|
+
}
|
|
280
|
+
/** Create a task definition. */
|
|
281
|
+
createTaskDefinition(payload) {
|
|
282
|
+
return this.request("CREATE_TASK_DEFINITION", payload);
|
|
283
|
+
}
|
|
284
|
+
/** Update a task definition by id. */
|
|
285
|
+
updateTaskDefinition(payload) {
|
|
286
|
+
return this.request("UPDATE_TASK_DEFINITION", payload);
|
|
287
|
+
}
|
|
288
|
+
/** Query task definitions by filter. */
|
|
289
|
+
getTaskDefinitionsByFilter(payload) {
|
|
290
|
+
return this.request("GET_TASK_DEFINITIONS_BY_FILTER", payload);
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Operations — Activity: task instances
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
/** List task instances. */
|
|
296
|
+
getTaskInstances(payload) {
|
|
297
|
+
return this.request("GET_TASK_INSTANCES", payload);
|
|
298
|
+
}
|
|
299
|
+
/** Fetch a single task instance by id. */
|
|
300
|
+
getTaskInstance(payload) {
|
|
301
|
+
return this.request("GET_TASK_INSTANCE", payload);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Submit a Close-Management task output. This is the **only write path** back
|
|
305
|
+
* into Nominal — Vibe Apps never write Nominal data directly.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* // `body` carries the task-specific output shape.
|
|
310
|
+
* await bridge.postTaskOutput({
|
|
311
|
+
* path: { task_instance_id: 'task-1' },
|
|
312
|
+
* body: {},
|
|
313
|
+
* })
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
postTaskOutput(payload) {
|
|
317
|
+
return this.request("POST_TASK_OUTPUT", payload);
|
|
318
|
+
}
|
|
319
|
+
/** Query task instances by filter. */
|
|
320
|
+
getTaskInstancesByFilter(payload) {
|
|
321
|
+
return this.request("GET_TASK_INSTANCES_BY_FILTER", payload);
|
|
322
|
+
}
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Operations — Audit trail
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
/** List audit-trail events. */
|
|
327
|
+
getAuditEvents(payload) {
|
|
328
|
+
return this.request("GET_AUDIT_EVENTS", payload);
|
|
329
|
+
}
|
|
330
|
+
/** List audit-trail events for a specific entity. */
|
|
331
|
+
getEntityAuditEvents(payload) {
|
|
332
|
+
return this.request("GET_ENTITY_AUDIT_EVENTS", payload);
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Operations — Period manager: fiscal calendars
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
/** List fiscal calendars for a subsidiary. */
|
|
338
|
+
getFiscalCalendars(payload) {
|
|
339
|
+
return this.request("GET_FISCAL_CALENDARS", payload);
|
|
340
|
+
}
|
|
341
|
+
/** Fetch a single fiscal calendar by id. */
|
|
342
|
+
getFiscalCalendar(payload) {
|
|
343
|
+
return this.request("GET_FISCAL_CALENDAR", payload);
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Operations — Tenancy
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
/** List subsidiaries for the tenant. */
|
|
349
|
+
getSubsidiaries(payload) {
|
|
350
|
+
return this.request("GET_SUBSIDIARIES", payload);
|
|
351
|
+
}
|
|
352
|
+
/** Fetch a single subsidiary by id. */
|
|
353
|
+
getSubsidiary(payload) {
|
|
354
|
+
return this.request("GET_SUBSIDIARY", payload);
|
|
355
|
+
}
|
|
356
|
+
/** List parent currencies for a subsidiary. */
|
|
357
|
+
getSubsidiaryParentCurrencies(payload) {
|
|
358
|
+
return this.request("GET_SUBSIDIARY_PARENT_CURRENCIES", payload);
|
|
359
|
+
}
|
|
360
|
+
/** List users for the tenant. */
|
|
361
|
+
getTenantUsers(payload) {
|
|
362
|
+
return this.request("GET_TENANT_USERS", payload);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Uploads a file through the host. Converts the `File` to an `ArrayBuffer`
|
|
366
|
+
* before sending — sandboxed iframes cannot pass `File` handles across
|
|
367
|
+
* windows. Resolves once Nominal has stored the file.
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* ```ts
|
|
371
|
+
* const input = document.querySelector<HTMLInputElement>('#file')!
|
|
372
|
+
* const file = input.files![0]
|
|
373
|
+
* const result = await bridge.upload(file, {
|
|
374
|
+
* entityType: 'JOURNAL_ENTRY',
|
|
375
|
+
* entityId: '123',
|
|
376
|
+
* onProgress: (p) => console.log(`${p.progress}%`),
|
|
377
|
+
* })
|
|
378
|
+
* // result.attachmentId, result.name
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
async upload(file, options) {
|
|
382
|
+
const buffer = await file.arrayBuffer();
|
|
383
|
+
const payload = {
|
|
384
|
+
buffer,
|
|
385
|
+
fileName: file.name,
|
|
386
|
+
fileType: file.type,
|
|
387
|
+
entityType: options.entityType,
|
|
388
|
+
entityId: options.entityId
|
|
389
|
+
};
|
|
390
|
+
const onProgress = options.onProgress ? (p) => options.onProgress(p) : void 0;
|
|
391
|
+
return this.request("UPLOAD_FILE", payload, onProgress);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// src/timeouts.ts
|
|
396
|
+
var CONNECT_TIMEOUT_MS = 1e4;
|
|
397
|
+
var CONNECT_POLL_MS = 500;
|
|
398
|
+
var DEFAULT_TIMEOUTS = {
|
|
399
|
+
/** Typed Nominal API reads/writes (`GET_*` / `POST_*` / `CREATE_*` / ...). */
|
|
400
|
+
api: 3e4,
|
|
401
|
+
/** `INVALIDATE_CACHE`. */
|
|
402
|
+
invalidate: 1e4,
|
|
403
|
+
/** `UPLOAD_FILE` — large payloads + presigned S3 PUT take a while. */
|
|
404
|
+
upload: 12e4
|
|
405
|
+
};
|
|
406
|
+
function resolveRequestTimeout(type, requestTimeout) {
|
|
407
|
+
if (requestTimeout !== void 0) return requestTimeout;
|
|
408
|
+
switch (type) {
|
|
409
|
+
case "UPLOAD_FILE":
|
|
410
|
+
return DEFAULT_TIMEOUTS.upload;
|
|
411
|
+
case "INVALIDATE_CACHE":
|
|
412
|
+
return DEFAULT_TIMEOUTS.invalidate;
|
|
413
|
+
default:
|
|
414
|
+
return DEFAULT_TIMEOUTS.api;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/VibeAppBridge.ts
|
|
419
|
+
function resolvePosthog(mod) {
|
|
420
|
+
const hasInit = (o) => typeof o?.init === "function";
|
|
421
|
+
const d1 = mod?.default;
|
|
422
|
+
if (hasInit(d1)) return d1;
|
|
423
|
+
const d2 = d1?.default;
|
|
424
|
+
if (hasInit(d2)) return d2;
|
|
425
|
+
if (hasInit(mod)) return mod;
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
function serverEnvError(method) {
|
|
429
|
+
return new BridgeError(
|
|
430
|
+
"SERVER_ENVIRONMENT",
|
|
431
|
+
`VibeAppBridge.${method} was called outside a browser (no \`window\`). The bridge only runs in the browser \u2014 call it from a client component or effect, not during SSR, in a React Server Component, or in a Worker.`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
function assertBrowser(method) {
|
|
435
|
+
if (typeof window === "undefined") throw serverEnvError(method);
|
|
436
|
+
}
|
|
437
|
+
var PARENT_ORIGIN_OVERRIDE_KEY = "__NOM_VIBE_PARENT_ORIGIN__";
|
|
438
|
+
var PREVIEW_HOST_RE = /(?:^|\.)(?:vercel\.app|lovable\.app|lovable\.dev|lovableproject\.com)$|^(?:localhost|127\.0\.0\.1)$/;
|
|
439
|
+
function isPreviewSelfHost(hostname) {
|
|
440
|
+
return PREVIEW_HOST_RE.test(hostname);
|
|
441
|
+
}
|
|
442
|
+
function resolveActualParentOrigin() {
|
|
443
|
+
if (typeof window === "undefined") return null;
|
|
444
|
+
try {
|
|
445
|
+
const ancestors = window.location.ancestorOrigins;
|
|
446
|
+
if (ancestors && ancestors.length > 0 && ancestors[0]) return ancestors[0];
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
if (document.referrer) return new URL(document.referrer).origin;
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
function resolveOriginPolicy(configured) {
|
|
456
|
+
if (typeof window !== "undefined") {
|
|
457
|
+
const override = window[PARENT_ORIGIN_OVERRIDE_KEY];
|
|
458
|
+
if (typeof override === "string" && override.length > 0) {
|
|
459
|
+
console.log(`[vibe-bridge] Using dev-bridge parentOrigin override: ${override}`);
|
|
460
|
+
return { targetOrigin: override, isTrusted: (o) => o === override };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const rawList = configured === void 0 ? [] : Array.isArray(configured) ? configured : [configured];
|
|
464
|
+
const allowlist = rawList.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
465
|
+
if (allowlist.length === 0) {
|
|
466
|
+
const actual2 = resolveActualParentOrigin();
|
|
467
|
+
if (actual2) return { targetOrigin: actual2, isTrusted: (o) => o === actual2 };
|
|
468
|
+
console.warn(
|
|
469
|
+
"[vibe-bridge] No `parentOrigin` set and the embedding host could not be auto-detected (no ancestorOrigins/referrer). Pass `parentOrigin` explicitly."
|
|
470
|
+
);
|
|
471
|
+
return { targetOrigin: null, isTrusted: () => false };
|
|
472
|
+
}
|
|
473
|
+
const exactEntries = allowlist.filter((entry) => !isOriginPattern(entry));
|
|
474
|
+
const hasPatterns = exactEntries.length !== allowlist.length;
|
|
475
|
+
if (allowlist.length === 1 && !hasPatterns) {
|
|
476
|
+
const only = allowlist[0];
|
|
477
|
+
return { targetOrigin: only, isTrusted: (o) => o === only };
|
|
478
|
+
}
|
|
479
|
+
const allowPatterns = hasPatterns && typeof window !== "undefined" && isPreviewSelfHost(window.location.hostname);
|
|
480
|
+
const isTrusted = (o) => isOriginAllowed(o, allowlist, { allowPatterns });
|
|
481
|
+
const actual = resolveActualParentOrigin();
|
|
482
|
+
let targetOrigin = null;
|
|
483
|
+
if (actual && isTrusted(actual)) {
|
|
484
|
+
targetOrigin = actual;
|
|
485
|
+
} else if (actual === null && !allowPatterns && exactEntries.length === 1) {
|
|
486
|
+
targetOrigin = exactEntries[0];
|
|
487
|
+
}
|
|
488
|
+
if (targetOrigin === null) {
|
|
489
|
+
console.warn(
|
|
490
|
+
"[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." : ".")
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return { targetOrigin, isTrusted };
|
|
494
|
+
}
|
|
495
|
+
var VibeAppBridge = class extends BridgeDataMethods {
|
|
496
|
+
/**
|
|
497
|
+
* Stores options only — **pure and side-effect-free**. No `window`,
|
|
498
|
+
* `document`, `history`, `postMessage`, `crypto`, or `posthog-js` access
|
|
499
|
+
* happens here, so constructing a bridge is safe during SSR / in a Server
|
|
500
|
+
* Component / in a Worker. Browser wiring is deferred to {@link connect} /
|
|
501
|
+
* {@link attach}.
|
|
502
|
+
*/
|
|
503
|
+
constructor(options = {}) {
|
|
504
|
+
super();
|
|
505
|
+
/** Concrete origin to `postMessage` to, or `null` until resolved at {@link attach}. */
|
|
506
|
+
this.targetOrigin = null;
|
|
507
|
+
/** Validates an inbound `event.origin`; rejects everything until {@link attach}. */
|
|
508
|
+
this.isTrustedOrigin = () => false;
|
|
509
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
510
|
+
/** Whether {@link attach} has installed the message listener + resolved origin. */
|
|
511
|
+
this.attached = false;
|
|
512
|
+
this.context = null;
|
|
513
|
+
/**
|
|
514
|
+
* Set once {@link initPostHog} has successfully called `posthog.init()`.
|
|
515
|
+
* Guards against a double-init and makes {@link track} a no-op until
|
|
516
|
+
* recording is actually live.
|
|
517
|
+
*/
|
|
518
|
+
this.posthogInitialized = false;
|
|
519
|
+
/** The lazily-imported `posthog-js` singleton, once recording is initialized. */
|
|
520
|
+
this.posthogInstance = null;
|
|
521
|
+
/**
|
|
522
|
+
* Set for the duration of an in-flight {@link initPostHog} (which is async — it
|
|
523
|
+
* awaits `import('posthog-js')`). Serializes initialization so two overlapping
|
|
524
|
+
* `POSTHOG_PUSH`es can't both call `posthog.init()`.
|
|
525
|
+
*/
|
|
526
|
+
this.posthogIniting = false;
|
|
527
|
+
/** So the "posthog-js not installed" warning is logged at most once. */
|
|
528
|
+
this.posthogUnavailableWarned = false;
|
|
529
|
+
this.connectPromise = null;
|
|
530
|
+
this.connectPollInterval = null;
|
|
531
|
+
this.connectTimeout = null;
|
|
532
|
+
this.onContextReceived = null;
|
|
533
|
+
/** Rejects an in-flight connect() (used by destroy()); `null` once settled. */
|
|
534
|
+
this.rejectConnect = null;
|
|
535
|
+
/**
|
|
536
|
+
* Persistent subscriber for post-connect context changes (tenant/subsidiary
|
|
537
|
+
* switch, period close, …). Distinct from {@link onContextReceived}, which only
|
|
538
|
+
* resolves the connect handshake and is cleared afterward. `null` until
|
|
539
|
+
* {@link onContextChange} is called.
|
|
540
|
+
*/
|
|
541
|
+
this.contextChangeCallback = null;
|
|
542
|
+
/**
|
|
543
|
+
* Persistent subscriber for host auth changes (Nominal logout or
|
|
544
|
+
* identity/tenant switch). `null` until {@link onAuthChange} is called.
|
|
545
|
+
*/
|
|
546
|
+
this.authChangeCallback = null;
|
|
547
|
+
this.lastReportedSubroute = null;
|
|
548
|
+
this.suppressSubrouteReport = false;
|
|
549
|
+
this.subrouteCallback = null;
|
|
550
|
+
this.origPushState = null;
|
|
551
|
+
this.origReplaceState = null;
|
|
552
|
+
this.popstateHandler = null;
|
|
553
|
+
this.kindHandlers = {
|
|
554
|
+
response: (msg) => this.handleResponse(msg),
|
|
555
|
+
push: (msg) => this.dispatchPush(msg),
|
|
556
|
+
progress: (msg) => this.handleProgress(msg)
|
|
557
|
+
};
|
|
558
|
+
this.pushHandlers = {
|
|
559
|
+
CONTEXT_PUSH: (payload) => {
|
|
560
|
+
if (this.onContextReceived) {
|
|
561
|
+
this.onContextReceived(payload);
|
|
562
|
+
} else if (this.context) {
|
|
563
|
+
this.context = payload;
|
|
564
|
+
this.contextChangeCallback?.(payload);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
// Persistent (not nulled after connect): the host delivers PostHog once at
|
|
568
|
+
// connect via its own push, and initPostHog is idempotent, so this can run
|
|
569
|
+
// whenever the message arrives, independent of how connect() resolved. Fire
|
|
570
|
+
// and forget — initPostHog is async (lazy-imports posthog-js) and swallows
|
|
571
|
+
// its own errors.
|
|
572
|
+
POSTHOG_PUSH: (payload) => {
|
|
573
|
+
void this.initPostHog(payload);
|
|
574
|
+
},
|
|
575
|
+
// Persistent: the host pushes auth changes (logout, identity/tenant
|
|
576
|
+
// switch) at any time after connect.
|
|
577
|
+
AUTH_CHANGED: (payload) => this.authChangeCallback?.(payload),
|
|
578
|
+
SUBROUTE_PUSH: (payload) => {
|
|
579
|
+
const safe = normalizeSubroute(payload.subroute);
|
|
580
|
+
if (!safe) return;
|
|
581
|
+
this.withSubrouteSuppressed(() => {
|
|
582
|
+
if (this.subrouteCallback) {
|
|
583
|
+
this.subrouteCallback(safe);
|
|
584
|
+
} else {
|
|
585
|
+
history.pushState(null, "", safe);
|
|
586
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
587
|
+
}
|
|
588
|
+
this.lastReportedSubroute = safe;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Commands targeting the host's UI chrome. All fire-and-forget.
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* ```ts
|
|
597
|
+
* bridge.ui.setSideNav({ collapsed: true })
|
|
598
|
+
* bridge.ui.switchSubsidiary(42)
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
this.ui = {
|
|
602
|
+
/** Collapse or expand the host's side navigation. */
|
|
603
|
+
setSideNav: (payload) => {
|
|
604
|
+
assertBrowser("ui.setSideNav()");
|
|
605
|
+
this.sendCommand("SET_SIDE_NAV", payload);
|
|
606
|
+
},
|
|
607
|
+
/** Switch the host to a different subsidiary. */
|
|
608
|
+
switchSubsidiary: (subsidiaryId) => {
|
|
609
|
+
assertBrowser("ui.switchSubsidiary()");
|
|
610
|
+
this.sendCommand("SWITCH_SUBSIDIARY", { subsidiaryId });
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
this.parentOriginOption = options.parentOrigin;
|
|
614
|
+
this.requestTimeout = options.requestTimeout;
|
|
615
|
+
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
616
|
+
}
|
|
617
|
+
dispatchPush(msg) {
|
|
618
|
+
const handler = this.pushHandlers[msg.type];
|
|
619
|
+
handler(msg.payload);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Installs the browser wiring: resolves the parent-origin policy and starts
|
|
623
|
+
* listening for host `postMessage`s. **Idempotent** and safe to call before
|
|
624
|
+
* {@link connect} if you want the bridge receiving pushes early; {@link
|
|
625
|
+
* connect} calls it for you, so most apps never call this directly.
|
|
626
|
+
*
|
|
627
|
+
* @throws `BridgeError` code `'SERVER_ENVIRONMENT'` if called where there is no
|
|
628
|
+
* `window`.
|
|
629
|
+
*/
|
|
630
|
+
attach() {
|
|
631
|
+
assertBrowser("attach()");
|
|
632
|
+
if (this.attached) return;
|
|
633
|
+
const policy = resolveOriginPolicy(this.parentOriginOption);
|
|
634
|
+
this.targetOrigin = policy.targetOrigin;
|
|
635
|
+
this.isTrustedOrigin = policy.isTrusted;
|
|
636
|
+
window.addEventListener("message", this.boundHandleMessage);
|
|
637
|
+
this.attached = true;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Lazily installs the browser wiring on first transport use. Keeps the
|
|
641
|
+
* constructor pure while ensuring that any method reaching the wire (a data
|
|
642
|
+
* `request`, a fire-and-forget command) works without an explicit prior
|
|
643
|
+
* `attach()`/`connect()` — attach stays idempotent, so `connect()` is
|
|
644
|
+
* unaffected.
|
|
645
|
+
*/
|
|
646
|
+
ensureAttached() {
|
|
647
|
+
if (!this.attached) this.attach();
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Connects to the host and returns the tenant/user {@link ContextPayload}.
|
|
651
|
+
* **Call this once on app init and await it before any other method** — data
|
|
652
|
+
* methods, `upload()`, and subroute reporting all require an established
|
|
653
|
+
* connection. Concurrent calls return the same promise.
|
|
654
|
+
*
|
|
655
|
+
* First {@link attach | attaches} the browser wiring (message listener +
|
|
656
|
+
* origin resolution), then sends a `CONNECT` handshake, re-sending every 500ms
|
|
657
|
+
* until the host replies by pushing the context (so it works even if the host
|
|
658
|
+
* mounts after the iframe loads). Context is only ever delivered by push.
|
|
659
|
+
* Rejects with `Bridge connect timed out` after 10 seconds if no context
|
|
660
|
+
* arrives (usually a `parentOrigin` mismatch or the host hasn't mounted).
|
|
661
|
+
*
|
|
662
|
+
* After connecting, if the context includes an initial subroute (e.g. from
|
|
663
|
+
* a deep link), the bridge navigates to it and starts auto-tracking
|
|
664
|
+
* internal navigation via the history API.
|
|
665
|
+
*
|
|
666
|
+
* @returns The tenant/user/subsidiary context for this app session.
|
|
667
|
+
* @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
|
|
668
|
+
* where there is no `window`, `'PARENT_ORIGIN_UNRESOLVED'` if no embedding
|
|
669
|
+
* origin could be resolved, or `'TIMEOUT'` if no context arrives in 10s.
|
|
670
|
+
* @example
|
|
671
|
+
* ```ts
|
|
672
|
+
* const ctx = await bridge.connect()
|
|
673
|
+
* // ctx.tenant, ctx.subsidiaryId, ctx.subsidiaries, ctx.user, ctx.lastClosedPeriodSlug
|
|
674
|
+
* ```
|
|
675
|
+
*/
|
|
676
|
+
connect() {
|
|
677
|
+
if (typeof window === "undefined") return Promise.reject(serverEnvError("connect()"));
|
|
678
|
+
if (this.context) return Promise.resolve(this.context);
|
|
679
|
+
if (this.connectPromise) return this.connectPromise;
|
|
680
|
+
this.attach();
|
|
681
|
+
if (this.targetOrigin === null) {
|
|
682
|
+
return Promise.reject(
|
|
683
|
+
new BridgeError(
|
|
684
|
+
"PARENT_ORIGIN_UNRESOLVED",
|
|
685
|
+
"Could not resolve a parent origin to connect to. Check `parentOrigin` against the embedding host."
|
|
686
|
+
)
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
690
|
+
const timer = setTimeout(() => {
|
|
691
|
+
this.stopConnectPolling();
|
|
692
|
+
this.connectPromise = null;
|
|
693
|
+
this.onContextReceived = null;
|
|
694
|
+
this.rejectConnect = null;
|
|
695
|
+
reject(new BridgeError("TIMEOUT", "Bridge connect timed out"));
|
|
696
|
+
}, CONNECT_TIMEOUT_MS);
|
|
697
|
+
this.connectTimeout = timer;
|
|
698
|
+
this.rejectConnect = (error) => {
|
|
699
|
+
clearTimeout(timer);
|
|
700
|
+
this.stopConnectPolling();
|
|
701
|
+
this.connectPromise = null;
|
|
702
|
+
this.onContextReceived = null;
|
|
703
|
+
this.rejectConnect = null;
|
|
704
|
+
reject(error);
|
|
705
|
+
};
|
|
706
|
+
this.onContextReceived = (ctx) => {
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
this.stopConnectPolling();
|
|
709
|
+
this.context = ctx;
|
|
710
|
+
this.connectPromise = null;
|
|
711
|
+
this.onContextReceived = null;
|
|
712
|
+
this.rejectConnect = null;
|
|
713
|
+
console.log(
|
|
714
|
+
`[vibe-bridge] Connected \u2014 bridge: ${BRIDGE_VERSION}, host: ${ctx.hostVersion}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
|
|
715
|
+
);
|
|
716
|
+
const initialSubroute = ctx.subroute ? normalizeSubroute(ctx.subroute) : null;
|
|
717
|
+
if (initialSubroute && initialSubroute !== "/") {
|
|
718
|
+
this.withSubrouteSuppressed(() => {
|
|
719
|
+
history.replaceState(null, "", initialSubroute);
|
|
720
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
721
|
+
this.lastReportedSubroute = initialSubroute;
|
|
722
|
+
});
|
|
723
|
+
} else {
|
|
724
|
+
this.lastReportedSubroute = window.location.pathname + window.location.search;
|
|
725
|
+
}
|
|
726
|
+
this.setupNavigationTracking();
|
|
727
|
+
this.warnMissingListeners();
|
|
728
|
+
resolve(ctx);
|
|
729
|
+
};
|
|
730
|
+
const sendConnect = () => {
|
|
731
|
+
this.sendCommand("CONNECT", { bridgeVersion: BRIDGE_VERSION });
|
|
732
|
+
};
|
|
733
|
+
sendConnect();
|
|
734
|
+
this.connectPollInterval = setInterval(sendConnect, CONNECT_POLL_MS);
|
|
735
|
+
});
|
|
736
|
+
return this.connectPromise;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Manually reports the current subroute to the host.
|
|
740
|
+
* Usually not needed — navigation is auto-detected via the history API.
|
|
741
|
+
* Use this for hash-based routers or non-standard navigation patterns.
|
|
742
|
+
*/
|
|
743
|
+
reportSubroute(subroute, options) {
|
|
744
|
+
assertBrowser("reportSubroute()");
|
|
745
|
+
const normalized = normalizeSubroute(subroute);
|
|
746
|
+
if (normalized === null) return;
|
|
747
|
+
this.lastReportedSubroute = normalized;
|
|
748
|
+
this.sendCommand("REPORT_SUBROUTE", { subroute: normalized, replace: options?.replace });
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Registers a callback for host-initiated navigation (browser back/forward).
|
|
752
|
+
* If no callback is registered, the SDK uses `history.pushState` + a
|
|
753
|
+
* `popstate` event, which works for most SPA routers automatically.
|
|
754
|
+
* Returns an unsubscribe function.
|
|
755
|
+
*
|
|
756
|
+
* Pure — only stores the callback, so it is safe to call before {@link connect}.
|
|
757
|
+
*/
|
|
758
|
+
onSubrouteRequest(callback) {
|
|
759
|
+
this.subrouteCallback = callback;
|
|
760
|
+
return () => {
|
|
761
|
+
this.subrouteCallback = null;
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Subscribes to post-connect context changes (tenant/subsidiary switch, period
|
|
766
|
+
* close, …). The callback fires for each {@link ContextPayload} the host pushes
|
|
767
|
+
* *after* connect — not for the initial context, which {@link connect} already
|
|
768
|
+
* returns. Returns an unsubscribe function. Single subscriber: a second call
|
|
769
|
+
* replaces the first. Wire it **before {@link connect}** (the bridge warns at
|
|
770
|
+
* connect if it isn't).
|
|
771
|
+
*
|
|
772
|
+
* Pure — only stores the callback, so it is safe to call before {@link connect}.
|
|
773
|
+
*/
|
|
774
|
+
onContextChange(callback) {
|
|
775
|
+
this.contextChangeCallback = callback;
|
|
776
|
+
return () => {
|
|
777
|
+
this.contextChangeCallback = null;
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Subscribes to host auth changes: a Nominal logout (`authenticated: false` —
|
|
782
|
+
* sign your backend out) or an identity/tenant switch (`authenticated: true`
|
|
783
|
+
* with a new `userId`/`tenant` — re-authenticate). Fires for each change the
|
|
784
|
+
* host pushes after connect. Returns an unsubscribe function. Single
|
|
785
|
+
* subscriber: a second call replaces the first.
|
|
786
|
+
*
|
|
787
|
+
* **Wire this before {@link connect}** — without it the embedded app won't
|
|
788
|
+
* sign out when the user logs out of Nominal (the bridge warns at connect if
|
|
789
|
+
* it isn't wired). Pure — only stores the callback.
|
|
790
|
+
*/
|
|
791
|
+
onAuthChange(callback) {
|
|
792
|
+
this.authChangeCallback = callback;
|
|
793
|
+
return () => {
|
|
794
|
+
this.authChangeCallback = null;
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* At connect, nudge the app if it hasn't wired the change listeners: every
|
|
799
|
+
* embedded app should react to a Nominal logout ({@link onAuthChange}) and a
|
|
800
|
+
* tenant/subsidiary switch ({@link onContextChange}). Fires once during the
|
|
801
|
+
* connect flow (not speculatively later), so wire both **before** `connect()`.
|
|
802
|
+
* The skill/codegen does this by default; this catches hand-wired apps.
|
|
803
|
+
*/
|
|
804
|
+
warnMissingListeners() {
|
|
805
|
+
if (!this.authChangeCallback) {
|
|
806
|
+
console.warn(
|
|
807
|
+
"[vibe-bridge] connected without onAuthChange \u2014 the app will not sign out on Nominal logout or react to an identity/tenant switch. Call bridge.onAuthChange() before connect()."
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (!this.contextChangeCallback) {
|
|
811
|
+
console.warn(
|
|
812
|
+
"[vibe-bridge] connected without onContextChange \u2014 the app will not react to a tenant/subsidiary/period switch. Call bridge.onContextChange() before connect()."
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// ---------------------------------------------------------------------------
|
|
817
|
+
// Host navigation & UI commands
|
|
818
|
+
//
|
|
819
|
+
// These drive the *host* (nom-ui) chrome — its router, side nav, and
|
|
820
|
+
// subsidiary switcher — rather than the iframe's own internal routing
|
|
821
|
+
// (`reportSubroute`). Navigation/UI commands are fire-and-forget;
|
|
822
|
+
// `invalidateCache` is awaited.
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
/**
|
|
825
|
+
* Navigates the host to a raw path relative to this app's tenant/subsidiary
|
|
826
|
+
* base. Fire-and-forget.
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* ```ts
|
|
830
|
+
* bridge.navigate('/journal-entries/123')
|
|
831
|
+
* bridge.navigate('/journal-entries/123', 'replace')
|
|
832
|
+
* ```
|
|
833
|
+
*/
|
|
834
|
+
navigate(path, action = "push") {
|
|
835
|
+
assertBrowser("navigate()");
|
|
836
|
+
this.sendCommand("NAVIGATE", { path, action });
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Navigates the host to a named Nominal route, with optional params for
|
|
840
|
+
* placeholder segments. Fire-and-forget.
|
|
841
|
+
*
|
|
842
|
+
* @example
|
|
843
|
+
* ```ts
|
|
844
|
+
* bridge.navigateTo('CHART_OF_ACCOUNTS')
|
|
845
|
+
* bridge.navigateTo('JOURNAL_ENTRY', { id: '123' }, 'replace')
|
|
846
|
+
* ```
|
|
847
|
+
*/
|
|
848
|
+
navigateTo(route, routeParams, action = "push") {
|
|
849
|
+
assertBrowser("navigateTo()");
|
|
850
|
+
this.sendCommand("NAVIGATE", { route, routeParams, action });
|
|
851
|
+
}
|
|
852
|
+
/** Refreshes the host's current route (re-fetches its server data). Fire-and-forget. */
|
|
853
|
+
refresh() {
|
|
854
|
+
assertBrowser("refresh()");
|
|
855
|
+
this.sendCommand("NAVIGATE", { action: "refresh" });
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Asks the host to invalidate cached data by path prefix and/or cache tag so
|
|
859
|
+
* subsequent reads see fresh values. Awaited.
|
|
860
|
+
*
|
|
861
|
+
* @example
|
|
862
|
+
* ```ts
|
|
863
|
+
* await bridge.invalidateCache({ tags: ['SUBSIDIARIES'], paths: ['/accounting/accounts'] })
|
|
864
|
+
* ```
|
|
865
|
+
*/
|
|
866
|
+
invalidateCache(payload) {
|
|
867
|
+
return this.request("INVALIDATE_CACHE", payload);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Captures a custom product event from inside the Vibe App, attributed to the
|
|
871
|
+
* same PostHog person as the host (via the `distinctId` the host supplied at
|
|
872
|
+
* connect).
|
|
873
|
+
*
|
|
874
|
+
* **No-op until {@link connect} has resolved with PostHog enabled by the
|
|
875
|
+
* host** — if the host didn't send a `posthog` block (older host, or recording
|
|
876
|
+
* disabled for this env/app), or `posthog-js` isn't installed (it is an
|
|
877
|
+
* optional dependency), nothing is sent. App authors don't import `posthog-js`
|
|
878
|
+
* themselves; this is the supported event surface.
|
|
879
|
+
*
|
|
880
|
+
* The event is sent **directly** from the iframe's own PostHog instance (only
|
|
881
|
+
* the session *recording* is forwarded to the parent), so it carries the
|
|
882
|
+
* iframe instance's `$session_id` rather than the host's — events tie to the
|
|
883
|
+
* same person, but cross-boundary event↔replay timeline correlation is a known
|
|
884
|
+
* limitation.
|
|
885
|
+
*
|
|
886
|
+
* @param event Event name. Namespacing app events (e.g. `'vibe:<slug>:<action>'`)
|
|
887
|
+
* keeps them filterable from the host's own events.
|
|
888
|
+
* @param properties Optional event properties.
|
|
889
|
+
* @example
|
|
890
|
+
* ```ts
|
|
891
|
+
* bridge.track('vibe:fixed-assets:report_exported', { format: 'csv', rows: 128 })
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
track(event, properties) {
|
|
895
|
+
assertBrowser("track()");
|
|
896
|
+
if (!this.posthogInitialized || !this.posthogInstance) return;
|
|
897
|
+
this.posthogInstance.capture(event, properties);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Tears down the bridge: stops the connect handshake, removes the message
|
|
901
|
+
* listener, restores patched history methods, and rejects any in-flight calls
|
|
902
|
+
* with a `'DESTROYED'` error. **Idempotent and safe on the server** — calling
|
|
903
|
+
* it when {@link connect}/{@link attach} never ran (or where there is no
|
|
904
|
+
* `window`) is a no-op, so it is safe as a React effect cleanup.
|
|
905
|
+
*/
|
|
906
|
+
destroy() {
|
|
907
|
+
this.teardownNavigationTracking();
|
|
908
|
+
this.rejectConnect?.(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
909
|
+
this.stopConnectPolling();
|
|
910
|
+
if (this.attached && typeof window !== "undefined") {
|
|
911
|
+
window.removeEventListener("message", this.boundHandleMessage);
|
|
912
|
+
}
|
|
913
|
+
this.attached = false;
|
|
914
|
+
for (const pending of this.pendingRequests.values()) {
|
|
915
|
+
pending.reject(new BridgeError("DESTROYED", "Bridge destroyed"));
|
|
916
|
+
}
|
|
917
|
+
this.pendingRequests.clear();
|
|
918
|
+
this.context = null;
|
|
919
|
+
this.posthogInitialized = false;
|
|
920
|
+
this.posthogInstance = null;
|
|
921
|
+
this.posthogIniting = false;
|
|
922
|
+
this.connectPromise = null;
|
|
923
|
+
this.onContextReceived = null;
|
|
924
|
+
this.rejectConnect = null;
|
|
925
|
+
this.contextChangeCallback = null;
|
|
926
|
+
this.authChangeCallback = null;
|
|
927
|
+
this.subrouteCallback = null;
|
|
928
|
+
this.lastReportedSubroute = null;
|
|
929
|
+
}
|
|
930
|
+
stopConnectPolling() {
|
|
931
|
+
if (this.connectPollInterval !== null) {
|
|
932
|
+
clearInterval(this.connectPollInterval);
|
|
933
|
+
this.connectPollInterval = null;
|
|
934
|
+
}
|
|
935
|
+
if (this.connectTimeout !== null) {
|
|
936
|
+
clearTimeout(this.connectTimeout);
|
|
937
|
+
this.connectTimeout = null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
warnPosthogUnavailable() {
|
|
941
|
+
if (this.posthogUnavailableWarned) return;
|
|
942
|
+
this.posthogUnavailableWarned = true;
|
|
943
|
+
console.warn(
|
|
944
|
+
"[vibe-bridge] The host enabled session recording but `posthog-js` is not installed (it is an optional dependency). Recording and track() are disabled. Run `npm i posthog-js` to enable them."
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Initializes PostHog session recording + analytics from the host-supplied
|
|
949
|
+
* config carried by a one-time `POSTHOG_PUSH`, **only when the host opted in**
|
|
950
|
+
* (`config.enabled`).
|
|
951
|
+
*
|
|
952
|
+
* `posthog-js` is an **optional dependency**, loaded lazily via `await
|
|
953
|
+
* import('posthog-js')` the first time recording is enabled — so it stays out
|
|
954
|
+
* of the import graph (and out of any server bundle) for apps that never
|
|
955
|
+
* record. If it isn't installed, this warns once and no-ops (never throws).
|
|
956
|
+
*
|
|
957
|
+
* The host is the single source of truth: when it sends no `POSTHOG_PUSH`
|
|
958
|
+
* (recording disabled for this env/app) or `enabled: false`, this does
|
|
959
|
+
* nothing. Cross-origin replay needs `recordCrossOriginIframes` on both sides
|
|
960
|
+
* so the parent receives the iframe's rrweb frames into one stitched recording,
|
|
961
|
+
* and `identify()` ties the iframe's recording + events to the same person as
|
|
962
|
+
* the host. Idempotent (the `posthogInitialized` guard) and wrapped in
|
|
963
|
+
* try/catch so a duplicate push or a PostHog failure is harmless.
|
|
964
|
+
*/
|
|
965
|
+
async initPostHog(config) {
|
|
966
|
+
if (!config.enabled) return;
|
|
967
|
+
if (this.posthogInitialized || this.posthogIniting) return;
|
|
968
|
+
if (typeof window === "undefined") return;
|
|
969
|
+
if (!config.token || !config.apiHost || !config.distinctId) {
|
|
970
|
+
console.warn(
|
|
971
|
+
"[vibe-bridge] PostHog enabled but token/apiHost/distinctId missing \u2014 skipping init."
|
|
972
|
+
);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
this.posthogIniting = true;
|
|
976
|
+
try {
|
|
977
|
+
let posthog;
|
|
978
|
+
try {
|
|
979
|
+
posthog = resolvePosthog(await import("posthog-js"));
|
|
980
|
+
} catch {
|
|
981
|
+
this.warnPosthogUnavailable();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (!posthog) {
|
|
985
|
+
this.warnPosthogUnavailable();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (!this.attached) return;
|
|
989
|
+
posthog.init(config.token, {
|
|
990
|
+
api_host: config.apiHost,
|
|
991
|
+
// absolute ingestion host, NOT a relative /ingest proxy
|
|
992
|
+
person_profiles: "identified_only",
|
|
993
|
+
capture_pageview: false,
|
|
994
|
+
// the host owns pageviews; don't double-count
|
|
995
|
+
autocapture: false,
|
|
996
|
+
// opt-in events only, via track()
|
|
997
|
+
// Seed the host's session id so track() events share the host's
|
|
998
|
+
// $session_id and line up with the stitched replay. Replay stitching
|
|
999
|
+
// itself does not depend on this (recordCrossOriginIframes handles it).
|
|
1000
|
+
...config.sessionId ? { bootstrap: { sessionID: config.sessionId } } : {},
|
|
1001
|
+
session_recording: {
|
|
1002
|
+
recordCrossOriginIframes: true,
|
|
1003
|
+
// REQUIRED so the parent receives our rrweb frames
|
|
1004
|
+
// Mirror nom-ui's posture (PostHogProvider.tsx): unmask inputs for
|
|
1005
|
+
// richer insight, but keep password fields masked.
|
|
1006
|
+
maskAllInputs: false,
|
|
1007
|
+
maskInputOptions: { password: true }
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
posthog.identify(config.distinctId);
|
|
1011
|
+
this.posthogInstance = posthog;
|
|
1012
|
+
this.posthogInitialized = true;
|
|
1013
|
+
console.log("[vibe-bridge] PostHog session recording initialized");
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
console.warn("[vibe-bridge] PostHog init failed \u2014 continuing without recording.", err);
|
|
1016
|
+
} finally {
|
|
1017
|
+
this.posthogIniting = false;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Monkey-patches `history.pushState` and `history.replaceState` to
|
|
1022
|
+
* auto-detect SPA navigation and report it to the host. Called after
|
|
1023
|
+
* connect() resolves (so `window`/`history` are guaranteed present).
|
|
1024
|
+
*/
|
|
1025
|
+
setupNavigationTracking() {
|
|
1026
|
+
if (this.origPushState) return;
|
|
1027
|
+
this.origPushState = history.pushState.bind(history);
|
|
1028
|
+
this.origReplaceState = history.replaceState.bind(history);
|
|
1029
|
+
const origPush = this.origPushState;
|
|
1030
|
+
const origReplace = this.origReplaceState;
|
|
1031
|
+
history.pushState = (...args) => {
|
|
1032
|
+
origPush(...args);
|
|
1033
|
+
this.reportCurrentSubroute(false);
|
|
1034
|
+
};
|
|
1035
|
+
history.replaceState = (...args) => {
|
|
1036
|
+
origReplace(...args);
|
|
1037
|
+
this.reportCurrentSubroute(true);
|
|
1038
|
+
};
|
|
1039
|
+
this.popstateHandler = () => this.reportCurrentSubroute(true);
|
|
1040
|
+
window.addEventListener("popstate", this.popstateHandler);
|
|
1041
|
+
}
|
|
1042
|
+
teardownNavigationTracking() {
|
|
1043
|
+
if (this.origPushState) {
|
|
1044
|
+
history.pushState = this.origPushState;
|
|
1045
|
+
this.origPushState = null;
|
|
1046
|
+
}
|
|
1047
|
+
if (this.origReplaceState) {
|
|
1048
|
+
history.replaceState = this.origReplaceState;
|
|
1049
|
+
this.origReplaceState = null;
|
|
1050
|
+
}
|
|
1051
|
+
if (this.popstateHandler) {
|
|
1052
|
+
window.removeEventListener("popstate", this.popstateHandler);
|
|
1053
|
+
this.popstateHandler = null;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** Runs `fn` while suppressing subroute auto-reporting to prevent echo loops. */
|
|
1057
|
+
withSubrouteSuppressed(fn) {
|
|
1058
|
+
this.suppressSubrouteReport = true;
|
|
1059
|
+
try {
|
|
1060
|
+
fn();
|
|
1061
|
+
} finally {
|
|
1062
|
+
this.suppressSubrouteReport = false;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
reportCurrentSubroute(replace) {
|
|
1066
|
+
if (this.suppressSubrouteReport) return;
|
|
1067
|
+
const subroute = normalizeSubroute(window.location.pathname + window.location.search);
|
|
1068
|
+
if (subroute === null) return;
|
|
1069
|
+
if (subroute === this.lastReportedSubroute) return;
|
|
1070
|
+
this.lastReportedSubroute = subroute;
|
|
1071
|
+
this.sendCommand("REPORT_SUBROUTE", { subroute, replace });
|
|
1072
|
+
}
|
|
1073
|
+
sendCommand(type, payload) {
|
|
1074
|
+
this.ensureAttached();
|
|
1075
|
+
this.postToParent({
|
|
1076
|
+
__protocol: PROTOCOL_ID,
|
|
1077
|
+
__version: PROTOCOL_VERSION,
|
|
1078
|
+
kind: "command",
|
|
1079
|
+
type,
|
|
1080
|
+
payload
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Low-level typed request for **any** operation in {@link RequestRegistry}.
|
|
1085
|
+
* The named methods (e.g. {@link VibeAppBridge.getChartOfAccounts}) wrap this;
|
|
1086
|
+
* use it directly for operations without a named method, or to pass an
|
|
1087
|
+
* `onProgress` callback. `type` autocompletes to every operation name and
|
|
1088
|
+
* narrows `payload`/return to that operation's types.
|
|
1089
|
+
*
|
|
1090
|
+
* @throws Rejects with `BridgeError` code `'SERVER_ENVIRONMENT'` if called
|
|
1091
|
+
* where there is no `window`.
|
|
1092
|
+
* @example
|
|
1093
|
+
* ```ts
|
|
1094
|
+
* const accounts = await bridge.request('GET_ACCOUNTS', { query: { account_ids: ['acc-1'] } })
|
|
1095
|
+
* ```
|
|
1096
|
+
*/
|
|
1097
|
+
request(type, payload, onProgress) {
|
|
1098
|
+
if (typeof window === "undefined") {
|
|
1099
|
+
return Promise.reject(serverEnvError(`request(${String(type)})`));
|
|
1100
|
+
}
|
|
1101
|
+
this.ensureAttached();
|
|
1102
|
+
return new Promise((resolve, reject) => {
|
|
1103
|
+
const requestId = crypto.randomUUID();
|
|
1104
|
+
const timer = setTimeout(
|
|
1105
|
+
() => {
|
|
1106
|
+
this.pendingRequests.delete(requestId);
|
|
1107
|
+
reject(new BridgeError("TIMEOUT", `Vibe bridge request timed out: ${type}`));
|
|
1108
|
+
},
|
|
1109
|
+
resolveRequestTimeout(type, this.requestTimeout)
|
|
1110
|
+
);
|
|
1111
|
+
this.pendingRequests.set(requestId, {
|
|
1112
|
+
resolve: (data) => {
|
|
1113
|
+
clearTimeout(timer);
|
|
1114
|
+
resolve(data);
|
|
1115
|
+
},
|
|
1116
|
+
reject: (error) => {
|
|
1117
|
+
clearTimeout(timer);
|
|
1118
|
+
reject(error);
|
|
1119
|
+
},
|
|
1120
|
+
onProgress
|
|
1121
|
+
});
|
|
1122
|
+
const message = {
|
|
1123
|
+
__protocol: PROTOCOL_ID,
|
|
1124
|
+
__version: PROTOCOL_VERSION,
|
|
1125
|
+
kind: "request",
|
|
1126
|
+
type,
|
|
1127
|
+
requestId,
|
|
1128
|
+
payload
|
|
1129
|
+
};
|
|
1130
|
+
this.postToParent(message);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Posts to the embedding host. No-op when no concrete target origin could be
|
|
1135
|
+
* resolved (a config/embedding error) — `connect()` rejects fast in that case
|
|
1136
|
+
* so calls never silently hang waiting on a timeout.
|
|
1137
|
+
*/
|
|
1138
|
+
postToParent(message) {
|
|
1139
|
+
if (this.targetOrigin === null) return;
|
|
1140
|
+
window.parent.postMessage(message, this.targetOrigin);
|
|
1141
|
+
}
|
|
1142
|
+
handleMessage(event) {
|
|
1143
|
+
if (!this.isTrustedOrigin(event.origin)) return;
|
|
1144
|
+
if (!isBridgeMessage(event.data)) return;
|
|
1145
|
+
this.kindHandlers[event.data.kind]?.(event.data);
|
|
1146
|
+
}
|
|
1147
|
+
handleResponse(msg) {
|
|
1148
|
+
const pending = this.pendingRequests.get(msg.requestId);
|
|
1149
|
+
if (!pending) return;
|
|
1150
|
+
this.pendingRequests.delete(msg.requestId);
|
|
1151
|
+
if (msg.ok) {
|
|
1152
|
+
pending.resolve(msg.data);
|
|
1153
|
+
} else if (msg.status !== void 0) {
|
|
1154
|
+
pending.reject(new HttpBridgeError(msg.status, msg.error));
|
|
1155
|
+
} else if (msg.code) {
|
|
1156
|
+
pending.reject(new BridgeError(msg.code, msg.error));
|
|
1157
|
+
} else {
|
|
1158
|
+
pending.reject(new Error(msg.error));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
handleProgress(msg) {
|
|
1162
|
+
const pending = this.pendingRequests.get(msg.requestId);
|
|
1163
|
+
pending?.onProgress?.(msg.payload);
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
export {
|
|
1167
|
+
BRIDGE_VERSION,
|
|
1168
|
+
BridgeError,
|
|
1169
|
+
HttpBridgeError,
|
|
1170
|
+
VibeAppBridge
|
|
1171
|
+
};
|
|
1172
|
+
//# sourceMappingURL=index.browser.js.map
|