@nominalso/vibe-host 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -20,14 +20,28 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BridgeError: () => BridgeError,
24
+ HttpBridgeError: () => HttpBridgeError,
23
25
  VibeAppHost: () => VibeAppHost
24
26
  });
25
27
  module.exports = __toCommonJS(index_exports);
26
28
 
27
29
  // package.json
28
- var version = "0.1.0";
30
+ var version = "0.2.0";
29
31
 
30
32
  // ../protocol-types/dist/index.js
33
+ function normalizeSubroute(subroute) {
34
+ const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
35
+ const path = normalized.split(/[?#]/)[0];
36
+ let decoded = path;
37
+ let prev;
38
+ do {
39
+ prev = decoded;
40
+ decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
41
+ } while (decoded !== prev);
42
+ if (decoded.split("/").some((segment) => segment === "..")) return null;
43
+ return normalized;
44
+ }
31
45
  var PROTOCOL_ID = "nominal-vibe-bridge";
32
46
  var PROTOCOL_VERSION = 1;
33
47
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
@@ -44,6 +58,20 @@ function isBridgeMessage(data) {
44
58
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
45
59
  return true;
46
60
  }
61
+ var BridgeError = class extends Error {
62
+ constructor(code, message) {
63
+ super(message);
64
+ this.code = code;
65
+ this.name = "BridgeError";
66
+ }
67
+ };
68
+ var HttpBridgeError = class extends BridgeError {
69
+ constructor(status, message) {
70
+ super("REQUEST_FAILED", message);
71
+ this.status = status;
72
+ this.name = "HttpBridgeError";
73
+ }
74
+ };
47
75
 
48
76
  // ../api-client/dist/index.js
49
77
  var import_client_fetch = require("@hey-api/client-fetch");
@@ -375,10 +403,6 @@ var getSubsidiaryParentCurrenciesApiTenancySubsidiarySubsidiaryIdParentCurrencie
375
403
  };
376
404
  function createVibeApiClient({ baseUrl, stripApiPrefix }) {
377
405
  const client2 = (0, import_client_fetch2.createClient)((0, import_client_fetch2.createConfig)({ baseUrl }));
378
- client2.interceptors.request.use((request) => {
379
- console.log("[vibe-api-client] request url:", request.url);
380
- return request;
381
- });
382
406
  if (stripApiPrefix) {
383
407
  client2.interceptors.request.use(
384
408
  (request) => new Request(request.url.replace(`${baseUrl}/api/`, `${baseUrl}/`), request)
@@ -387,16 +411,28 @@ function createVibeApiClient({ baseUrl, stripApiPrefix }) {
387
411
  return client2;
388
412
  }
389
413
 
390
- // src/handlerMap.ts
414
+ // src/handlers/call.ts
391
415
  async function call(fn, payload, client2) {
392
- const { data } = await fn({ ...payload, client: client2 });
393
- if (data == null) throw new Error("API returned no data");
416
+ const { data, response } = await fn({ ...payload, client: client2 });
417
+ if (data == null) {
418
+ if (response && !response.ok) {
419
+ throw new HttpBridgeError(
420
+ response.status,
421
+ `API request failed with status ${response.status}`
422
+ );
423
+ }
424
+ if (!response) {
425
+ throw new Error("API request produced no response");
426
+ }
427
+ return null;
428
+ }
394
429
  return data;
395
430
  }
396
- function createHandlerMap(options) {
397
- const client2 = createVibeApiClient(options);
431
+
432
+ // src/handlers/accounting.ts
433
+ function accountingHandlers(client2) {
398
434
  return {
399
- // Accounting — chart of accounts
435
+ // Chart of accounts
400
436
  GET_CHART_OF_ACCOUNTS: (p) => call(getCoaFlatApiAccountingSubsidiarySubsidiaryIdCoaAccountGet, p, client2),
401
437
  GET_COA_TREE: (p) => call(getCoaTreeApiAccountingSubsidiarySubsidiaryIdCoaGet, p, client2),
402
438
  GET_COA_FLAT_SIMPLE: (p) => call(
@@ -421,7 +457,7 @@ function createHandlerMap(options) {
421
457
  ),
422
458
  GET_ACCOUNTS: (p) => call(getAccountsApiAccountingAccountGet, p, client2),
423
459
  GET_ACCOUNT: (p) => call(getAccountApiAccountingAccountAccountIdGet, p, client2),
424
- // Accounting — exchange rates
460
+ // Exchange rates
425
461
  GET_CONVERSION_RATES: (p) => call(getConversionRateApiAccountingCurrencyCurrencyConversionRatesGet, p, client2),
426
462
  GET_EFFECTIVE_EXCHANGE_RATE: (p) => call(getEffectiveExchangeRateApiAccountingConsolidationExchangeRateGet, p, client2),
427
463
  GET_EXCHANGE_RATE_BY_DATE: (p) => call(
@@ -429,7 +465,7 @@ function createHandlerMap(options) {
429
465
  p,
430
466
  client2
431
467
  ),
432
- // Accounting — dimensions
468
+ // Dimensions
433
469
  GET_DIMENSIONS: (p) => call(getDimensionsApiAccountingDimensionGet, p, client2),
434
470
  GET_DIMENSION_VALUES: (p) => call(getDimensionValuesApiAccountingDimensionValueGet, p, client2),
435
471
  GET_DIMENSION_VALUES_HIERARCHICAL: (p) => call(
@@ -442,7 +478,7 @@ function createHandlerMap(options) {
442
478
  p,
443
479
  client2
444
480
  ),
445
- // Accounting — journal entries
481
+ // Journal entries
446
482
  GET_JOURNAL_ENTRIES: (p) => call(
447
483
  getSubsidiaryJournalApiAccountingSubsidiarySubsidiaryIdJournalEntryGet,
448
484
  p,
@@ -457,8 +493,14 @@ function createHandlerMap(options) {
457
493
  getJournalEntryApiAccountingSubsidiarySubsidiaryIdJournalEntryJournalEntryIdGet,
458
494
  p,
459
495
  client2
460
- ),
461
- // Activity — period instances
496
+ )
497
+ };
498
+ }
499
+
500
+ // src/handlers/activity.ts
501
+ function activityHandlers(client2) {
502
+ return {
503
+ // Period instances
462
504
  GET_PERIODS: (p) => call(getPeriodInstancesApiActivityPeriodInstanceGet, p, client2),
463
505
  GET_PERIOD_INSTANCE: (p) => call(getPeriodInstanceApiActivityPeriodInstancePeriodInstanceIdGet, p, client2),
464
506
  GET_PERIOD_INSTANCE_BY_SLUG: (p) => call(
@@ -471,14 +513,14 @@ function createHandlerMap(options) {
471
513
  p,
472
514
  client2
473
515
  ),
474
- // Activity — activity definitions
516
+ // Activity definitions
475
517
  GET_ACTIVITY_DEFINITIONS: (p) => call(getActivityDefinitionsApiActivityActivityDefinitionGet, p, client2),
476
518
  GET_ACTIVITY_DEFINITION: (p) => call(
477
519
  getActivityDefinitionByIdApiActivityActivityDefinitionActivityDefinitionIdGet,
478
520
  p,
479
521
  client2
480
522
  ),
481
- // Activity — activity instances
523
+ // Activity instances
482
524
  GET_ACTIVITY_INSTANCES: (p) => call(getActivityInstancesApiActivityActivityInstanceGet, p, client2),
483
525
  GET_ACTIVITY_INSTANCE: (p) => call(getActivityInstanceApiActivityActivityInstanceActivityInstanceIdGet, p, client2),
484
526
  GET_ACTIVITY_INSTANCE_BY_PERIOD: (p) => call(
@@ -496,28 +538,43 @@ function createHandlerMap(options) {
496
538
  p,
497
539
  client2
498
540
  ),
499
- // Activity — task definitions
541
+ // Task definitions
500
542
  GET_TASK_DEFINITIONS: (p) => call(getAllTasksDefinitionsApiActivityTaskDefinitionGet, p, client2),
501
543
  GET_TASK_DEFINITION: (p) => call(getTaskDefinitionByIdApiActivityTaskDefinitionTaskDefinitionIdGet, p, client2),
502
544
  CREATE_TASK_DEFINITION: (p) => call(createTaskDefinitionApiActivityTaskDefinitionPost, p, client2),
503
545
  UPDATE_TASK_DEFINITION: (p) => call(updateTaskDefinitionApiActivityTaskDefinitionTaskDefinitionIdPut, p, client2),
504
546
  GET_TASK_DEFINITIONS_BY_FILTER: (p) => call(getAllTasksDefinitionsByFilterApiActivityTaskDefinitionQueryPost, p, client2),
505
- // Activity — task instances
547
+ // Task instances
506
548
  GET_TASK_INSTANCES: (p) => call(getAllTaskInstancesApiActivityTaskInstanceGet, p, client2),
507
549
  GET_TASK_INSTANCE: (p) => call(getTaskInstanceByIdApiActivityTaskInstanceTaskInstanceIdGet, p, client2),
508
550
  POST_TASK_OUTPUT: (p) => call(updateTaskInstanceApiActivityTaskInstanceTaskInstanceIdPut, p, client2),
509
- GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2),
510
- // Audit trail
551
+ GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2)
552
+ };
553
+ }
554
+
555
+ // src/handlers/audit-trail.ts
556
+ function auditTrailHandlers(client2) {
557
+ return {
511
558
  GET_AUDIT_EVENTS: (p) => call(getEventsApiAuditTrailEventGet, p, client2),
512
- GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2),
513
- // Period manager
559
+ GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2)
560
+ };
561
+ }
562
+
563
+ // src/handlers/period-manager.ts
564
+ function periodManagerHandlers(client2) {
565
+ return {
514
566
  GET_FISCAL_CALENDARS: (p) => call(
515
567
  getFiscalCalendarsBySubsidiaryApiPeriodManagerFiscalCalendarBySubsidiarySubsidiaryIdGet,
516
568
  p,
517
569
  client2
518
570
  ),
519
- GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2),
520
- // Tenancy
571
+ GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2)
572
+ };
573
+ }
574
+
575
+ // src/handlers/tenancy.ts
576
+ function tenancyHandlers(client2) {
577
+ return {
521
578
  GET_SUBSIDIARIES: (p) => call(getAllSubsidiariesApiTenancySubsidiaryGet, p, client2),
522
579
  GET_SUBSIDIARY: (p) => call(getSubsidiaryApiTenancySubsidiarySubsidiaryIdGet, p, client2),
523
580
  GET_SUBSIDIARY_PARENT_CURRENCIES: (p) => call(
@@ -529,46 +586,115 @@ function createHandlerMap(options) {
529
586
  };
530
587
  }
531
588
 
532
- // src/uploadHandler.ts
533
- async function uploadHandler(payload, sendProgress) {
534
- const attachmentId = crypto.randomUUID();
535
- for (const progress of [25, 50, 75, 100]) {
536
- await new Promise((r) => setTimeout(r, 400));
537
- sendProgress({ attachmentId, progress });
589
+ // src/handlerMap.ts
590
+ function createHandlerMap(options) {
591
+ const client2 = createVibeApiClient(options);
592
+ return {
593
+ ...accountingHandlers(client2),
594
+ ...activityHandlers(client2),
595
+ ...auditTrailHandlers(client2),
596
+ ...periodManagerHandlers(client2),
597
+ ...tenancyHandlers(client2)
598
+ };
599
+ }
600
+
601
+ // src/rateLimiter.ts
602
+ var DEFAULT_RATE_LIMIT = 60;
603
+ function createRateLimiter(limit = DEFAULT_RATE_LIMIT, windowMs = 6e4) {
604
+ return { timestamps: [], limit, windowMs };
605
+ }
606
+ function checkRateLimit(state) {
607
+ const now = Date.now();
608
+ const cutoff = now - state.windowMs;
609
+ state.timestamps = state.timestamps.filter((t) => t > cutoff);
610
+ if (state.timestamps.length >= state.limit) {
611
+ return false;
538
612
  }
539
- return { attachmentId, name: payload.fileName };
613
+ state.timestamps.push(now);
614
+ return true;
615
+ }
616
+
617
+ // src/unwired.ts
618
+ function warnUnwired(command) {
619
+ return () => {
620
+ console.warn(`[vibe-host] Received "${command}" command but no handler is wired \u2014 ignoring.`);
621
+ };
622
+ }
623
+ function rejectUnwired(op) {
624
+ return () => Promise.reject(
625
+ new BridgeError("NOT_WIRED", `[vibe-host] Received "${op}" request but no handler is wired.`)
626
+ );
540
627
  }
541
628
 
542
629
  // src/VibeAppHost.ts
543
630
  var VibeAppHost = class {
544
- constructor({ trustedOrigins, getContext, appBasePath, clientConfig }) {
631
+ constructor(opts) {
545
632
  this.iframeWindow = null;
633
+ /**
634
+ * Source windows we've already logged a successful handshake for. The bridge
635
+ * polls `GET_CONTEXT` on an interval until it connects, so without this the
636
+ * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
637
+ */
638
+ this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
546
639
  this.kindHandlers = {
547
640
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
548
641
  command: (msg) => this.dispatchCommand(msg)
549
642
  };
550
- this.commandHandlers = {
551
- REPORT_SUBROUTE: (payload) => {
552
- const newPath = payload.subroute === "/" ? this.appBasePath : `${this.appBasePath}${payload.subroute}`;
553
- const method = payload.replace ? "replaceState" : "pushState";
554
- window.history[method](null, "", newPath);
555
- }
556
- };
643
+ const { trustedOrigins, getContext, appBasePath, clientConfig } = opts;
557
644
  this.trustedOrigins = new Set(trustedOrigins);
558
645
  this.getContext = getContext;
559
646
  this.appBasePath = appBasePath;
647
+ this.onRequestComplete = opts.onRequestComplete;
648
+ this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
560
649
  this.boundHandleMessage = this.handleMessage.bind(this);
561
650
  this.boundHandlePopState = this.handlePopState.bind(this);
562
- this.handlers = this.initializeHandlers(clientConfig || { baseUrl: "", stripApiPrefix: false });
651
+ this.handlers = this.initializeHandlers(
652
+ clientConfig || { baseUrl: "", stripApiPrefix: false },
653
+ opts
654
+ );
655
+ this.commandHandlers = this.buildCommandHandlers(opts);
563
656
  }
564
657
  dispatchCommand(msg) {
565
658
  const handler = this.commandHandlers[msg.type];
566
- handler(msg.payload);
659
+ if (!handler) {
660
+ console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
661
+ return;
662
+ }
663
+ try {
664
+ handler(msg.payload);
665
+ } catch (err) {
666
+ console.warn(`[vibe-host] Command "${msg.type}" handler threw \u2014 ignoring.`, err);
667
+ }
567
668
  }
568
- initializeHandlers(clientConfig) {
669
+ /**
670
+ * Commands: `REPORT_SUBROUTE` is handled internally (transport + traversal
671
+ * guard, item `l`); navigation/UI side effects are injected by the consumer,
672
+ * falling back to warn-and-ignore (DP4).
673
+ */
674
+ buildCommandHandlers(opts) {
675
+ return {
676
+ REPORT_SUBROUTE: (payload) => {
677
+ const safe = normalizeSubroute(payload.subroute);
678
+ if (safe === null) return;
679
+ const newPath = safe === "/" ? this.appBasePath : `${this.appBasePath}${safe}`;
680
+ const method = payload.replace ? "replaceState" : "pushState";
681
+ window.history[method](null, "", newPath);
682
+ },
683
+ NAVIGATE: opts.onNavigate ?? warnUnwired("NAVIGATE"),
684
+ SET_SIDE_NAV: opts.onSetSideNav ?? warnUnwired("SET_SIDE_NAV"),
685
+ SWITCH_SUBSIDIARY: opts.onSwitchSubsidiary ?? warnUnwired("SWITCH_SUBSIDIARY")
686
+ };
687
+ }
688
+ /**
689
+ * Requests: the built-in API map (hey-api), plus the injected/awaited side
690
+ * effects with safe defaults — unwired `UPLOAD_FILE` rejects with a coded
691
+ * error; unwired `INVALIDATE_CACHE` resolves to a no-op (DP4).
692
+ */
693
+ initializeHandlers(clientConfig, opts) {
569
694
  return {
570
695
  ...createHandlerMap(clientConfig),
571
- UPLOAD_FILE: uploadHandler
696
+ UPLOAD_FILE: opts.onUpload ?? rejectUnwired("UPLOAD_FILE"),
697
+ INVALIDATE_CACHE: opts.onInvalidateCache ?? (async () => ({ invalidated: false }))
572
698
  };
573
699
  }
574
700
  /**
@@ -640,12 +766,33 @@ var VibeAppHost = class {
640
766
  async handleRequest(msg, source, origin) {
641
767
  const { requestId, type } = msg;
642
768
  if (type === "GET_CONTEXT") {
643
- const ctx = { ...this.getContext(), hostVersion: version };
644
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
645
- console.log(
646
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
647
- );
648
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
769
+ try {
770
+ const ctx = { ...this.getContext(), hostVersion: version };
771
+ if (!this.handshakeLoggedWindows.has(source)) {
772
+ this.handshakeLoggedWindows.add(source);
773
+ const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
774
+ console.log(
775
+ `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
776
+ );
777
+ }
778
+ this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
779
+ } catch (err) {
780
+ const error = err instanceof Error ? err.message : String(err);
781
+ const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
782
+ this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
783
+ }
784
+ return;
785
+ }
786
+ if (!checkRateLimit(this.rateLimiter)) {
787
+ this.respond(source, origin, {
788
+ kind: "response",
789
+ type,
790
+ requestId,
791
+ ok: false,
792
+ error: `Too many requests. Limit is ${this.rateLimiter.limit} per minute.`,
793
+ code: "RATE_LIMITED"
794
+ });
795
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
649
796
  return;
650
797
  }
651
798
  const sendProgress = (payload) => {
@@ -663,9 +810,21 @@ var VibeAppHost = class {
663
810
  const handler = this.handlers[type];
664
811
  const data = await handler(msg.payload, sendProgress);
665
812
  this.respond(source, origin, { kind: "response", type, requestId, ok: true, data });
813
+ this.onRequestComplete?.({ type, ok: true, payload: msg.payload });
666
814
  } catch (err) {
667
815
  const error = err instanceof Error ? err.message : String(err);
668
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error });
816
+ const code = err instanceof BridgeError ? err.code : void 0;
817
+ const status = err instanceof HttpBridgeError ? err.status : void 0;
818
+ this.respond(source, origin, {
819
+ kind: "response",
820
+ type,
821
+ requestId,
822
+ ok: false,
823
+ error,
824
+ code,
825
+ status
826
+ });
827
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
669
828
  }
670
829
  }
671
830
  respond(target, origin, response) {
@@ -677,5 +836,7 @@ var VibeAppHost = class {
677
836
  };
678
837
  // Annotate the CommonJS export names for ESM import in node:
679
838
  0 && (module.exports = {
839
+ BridgeError,
840
+ HttpBridgeError,
680
841
  VibeAppHost
681
842
  });
package/dist/index.d.cts CHANGED
@@ -3201,15 +3201,6 @@ interface GetContextPayload {
3201
3201
  bridgeVersion: string;
3202
3202
  }
3203
3203
 
3204
- /**
3205
- * All operations the iframe can invoke on the host, each with a typed request
3206
- * payload and a typed response. Adding a new operation here is all that's
3207
- * required — message union types and handler types below are derived
3208
- * automatically.
3209
- *
3210
- * Operations that also emit intermediate progress events should include a
3211
- * `progress` field with the progress payload type.
3212
- */
3213
3204
  interface UploadPayload {
3214
3205
  buffer: ArrayBuffer;
3215
3206
  fileName: string;
@@ -3220,11 +3211,57 @@ interface UploadPayload {
3220
3211
  interface UploadResponse {
3221
3212
  attachmentId: string;
3222
3213
  name: string;
3214
+ /** Public/presigned URL of the stored file, when the host returns one. */
3215
+ url?: string;
3223
3216
  }
3224
3217
  interface UploadProgress {
3225
3218
  attachmentId: string;
3226
3219
  progress: number;
3227
3220
  }
3221
+ /** How the host should apply a navigation. Defaults to `'push'`. */
3222
+ type NavigateAction = 'push' | 'replace' | 'refresh';
3223
+ /**
3224
+ * Tells the host to navigate. Provide **either** a raw `path` (relative to the
3225
+ * app's tenant/subsidiary base) **or** a named `route` resolved host-side.
3226
+ */
3227
+ interface NavigatePayload {
3228
+ /** Raw path relative to the app's base, e.g. `'/journal-entries/123'`. */
3229
+ path?: string;
3230
+ /** Named route key the host resolves, e.g. `'CHART_OF_ACCOUNTS'`. */
3231
+ route?: string;
3232
+ /** Values for route placeholders, e.g. `{ id: '123' }`. */
3233
+ routeParams?: Record<string, string>;
3234
+ /** Navigation method. Defaults to `'push'`. */
3235
+ action?: NavigateAction;
3236
+ }
3237
+ /** Collapse/expand the host's side navigation. */
3238
+ interface SetSideNavPayload {
3239
+ collapsed: boolean;
3240
+ }
3241
+ /** Switch the host to a different subsidiary. */
3242
+ interface SwitchSubsidiaryPayload {
3243
+ subsidiaryId: number;
3244
+ }
3245
+ /** Targets for a host cache invalidation. */
3246
+ interface InvalidateCachePayload {
3247
+ /** API path prefixes to invalidate, e.g. `['/accounting/accounts']`. */
3248
+ paths?: string[];
3249
+ /** Cache tag keys to invalidate, e.g. `['REPORTS', 'SUBSIDIARIES']`. */
3250
+ tags?: string[];
3251
+ }
3252
+ /** Result of a host cache invalidation. */
3253
+ interface InvalidateResult {
3254
+ invalidated: boolean;
3255
+ }
3256
+
3257
+ /**
3258
+ * All operations the iframe can invoke on the host, each with a typed request
3259
+ * payload and a typed response. Adding a new operation here is all that's
3260
+ * required — message union types and handler types are derived automatically.
3261
+ *
3262
+ * Operations that also emit intermediate progress events should include a
3263
+ * `progress` field with the progress payload type.
3264
+ */
3228
3265
  interface RequestRegistry {
3229
3266
  GET_CHART_OF_ACCOUNTS: {
3230
3267
  payload: Omit<GetCoaFlatApiAccountingSubsidiarySubsidiaryIdCoaAccountGetData, 'url'>;
@@ -3410,6 +3447,10 @@ interface RequestRegistry {
3410
3447
  payload: Omit<GetTenantUsersApiTenancyTenantTenantIdOrNameUsersGetData, 'url'>;
3411
3448
  data: GetTenantUsersApiTenancyTenantTenantIdOrNameUsersGetResponse;
3412
3449
  };
3450
+ INVALIDATE_CACHE: {
3451
+ payload: InvalidateCachePayload;
3452
+ data: InvalidateResult;
3453
+ };
3413
3454
  GET_CONTEXT: {
3414
3455
  payload: GetContextPayload;
3415
3456
  data: ContextPayload;
@@ -3434,6 +3475,53 @@ type RequestHandlers = {
3434
3475
  } ? (payload: RequestRegistry[K]['payload'], sendProgress: (p: P) => void) => Promise<RequestRegistry[K]['data']> : (payload: RequestRegistry[K]['payload']) => Promise<RequestRegistry[K]['data']>;
3435
3476
  };
3436
3477
 
3478
+ /**
3479
+ * An error that crossed the bridge, carrying a machine-readable {@link code}
3480
+ * alongside the human-readable message.
3481
+ *
3482
+ * The host attaches a `code` to failed responses (e.g. `'RATE_LIMITED'`,
3483
+ * `'FILE_TOO_LARGE'`, `'REQUEST_FAILED'`); the bridge rehydrates it into a
3484
+ * `BridgeError` so app `catch` blocks can branch on `error.code` instead of
3485
+ * string-matching `error.message`. A failed Nominal API call rehydrates as the
3486
+ * {@link HttpBridgeError} subtype, which adds the HTTP `status`.
3487
+ *
3488
+ * @example
3489
+ * ```ts
3490
+ * try {
3491
+ * await bridge.getAccounts({})
3492
+ * } catch (err) {
3493
+ * if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
3494
+ * // back off and retry
3495
+ * }
3496
+ * }
3497
+ * ```
3498
+ */
3499
+ declare class BridgeError extends Error {
3500
+ readonly code: string;
3501
+ constructor(code: string, message: string);
3502
+ }
3503
+ /**
3504
+ * A {@link BridgeError} for a failed Nominal API call. Its `code` is always
3505
+ * `'REQUEST_FAILED'`; the HTTP {@link status} is carried as a structured field
3506
+ * so callers can branch on it (e.g. `err.status === 404`) instead of parsing
3507
+ * the code string.
3508
+ *
3509
+ * @example
3510
+ * ```ts
3511
+ * try {
3512
+ * await bridge.getAccount({ path: { account_id: 'missing' } })
3513
+ * } catch (err) {
3514
+ * if (err instanceof HttpBridgeError && err.status === 404) {
3515
+ * // handle not-found
3516
+ * }
3517
+ * }
3518
+ * ```
3519
+ */
3520
+ declare class HttpBridgeError extends BridgeError {
3521
+ readonly status: number;
3522
+ constructor(status: number, message: string);
3523
+ }
3524
+
3437
3525
  interface VibeApiClientOptions {
3438
3526
  baseUrl: string;
3439
3527
  /**
@@ -3446,6 +3534,18 @@ interface VibeApiClientOptions {
3446
3534
 
3447
3535
  type HostHandlers = Omit<RequestHandlers, 'GET_CONTEXT'>;
3448
3536
  type HostContext = Omit<ContextPayload, 'hostVersion'>;
3537
+ /**
3538
+ * Notified after each request resolves — except the `GET_CONTEXT` connect poll,
3539
+ * which is exempt. Used by nom-ui for SWR busting (SSOT §5j).
3540
+ */
3541
+ interface RequestCompleteInfo {
3542
+ /** The operation type, e.g. `'POST_TASK_OUTPUT'`. */
3543
+ type: string;
3544
+ /** Whether the operation succeeded. */
3545
+ ok: boolean;
3546
+ /** The request payload that was handled. */
3547
+ payload: unknown;
3548
+ }
3449
3549
  interface VibeAppHostOptions {
3450
3550
  /**
3451
3551
  * Iframe origins allowed to talk to this host. Messages from any other origin
@@ -3467,13 +3567,33 @@ interface VibeAppHostOptions {
3467
3567
  * `{ baseUrl: '', stripApiPrefix: false }`.
3468
3568
  */
3469
3569
  clientConfig?: VibeApiClientOptions;
3570
+ /** Fire-and-forget. Resolve a path / named route and push to the host router. */
3571
+ onNavigate?: (payload: NavigatePayload) => void;
3572
+ /** Fire-and-forget. Collapse/expand the host side nav. */
3573
+ onSetSideNav?: (payload: SetSideNavPayload) => void;
3574
+ /** Fire-and-forget. Switch subsidiary (host owns validation + navigation). */
3575
+ onSwitchSubsidiary?: (payload: SwitchSubsidiaryPayload) => void;
3576
+ /** Awaited. Invalidate host cache by tags/paths. Defaults to a no-op. */
3577
+ onInvalidateCache?: (payload: InvalidateCachePayload) => Promise<InvalidateResult>;
3578
+ /**
3579
+ * Awaited. Real file upload — replaces the built-in stub (DP2). When omitted,
3580
+ * `UPLOAD_FILE` requests reject with a `'NOT_WIRED'` coded error.
3581
+ */
3582
+ onUpload?: (payload: UploadPayload, sendProgress: (p: UploadProgress) => void) => Promise<UploadResponse>;
3583
+ /** Optional. Notified after each request resolves (except the `GET_CONTEXT` connect poll) — used by nom-ui for SWR busting (SSOT §5j). */
3584
+ onRequestComplete?: (info: RequestCompleteInfo) => void;
3585
+ /** Requests/min before a request trips `RATE_LIMITED` (DP5). Default 60. */
3586
+ rateLimit?: number;
3470
3587
  }
3588
+
3471
3589
  /**
3472
3590
  * Host-side counterpart to `VibeAppBridge`. Construct it with the trusted
3473
3591
  * iframe origin(s), a `getContext` callback, the app's base path, and a
3474
3592
  * `clientConfig` pointing at the Nominal API; then call {@link VibeAppHost.mount}
3475
- * to start listening. The handler map for all protocol operations is built
3476
- * automatically — you do not pass handlers.
3593
+ * to start listening. The handler map for all protocol API operations is built
3594
+ * automatically — you do not pass those handlers. Non-API side effects
3595
+ * (navigation, side nav, subsidiary switch, cache invalidation, upload) are
3596
+ * supplied via the optional injected callbacks.
3477
3597
  *
3478
3598
  * @example
3479
3599
  * ```tsx
@@ -3483,6 +3603,8 @@ interface VibeAppHostOptions {
3483
3603
  * appBasePath: `/${tenant}/${subsidiary}/apps/${slug}`,
3484
3604
  * getContext: () => ({ tenant, subsidiaryId, subsidiaries, user, lastClosedPeriodSlug }),
3485
3605
  * clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
3606
+ * onNavigate: (p) => router.push(resolve(p)),
3607
+ * onUpload: (p, sendProgress) => uploadToS3(p, sendProgress),
3486
3608
  * })
3487
3609
  *
3488
3610
  * useEffect(() => {
@@ -3501,16 +3623,35 @@ interface VibeAppHostOptions {
3501
3623
  declare class VibeAppHost {
3502
3624
  private readonly trustedOrigins;
3503
3625
  private readonly handlers;
3626
+ private readonly commandHandlers;
3504
3627
  private readonly getContext;
3505
3628
  private readonly appBasePath;
3629
+ private readonly onRequestComplete?;
3630
+ private readonly rateLimiter;
3506
3631
  private readonly boundHandleMessage;
3507
3632
  private readonly boundHandlePopState;
3508
3633
  private iframeWindow;
3634
+ /**
3635
+ * Source windows we've already logged a successful handshake for. The bridge
3636
+ * polls `GET_CONTEXT` on an interval until it connects, so without this the
3637
+ * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
3638
+ */
3639
+ private readonly handshakeLoggedWindows;
3509
3640
  private readonly kindHandlers;
3510
3641
  private dispatchCommand;
3511
- private readonly commandHandlers;
3642
+ /**
3643
+ * Commands: `REPORT_SUBROUTE` is handled internally (transport + traversal
3644
+ * guard, item `l`); navigation/UI side effects are injected by the consumer,
3645
+ * falling back to warn-and-ignore (DP4).
3646
+ */
3647
+ private buildCommandHandlers;
3648
+ /**
3649
+ * Requests: the built-in API map (hey-api), plus the injected/awaited side
3650
+ * effects with safe defaults — unwired `UPLOAD_FILE` rejects with a coded
3651
+ * error; unwired `INVALIDATE_CACHE` resolves to a no-op (DP4).
3652
+ */
3512
3653
  private initializeHandlers;
3513
- constructor({ trustedOrigins, getContext, appBasePath, clientConfig }: VibeAppHostOptions);
3654
+ constructor(opts: VibeAppHostOptions);
3514
3655
  /**
3515
3656
  * Starts listening for bridge messages and sets up browser back/forward sync.
3516
3657
  * Returns a cleanup function to call on unmount.
@@ -3535,4 +3676,4 @@ declare class VibeAppHost {
3535
3676
  private respond;
3536
3677
  }
3537
3678
 
3538
- export { type ContextPayload, type HostContext, type HostHandlers, type RequestHandlers, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
3679
+ export { BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
package/dist/index.d.ts CHANGED
@@ -3201,15 +3201,6 @@ interface GetContextPayload {
3201
3201
  bridgeVersion: string;
3202
3202
  }
3203
3203
 
3204
- /**
3205
- * All operations the iframe can invoke on the host, each with a typed request
3206
- * payload and a typed response. Adding a new operation here is all that's
3207
- * required — message union types and handler types below are derived
3208
- * automatically.
3209
- *
3210
- * Operations that also emit intermediate progress events should include a
3211
- * `progress` field with the progress payload type.
3212
- */
3213
3204
  interface UploadPayload {
3214
3205
  buffer: ArrayBuffer;
3215
3206
  fileName: string;
@@ -3220,11 +3211,57 @@ interface UploadPayload {
3220
3211
  interface UploadResponse {
3221
3212
  attachmentId: string;
3222
3213
  name: string;
3214
+ /** Public/presigned URL of the stored file, when the host returns one. */
3215
+ url?: string;
3223
3216
  }
3224
3217
  interface UploadProgress {
3225
3218
  attachmentId: string;
3226
3219
  progress: number;
3227
3220
  }
3221
+ /** How the host should apply a navigation. Defaults to `'push'`. */
3222
+ type NavigateAction = 'push' | 'replace' | 'refresh';
3223
+ /**
3224
+ * Tells the host to navigate. Provide **either** a raw `path` (relative to the
3225
+ * app's tenant/subsidiary base) **or** a named `route` resolved host-side.
3226
+ */
3227
+ interface NavigatePayload {
3228
+ /** Raw path relative to the app's base, e.g. `'/journal-entries/123'`. */
3229
+ path?: string;
3230
+ /** Named route key the host resolves, e.g. `'CHART_OF_ACCOUNTS'`. */
3231
+ route?: string;
3232
+ /** Values for route placeholders, e.g. `{ id: '123' }`. */
3233
+ routeParams?: Record<string, string>;
3234
+ /** Navigation method. Defaults to `'push'`. */
3235
+ action?: NavigateAction;
3236
+ }
3237
+ /** Collapse/expand the host's side navigation. */
3238
+ interface SetSideNavPayload {
3239
+ collapsed: boolean;
3240
+ }
3241
+ /** Switch the host to a different subsidiary. */
3242
+ interface SwitchSubsidiaryPayload {
3243
+ subsidiaryId: number;
3244
+ }
3245
+ /** Targets for a host cache invalidation. */
3246
+ interface InvalidateCachePayload {
3247
+ /** API path prefixes to invalidate, e.g. `['/accounting/accounts']`. */
3248
+ paths?: string[];
3249
+ /** Cache tag keys to invalidate, e.g. `['REPORTS', 'SUBSIDIARIES']`. */
3250
+ tags?: string[];
3251
+ }
3252
+ /** Result of a host cache invalidation. */
3253
+ interface InvalidateResult {
3254
+ invalidated: boolean;
3255
+ }
3256
+
3257
+ /**
3258
+ * All operations the iframe can invoke on the host, each with a typed request
3259
+ * payload and a typed response. Adding a new operation here is all that's
3260
+ * required — message union types and handler types are derived automatically.
3261
+ *
3262
+ * Operations that also emit intermediate progress events should include a
3263
+ * `progress` field with the progress payload type.
3264
+ */
3228
3265
  interface RequestRegistry {
3229
3266
  GET_CHART_OF_ACCOUNTS: {
3230
3267
  payload: Omit<GetCoaFlatApiAccountingSubsidiarySubsidiaryIdCoaAccountGetData, 'url'>;
@@ -3410,6 +3447,10 @@ interface RequestRegistry {
3410
3447
  payload: Omit<GetTenantUsersApiTenancyTenantTenantIdOrNameUsersGetData, 'url'>;
3411
3448
  data: GetTenantUsersApiTenancyTenantTenantIdOrNameUsersGetResponse;
3412
3449
  };
3450
+ INVALIDATE_CACHE: {
3451
+ payload: InvalidateCachePayload;
3452
+ data: InvalidateResult;
3453
+ };
3413
3454
  GET_CONTEXT: {
3414
3455
  payload: GetContextPayload;
3415
3456
  data: ContextPayload;
@@ -3434,6 +3475,53 @@ type RequestHandlers = {
3434
3475
  } ? (payload: RequestRegistry[K]['payload'], sendProgress: (p: P) => void) => Promise<RequestRegistry[K]['data']> : (payload: RequestRegistry[K]['payload']) => Promise<RequestRegistry[K]['data']>;
3435
3476
  };
3436
3477
 
3478
+ /**
3479
+ * An error that crossed the bridge, carrying a machine-readable {@link code}
3480
+ * alongside the human-readable message.
3481
+ *
3482
+ * The host attaches a `code` to failed responses (e.g. `'RATE_LIMITED'`,
3483
+ * `'FILE_TOO_LARGE'`, `'REQUEST_FAILED'`); the bridge rehydrates it into a
3484
+ * `BridgeError` so app `catch` blocks can branch on `error.code` instead of
3485
+ * string-matching `error.message`. A failed Nominal API call rehydrates as the
3486
+ * {@link HttpBridgeError} subtype, which adds the HTTP `status`.
3487
+ *
3488
+ * @example
3489
+ * ```ts
3490
+ * try {
3491
+ * await bridge.getAccounts({})
3492
+ * } catch (err) {
3493
+ * if (err instanceof BridgeError && err.code === 'RATE_LIMITED') {
3494
+ * // back off and retry
3495
+ * }
3496
+ * }
3497
+ * ```
3498
+ */
3499
+ declare class BridgeError extends Error {
3500
+ readonly code: string;
3501
+ constructor(code: string, message: string);
3502
+ }
3503
+ /**
3504
+ * A {@link BridgeError} for a failed Nominal API call. Its `code` is always
3505
+ * `'REQUEST_FAILED'`; the HTTP {@link status} is carried as a structured field
3506
+ * so callers can branch on it (e.g. `err.status === 404`) instead of parsing
3507
+ * the code string.
3508
+ *
3509
+ * @example
3510
+ * ```ts
3511
+ * try {
3512
+ * await bridge.getAccount({ path: { account_id: 'missing' } })
3513
+ * } catch (err) {
3514
+ * if (err instanceof HttpBridgeError && err.status === 404) {
3515
+ * // handle not-found
3516
+ * }
3517
+ * }
3518
+ * ```
3519
+ */
3520
+ declare class HttpBridgeError extends BridgeError {
3521
+ readonly status: number;
3522
+ constructor(status: number, message: string);
3523
+ }
3524
+
3437
3525
  interface VibeApiClientOptions {
3438
3526
  baseUrl: string;
3439
3527
  /**
@@ -3446,6 +3534,18 @@ interface VibeApiClientOptions {
3446
3534
 
3447
3535
  type HostHandlers = Omit<RequestHandlers, 'GET_CONTEXT'>;
3448
3536
  type HostContext = Omit<ContextPayload, 'hostVersion'>;
3537
+ /**
3538
+ * Notified after each request resolves — except the `GET_CONTEXT` connect poll,
3539
+ * which is exempt. Used by nom-ui for SWR busting (SSOT §5j).
3540
+ */
3541
+ interface RequestCompleteInfo {
3542
+ /** The operation type, e.g. `'POST_TASK_OUTPUT'`. */
3543
+ type: string;
3544
+ /** Whether the operation succeeded. */
3545
+ ok: boolean;
3546
+ /** The request payload that was handled. */
3547
+ payload: unknown;
3548
+ }
3449
3549
  interface VibeAppHostOptions {
3450
3550
  /**
3451
3551
  * Iframe origins allowed to talk to this host. Messages from any other origin
@@ -3467,13 +3567,33 @@ interface VibeAppHostOptions {
3467
3567
  * `{ baseUrl: '', stripApiPrefix: false }`.
3468
3568
  */
3469
3569
  clientConfig?: VibeApiClientOptions;
3570
+ /** Fire-and-forget. Resolve a path / named route and push to the host router. */
3571
+ onNavigate?: (payload: NavigatePayload) => void;
3572
+ /** Fire-and-forget. Collapse/expand the host side nav. */
3573
+ onSetSideNav?: (payload: SetSideNavPayload) => void;
3574
+ /** Fire-and-forget. Switch subsidiary (host owns validation + navigation). */
3575
+ onSwitchSubsidiary?: (payload: SwitchSubsidiaryPayload) => void;
3576
+ /** Awaited. Invalidate host cache by tags/paths. Defaults to a no-op. */
3577
+ onInvalidateCache?: (payload: InvalidateCachePayload) => Promise<InvalidateResult>;
3578
+ /**
3579
+ * Awaited. Real file upload — replaces the built-in stub (DP2). When omitted,
3580
+ * `UPLOAD_FILE` requests reject with a `'NOT_WIRED'` coded error.
3581
+ */
3582
+ onUpload?: (payload: UploadPayload, sendProgress: (p: UploadProgress) => void) => Promise<UploadResponse>;
3583
+ /** Optional. Notified after each request resolves (except the `GET_CONTEXT` connect poll) — used by nom-ui for SWR busting (SSOT §5j). */
3584
+ onRequestComplete?: (info: RequestCompleteInfo) => void;
3585
+ /** Requests/min before a request trips `RATE_LIMITED` (DP5). Default 60. */
3586
+ rateLimit?: number;
3470
3587
  }
3588
+
3471
3589
  /**
3472
3590
  * Host-side counterpart to `VibeAppBridge`. Construct it with the trusted
3473
3591
  * iframe origin(s), a `getContext` callback, the app's base path, and a
3474
3592
  * `clientConfig` pointing at the Nominal API; then call {@link VibeAppHost.mount}
3475
- * to start listening. The handler map for all protocol operations is built
3476
- * automatically — you do not pass handlers.
3593
+ * to start listening. The handler map for all protocol API operations is built
3594
+ * automatically — you do not pass those handlers. Non-API side effects
3595
+ * (navigation, side nav, subsidiary switch, cache invalidation, upload) are
3596
+ * supplied via the optional injected callbacks.
3477
3597
  *
3478
3598
  * @example
3479
3599
  * ```tsx
@@ -3483,6 +3603,8 @@ interface VibeAppHostOptions {
3483
3603
  * appBasePath: `/${tenant}/${subsidiary}/apps/${slug}`,
3484
3604
  * getContext: () => ({ tenant, subsidiaryId, subsidiaries, user, lastClosedPeriodSlug }),
3485
3605
  * clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
3606
+ * onNavigate: (p) => router.push(resolve(p)),
3607
+ * onUpload: (p, sendProgress) => uploadToS3(p, sendProgress),
3486
3608
  * })
3487
3609
  *
3488
3610
  * useEffect(() => {
@@ -3501,16 +3623,35 @@ interface VibeAppHostOptions {
3501
3623
  declare class VibeAppHost {
3502
3624
  private readonly trustedOrigins;
3503
3625
  private readonly handlers;
3626
+ private readonly commandHandlers;
3504
3627
  private readonly getContext;
3505
3628
  private readonly appBasePath;
3629
+ private readonly onRequestComplete?;
3630
+ private readonly rateLimiter;
3506
3631
  private readonly boundHandleMessage;
3507
3632
  private readonly boundHandlePopState;
3508
3633
  private iframeWindow;
3634
+ /**
3635
+ * Source windows we've already logged a successful handshake for. The bridge
3636
+ * polls `GET_CONTEXT` on an interval until it connects, so without this the
3637
+ * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
3638
+ */
3639
+ private readonly handshakeLoggedWindows;
3509
3640
  private readonly kindHandlers;
3510
3641
  private dispatchCommand;
3511
- private readonly commandHandlers;
3642
+ /**
3643
+ * Commands: `REPORT_SUBROUTE` is handled internally (transport + traversal
3644
+ * guard, item `l`); navigation/UI side effects are injected by the consumer,
3645
+ * falling back to warn-and-ignore (DP4).
3646
+ */
3647
+ private buildCommandHandlers;
3648
+ /**
3649
+ * Requests: the built-in API map (hey-api), plus the injected/awaited side
3650
+ * effects with safe defaults — unwired `UPLOAD_FILE` rejects with a coded
3651
+ * error; unwired `INVALIDATE_CACHE` resolves to a no-op (DP4).
3652
+ */
3512
3653
  private initializeHandlers;
3513
- constructor({ trustedOrigins, getContext, appBasePath, clientConfig }: VibeAppHostOptions);
3654
+ constructor(opts: VibeAppHostOptions);
3514
3655
  /**
3515
3656
  * Starts listening for bridge messages and sets up browser back/forward sync.
3516
3657
  * Returns a cleanup function to call on unmount.
@@ -3535,4 +3676,4 @@ declare class VibeAppHost {
3535
3676
  private respond;
3536
3677
  }
3537
3678
 
3538
- export { type ContextPayload, type HostContext, type HostHandlers, type RequestHandlers, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
3679
+ export { BridgeError, type ContextPayload, type HostContext, type HostHandlers, HttpBridgeError, type InvalidateCachePayload, type InvalidateResult, type NavigateAction, type NavigatePayload, type RequestCompleteInfo, type RequestHandlers, type SetSideNavPayload, type SwitchSubsidiaryPayload, type UploadPayload, type UploadProgress, type UploadResponse, type VibeApiClientOptions, VibeAppHost, type VibeAppHostOptions };
package/dist/index.js CHANGED
@@ -1,7 +1,19 @@
1
1
  // package.json
2
- var version = "0.1.0";
2
+ var version = "0.2.0";
3
3
 
4
4
  // ../protocol-types/dist/index.js
5
+ function normalizeSubroute(subroute) {
6
+ const normalized = subroute.startsWith("/") ? subroute : `/${subroute}`;
7
+ const path = normalized.split(/[?#]/)[0];
8
+ let decoded = path;
9
+ let prev;
10
+ do {
11
+ prev = decoded;
12
+ decoded = decoded.replace(/%25/gi, "%").replace(/%2e/gi, ".").replace(/%2f/gi, "/");
13
+ } while (decoded !== prev);
14
+ if (decoded.split("/").some((segment) => segment === "..")) return null;
15
+ return normalized;
16
+ }
5
17
  var PROTOCOL_ID = "nominal-vibe-bridge";
6
18
  var PROTOCOL_VERSION = 1;
7
19
  var BRIDGE_KINDS = ["request", "response", "push", "command", "progress"];
@@ -18,6 +30,20 @@ function isBridgeMessage(data) {
18
30
  if (CORRELATED_KINDS_SET.has(d.kind) && typeof d.requestId !== "string") return false;
19
31
  return true;
20
32
  }
33
+ var BridgeError = class extends Error {
34
+ constructor(code, message) {
35
+ super(message);
36
+ this.code = code;
37
+ this.name = "BridgeError";
38
+ }
39
+ };
40
+ var HttpBridgeError = class extends BridgeError {
41
+ constructor(status, message) {
42
+ super("REQUEST_FAILED", message);
43
+ this.status = status;
44
+ this.name = "HttpBridgeError";
45
+ }
46
+ };
21
47
 
22
48
  // ../api-client/dist/index.js
23
49
  import {
@@ -352,10 +378,6 @@ var getSubsidiaryParentCurrenciesApiTenancySubsidiarySubsidiaryIdParentCurrencie
352
378
  };
353
379
  function createVibeApiClient({ baseUrl, stripApiPrefix }) {
354
380
  const client2 = createClient2(createConfig2({ baseUrl }));
355
- client2.interceptors.request.use((request) => {
356
- console.log("[vibe-api-client] request url:", request.url);
357
- return request;
358
- });
359
381
  if (stripApiPrefix) {
360
382
  client2.interceptors.request.use(
361
383
  (request) => new Request(request.url.replace(`${baseUrl}/api/`, `${baseUrl}/`), request)
@@ -364,16 +386,28 @@ function createVibeApiClient({ baseUrl, stripApiPrefix }) {
364
386
  return client2;
365
387
  }
366
388
 
367
- // src/handlerMap.ts
389
+ // src/handlers/call.ts
368
390
  async function call(fn, payload, client2) {
369
- const { data } = await fn({ ...payload, client: client2 });
370
- if (data == null) throw new Error("API returned no data");
391
+ const { data, response } = await fn({ ...payload, client: client2 });
392
+ if (data == null) {
393
+ if (response && !response.ok) {
394
+ throw new HttpBridgeError(
395
+ response.status,
396
+ `API request failed with status ${response.status}`
397
+ );
398
+ }
399
+ if (!response) {
400
+ throw new Error("API request produced no response");
401
+ }
402
+ return null;
403
+ }
371
404
  return data;
372
405
  }
373
- function createHandlerMap(options) {
374
- const client2 = createVibeApiClient(options);
406
+
407
+ // src/handlers/accounting.ts
408
+ function accountingHandlers(client2) {
375
409
  return {
376
- // Accounting — chart of accounts
410
+ // Chart of accounts
377
411
  GET_CHART_OF_ACCOUNTS: (p) => call(getCoaFlatApiAccountingSubsidiarySubsidiaryIdCoaAccountGet, p, client2),
378
412
  GET_COA_TREE: (p) => call(getCoaTreeApiAccountingSubsidiarySubsidiaryIdCoaGet, p, client2),
379
413
  GET_COA_FLAT_SIMPLE: (p) => call(
@@ -398,7 +432,7 @@ function createHandlerMap(options) {
398
432
  ),
399
433
  GET_ACCOUNTS: (p) => call(getAccountsApiAccountingAccountGet, p, client2),
400
434
  GET_ACCOUNT: (p) => call(getAccountApiAccountingAccountAccountIdGet, p, client2),
401
- // Accounting — exchange rates
435
+ // Exchange rates
402
436
  GET_CONVERSION_RATES: (p) => call(getConversionRateApiAccountingCurrencyCurrencyConversionRatesGet, p, client2),
403
437
  GET_EFFECTIVE_EXCHANGE_RATE: (p) => call(getEffectiveExchangeRateApiAccountingConsolidationExchangeRateGet, p, client2),
404
438
  GET_EXCHANGE_RATE_BY_DATE: (p) => call(
@@ -406,7 +440,7 @@ function createHandlerMap(options) {
406
440
  p,
407
441
  client2
408
442
  ),
409
- // Accounting — dimensions
443
+ // Dimensions
410
444
  GET_DIMENSIONS: (p) => call(getDimensionsApiAccountingDimensionGet, p, client2),
411
445
  GET_DIMENSION_VALUES: (p) => call(getDimensionValuesApiAccountingDimensionValueGet, p, client2),
412
446
  GET_DIMENSION_VALUES_HIERARCHICAL: (p) => call(
@@ -419,7 +453,7 @@ function createHandlerMap(options) {
419
453
  p,
420
454
  client2
421
455
  ),
422
- // Accounting — journal entries
456
+ // Journal entries
423
457
  GET_JOURNAL_ENTRIES: (p) => call(
424
458
  getSubsidiaryJournalApiAccountingSubsidiarySubsidiaryIdJournalEntryGet,
425
459
  p,
@@ -434,8 +468,14 @@ function createHandlerMap(options) {
434
468
  getJournalEntryApiAccountingSubsidiarySubsidiaryIdJournalEntryJournalEntryIdGet,
435
469
  p,
436
470
  client2
437
- ),
438
- // Activity — period instances
471
+ )
472
+ };
473
+ }
474
+
475
+ // src/handlers/activity.ts
476
+ function activityHandlers(client2) {
477
+ return {
478
+ // Period instances
439
479
  GET_PERIODS: (p) => call(getPeriodInstancesApiActivityPeriodInstanceGet, p, client2),
440
480
  GET_PERIOD_INSTANCE: (p) => call(getPeriodInstanceApiActivityPeriodInstancePeriodInstanceIdGet, p, client2),
441
481
  GET_PERIOD_INSTANCE_BY_SLUG: (p) => call(
@@ -448,14 +488,14 @@ function createHandlerMap(options) {
448
488
  p,
449
489
  client2
450
490
  ),
451
- // Activity — activity definitions
491
+ // Activity definitions
452
492
  GET_ACTIVITY_DEFINITIONS: (p) => call(getActivityDefinitionsApiActivityActivityDefinitionGet, p, client2),
453
493
  GET_ACTIVITY_DEFINITION: (p) => call(
454
494
  getActivityDefinitionByIdApiActivityActivityDefinitionActivityDefinitionIdGet,
455
495
  p,
456
496
  client2
457
497
  ),
458
- // Activity — activity instances
498
+ // Activity instances
459
499
  GET_ACTIVITY_INSTANCES: (p) => call(getActivityInstancesApiActivityActivityInstanceGet, p, client2),
460
500
  GET_ACTIVITY_INSTANCE: (p) => call(getActivityInstanceApiActivityActivityInstanceActivityInstanceIdGet, p, client2),
461
501
  GET_ACTIVITY_INSTANCE_BY_PERIOD: (p) => call(
@@ -473,28 +513,43 @@ function createHandlerMap(options) {
473
513
  p,
474
514
  client2
475
515
  ),
476
- // Activity — task definitions
516
+ // Task definitions
477
517
  GET_TASK_DEFINITIONS: (p) => call(getAllTasksDefinitionsApiActivityTaskDefinitionGet, p, client2),
478
518
  GET_TASK_DEFINITION: (p) => call(getTaskDefinitionByIdApiActivityTaskDefinitionTaskDefinitionIdGet, p, client2),
479
519
  CREATE_TASK_DEFINITION: (p) => call(createTaskDefinitionApiActivityTaskDefinitionPost, p, client2),
480
520
  UPDATE_TASK_DEFINITION: (p) => call(updateTaskDefinitionApiActivityTaskDefinitionTaskDefinitionIdPut, p, client2),
481
521
  GET_TASK_DEFINITIONS_BY_FILTER: (p) => call(getAllTasksDefinitionsByFilterApiActivityTaskDefinitionQueryPost, p, client2),
482
- // Activity — task instances
522
+ // Task instances
483
523
  GET_TASK_INSTANCES: (p) => call(getAllTaskInstancesApiActivityTaskInstanceGet, p, client2),
484
524
  GET_TASK_INSTANCE: (p) => call(getTaskInstanceByIdApiActivityTaskInstanceTaskInstanceIdGet, p, client2),
485
525
  POST_TASK_OUTPUT: (p) => call(updateTaskInstanceApiActivityTaskInstanceTaskInstanceIdPut, p, client2),
486
- GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2),
487
- // Audit trail
526
+ GET_TASK_INSTANCES_BY_FILTER: (p) => call(getAllTaskInstancesByFilterApiActivityTaskInstanceQueryPost, p, client2)
527
+ };
528
+ }
529
+
530
+ // src/handlers/audit-trail.ts
531
+ function auditTrailHandlers(client2) {
532
+ return {
488
533
  GET_AUDIT_EVENTS: (p) => call(getEventsApiAuditTrailEventGet, p, client2),
489
- GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2),
490
- // Period manager
534
+ GET_ENTITY_AUDIT_EVENTS: (p) => call(getEntityEventsApiAuditTrailEventEntityTargetTypeTargetIdPost, p, client2)
535
+ };
536
+ }
537
+
538
+ // src/handlers/period-manager.ts
539
+ function periodManagerHandlers(client2) {
540
+ return {
491
541
  GET_FISCAL_CALENDARS: (p) => call(
492
542
  getFiscalCalendarsBySubsidiaryApiPeriodManagerFiscalCalendarBySubsidiarySubsidiaryIdGet,
493
543
  p,
494
544
  client2
495
545
  ),
496
- GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2),
497
- // Tenancy
546
+ GET_FISCAL_CALENDAR: (p) => call(getFiscalCalendarByIdApiPeriodManagerFiscalCalendarCalendarIdGet, p, client2)
547
+ };
548
+ }
549
+
550
+ // src/handlers/tenancy.ts
551
+ function tenancyHandlers(client2) {
552
+ return {
498
553
  GET_SUBSIDIARIES: (p) => call(getAllSubsidiariesApiTenancySubsidiaryGet, p, client2),
499
554
  GET_SUBSIDIARY: (p) => call(getSubsidiaryApiTenancySubsidiarySubsidiaryIdGet, p, client2),
500
555
  GET_SUBSIDIARY_PARENT_CURRENCIES: (p) => call(
@@ -506,46 +561,115 @@ function createHandlerMap(options) {
506
561
  };
507
562
  }
508
563
 
509
- // src/uploadHandler.ts
510
- async function uploadHandler(payload, sendProgress) {
511
- const attachmentId = crypto.randomUUID();
512
- for (const progress of [25, 50, 75, 100]) {
513
- await new Promise((r) => setTimeout(r, 400));
514
- sendProgress({ attachmentId, progress });
564
+ // src/handlerMap.ts
565
+ function createHandlerMap(options) {
566
+ const client2 = createVibeApiClient(options);
567
+ return {
568
+ ...accountingHandlers(client2),
569
+ ...activityHandlers(client2),
570
+ ...auditTrailHandlers(client2),
571
+ ...periodManagerHandlers(client2),
572
+ ...tenancyHandlers(client2)
573
+ };
574
+ }
575
+
576
+ // src/rateLimiter.ts
577
+ var DEFAULT_RATE_LIMIT = 60;
578
+ function createRateLimiter(limit = DEFAULT_RATE_LIMIT, windowMs = 6e4) {
579
+ return { timestamps: [], limit, windowMs };
580
+ }
581
+ function checkRateLimit(state) {
582
+ const now = Date.now();
583
+ const cutoff = now - state.windowMs;
584
+ state.timestamps = state.timestamps.filter((t) => t > cutoff);
585
+ if (state.timestamps.length >= state.limit) {
586
+ return false;
515
587
  }
516
- return { attachmentId, name: payload.fileName };
588
+ state.timestamps.push(now);
589
+ return true;
590
+ }
591
+
592
+ // src/unwired.ts
593
+ function warnUnwired(command) {
594
+ return () => {
595
+ console.warn(`[vibe-host] Received "${command}" command but no handler is wired \u2014 ignoring.`);
596
+ };
597
+ }
598
+ function rejectUnwired(op) {
599
+ return () => Promise.reject(
600
+ new BridgeError("NOT_WIRED", `[vibe-host] Received "${op}" request but no handler is wired.`)
601
+ );
517
602
  }
518
603
 
519
604
  // src/VibeAppHost.ts
520
605
  var VibeAppHost = class {
521
- constructor({ trustedOrigins, getContext, appBasePath, clientConfig }) {
606
+ constructor(opts) {
522
607
  this.iframeWindow = null;
608
+ /**
609
+ * Source windows we've already logged a successful handshake for. The bridge
610
+ * polls `GET_CONTEXT` on an interval until it connects, so without this the
611
+ * "Vibe App connected" line repeats on every poll (SSOT §13 F3).
612
+ */
613
+ this.handshakeLoggedWindows = /* @__PURE__ */ new WeakSet();
523
614
  this.kindHandlers = {
524
615
  request: (msg, source, origin) => this.handleRequest(msg, source, origin),
525
616
  command: (msg) => this.dispatchCommand(msg)
526
617
  };
527
- this.commandHandlers = {
528
- REPORT_SUBROUTE: (payload) => {
529
- const newPath = payload.subroute === "/" ? this.appBasePath : `${this.appBasePath}${payload.subroute}`;
530
- const method = payload.replace ? "replaceState" : "pushState";
531
- window.history[method](null, "", newPath);
532
- }
533
- };
618
+ const { trustedOrigins, getContext, appBasePath, clientConfig } = opts;
534
619
  this.trustedOrigins = new Set(trustedOrigins);
535
620
  this.getContext = getContext;
536
621
  this.appBasePath = appBasePath;
622
+ this.onRequestComplete = opts.onRequestComplete;
623
+ this.rateLimiter = createRateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
537
624
  this.boundHandleMessage = this.handleMessage.bind(this);
538
625
  this.boundHandlePopState = this.handlePopState.bind(this);
539
- this.handlers = this.initializeHandlers(clientConfig || { baseUrl: "", stripApiPrefix: false });
626
+ this.handlers = this.initializeHandlers(
627
+ clientConfig || { baseUrl: "", stripApiPrefix: false },
628
+ opts
629
+ );
630
+ this.commandHandlers = this.buildCommandHandlers(opts);
540
631
  }
541
632
  dispatchCommand(msg) {
542
633
  const handler = this.commandHandlers[msg.type];
543
- handler(msg.payload);
634
+ if (!handler) {
635
+ console.warn(`[vibe-host] Received unknown command "${msg.type}" \u2014 ignoring.`);
636
+ return;
637
+ }
638
+ try {
639
+ handler(msg.payload);
640
+ } catch (err) {
641
+ console.warn(`[vibe-host] Command "${msg.type}" handler threw \u2014 ignoring.`, err);
642
+ }
544
643
  }
545
- initializeHandlers(clientConfig) {
644
+ /**
645
+ * Commands: `REPORT_SUBROUTE` is handled internally (transport + traversal
646
+ * guard, item `l`); navigation/UI side effects are injected by the consumer,
647
+ * falling back to warn-and-ignore (DP4).
648
+ */
649
+ buildCommandHandlers(opts) {
650
+ return {
651
+ REPORT_SUBROUTE: (payload) => {
652
+ const safe = normalizeSubroute(payload.subroute);
653
+ if (safe === null) return;
654
+ const newPath = safe === "/" ? this.appBasePath : `${this.appBasePath}${safe}`;
655
+ const method = payload.replace ? "replaceState" : "pushState";
656
+ window.history[method](null, "", newPath);
657
+ },
658
+ NAVIGATE: opts.onNavigate ?? warnUnwired("NAVIGATE"),
659
+ SET_SIDE_NAV: opts.onSetSideNav ?? warnUnwired("SET_SIDE_NAV"),
660
+ SWITCH_SUBSIDIARY: opts.onSwitchSubsidiary ?? warnUnwired("SWITCH_SUBSIDIARY")
661
+ };
662
+ }
663
+ /**
664
+ * Requests: the built-in API map (hey-api), plus the injected/awaited side
665
+ * effects with safe defaults — unwired `UPLOAD_FILE` rejects with a coded
666
+ * error; unwired `INVALIDATE_CACHE` resolves to a no-op (DP4).
667
+ */
668
+ initializeHandlers(clientConfig, opts) {
546
669
  return {
547
670
  ...createHandlerMap(clientConfig),
548
- UPLOAD_FILE: uploadHandler
671
+ UPLOAD_FILE: opts.onUpload ?? rejectUnwired("UPLOAD_FILE"),
672
+ INVALIDATE_CACHE: opts.onInvalidateCache ?? (async () => ({ invalidated: false }))
549
673
  };
550
674
  }
551
675
  /**
@@ -617,12 +741,33 @@ var VibeAppHost = class {
617
741
  async handleRequest(msg, source, origin) {
618
742
  const { requestId, type } = msg;
619
743
  if (type === "GET_CONTEXT") {
620
- const ctx = { ...this.getContext(), hostVersion: version };
621
- const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
622
- console.log(
623
- `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
624
- );
625
- this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
744
+ try {
745
+ const ctx = { ...this.getContext(), hostVersion: version };
746
+ if (!this.handshakeLoggedWindows.has(source)) {
747
+ this.handshakeLoggedWindows.add(source);
748
+ const bridgeVersion = msg.payload?.bridgeVersion ?? "unknown";
749
+ console.log(
750
+ `[vibe-host] Vibe App connected \u2014 bridge: ${bridgeVersion}, host: ${version}, tenant: ${ctx.tenant}, user: ${ctx.user.displayName}`
751
+ );
752
+ }
753
+ this.respond(source, origin, { kind: "response", type, requestId, ok: true, data: ctx });
754
+ } catch (err) {
755
+ const error = err instanceof Error ? err.message : String(err);
756
+ const code = err instanceof BridgeError ? err.code : "CONTEXT_ERROR";
757
+ this.respond(source, origin, { kind: "response", type, requestId, ok: false, error, code });
758
+ }
759
+ return;
760
+ }
761
+ if (!checkRateLimit(this.rateLimiter)) {
762
+ this.respond(source, origin, {
763
+ kind: "response",
764
+ type,
765
+ requestId,
766
+ ok: false,
767
+ error: `Too many requests. Limit is ${this.rateLimiter.limit} per minute.`,
768
+ code: "RATE_LIMITED"
769
+ });
770
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
626
771
  return;
627
772
  }
628
773
  const sendProgress = (payload) => {
@@ -640,9 +785,21 @@ var VibeAppHost = class {
640
785
  const handler = this.handlers[type];
641
786
  const data = await handler(msg.payload, sendProgress);
642
787
  this.respond(source, origin, { kind: "response", type, requestId, ok: true, data });
788
+ this.onRequestComplete?.({ type, ok: true, payload: msg.payload });
643
789
  } catch (err) {
644
790
  const error = err instanceof Error ? err.message : String(err);
645
- this.respond(source, origin, { kind: "response", type, requestId, ok: false, error });
791
+ const code = err instanceof BridgeError ? err.code : void 0;
792
+ const status = err instanceof HttpBridgeError ? err.status : void 0;
793
+ this.respond(source, origin, {
794
+ kind: "response",
795
+ type,
796
+ requestId,
797
+ ok: false,
798
+ error,
799
+ code,
800
+ status
801
+ });
802
+ this.onRequestComplete?.({ type, ok: false, payload: msg.payload });
646
803
  }
647
804
  }
648
805
  respond(target, origin, response) {
@@ -653,5 +810,7 @@ var VibeAppHost = class {
653
810
  }
654
811
  };
655
812
  export {
813
+ BridgeError,
814
+ HttpBridgeError,
656
815
  VibeAppHost
657
816
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nominalso/vibe-host",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Host-side SDK for embedding Nominal Vibe Apps — receives bridge requests over a typed postMessage protocol and dispatches them to Nominal APIs (used by nom-ui).",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/nominalso/vibe-apps-sdk#readme",