@nominalso/vibe-host 0.0.1 → 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/README.md CHANGED
@@ -1,9 +1,8 @@
1
1
  # @nominalso/vibe-host
2
2
 
3
- Host-side SDK for embedding **Nominal Vibe Apps**. Used by the Nominal app (nom-ui) to receive requests from an
4
- embedded Vibe App over a typed `postMessage` protocol and dispatch them to Nominal APIs. The host ships a
5
- **complete, type-checked handler map** covering every protocol operation you only supply a `clientConfig`
6
- pointing at the Nominal API, and the SDK wires up and dispatches all operations for you.
3
+ Host-side SDK for embedding **Nominal Vibe Apps**. Used by the Nominal app (nom-ui) to receive requests from an embedded Vibe App over a typed `postMessage` protocol and dispatch them to Nominal APIs. The host ships a **complete, type-checked handler map** covering every protocol operation — you supply a `clientConfig` pointing at the Nominal API, and the SDK wires up and dispatches all operations for you.
4
+
5
+ > **For AI agents:** you do **not** pass a `handlers` map. Provide `clientConfig` (where the Nominal API lives) and the SDK builds every handler. The iframe-side counterpart is `@nominalso/vibe-bridge`.
7
6
 
8
7
  ## Install
9
8
 
@@ -11,28 +10,93 @@ pointing at the Nominal API, and the SDK wires up and dispatches all operations
11
10
  npm install @nominalso/vibe-host
12
11
  ```
13
12
 
14
- ## Usage
13
+ Externalizes only `@hey-api/client-fetch`; TypeScript types are self-contained.
14
+
15
+ ## Quickstart
15
16
 
16
17
  ```tsx
17
18
  import { VibeAppHost } from '@nominalso/vibe-host'
18
19
 
19
20
  const host = new VibeAppHost({
21
+ // Iframe origin(s) allowed to talk to this host — must match exactly.
20
22
  trustedOrigins: ['https://my-vibe-app.lovable.app'],
23
+ // This Vibe App's base path in nom-ui (used to sync the browser URL).
21
24
  appBasePath: `/${tenant}/${subsidiary}/apps/${slug}`,
25
+ // Context pushed to the iframe on connect.
22
26
  getContext: () => ({ tenant, subsidiaryId, subsidiaries, user, lastClosedPeriodSlug }),
23
27
  // Points the built-in handler map at the Nominal API (e.g. nom-ui's proxy route).
24
- // The SDK provides handlers for every protocol operation out of the box.
25
28
  clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
26
29
  })
27
30
 
28
- // Start listening before the iframe loads, then push context on its `load` event.
31
+ // Start listening BEFORE the iframe loads, then push context on its `load` event.
29
32
  const unmount = host.mount()
30
- // host.pushContextTo(iframe.contentWindow)
33
+ // once the iframe has loaded: host.pushContextTo(iframe.contentWindow)
31
34
  ```
32
35
 
33
- The iframe side is [`@nominalso/vibe-bridge`](https://www.npmjs.com/package/@nominalso/vibe-bridge). See the
34
- [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol, architecture, and the
35
- SSR / iframe-mounting notes.
36
+ ## API
37
+
38
+ ### `new VibeAppHost(options)`
39
+
40
+ | Option | Type | Description |
41
+ | ---------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
42
+ | `trustedOrigins` | `string[]` | Iframe origins allowed to talk to this host. Messages from other origins are ignored. |
43
+ | `getContext` | `() => HostContext` | Returns the current context to send to the iframe (`hostVersion` is injected automatically). |
44
+ | `appBasePath` | `string` | Base URL path of this Vibe App in nom-ui, e.g. `/acme/1/apps/fixed-assets`. |
45
+ | `clientConfig` | `VibeApiClientOptions?` | Points the built-in handler map at the Nominal API. Defaults to `{ baseUrl: '', stripApiPrefix: false }`. |
46
+
47
+ `VibeApiClientOptions` is `{ baseUrl: string; stripApiPrefix?: boolean }`. Set `stripApiPrefix: true` when routing through a proxy whose convention expects paths without the `/api` prefix.
48
+
49
+ ### `mount(iframeWindow?): () => void`
50
+
51
+ Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Pass `iframeWindow` to immediately push context; if omitted, the host answers `GET_CONTEXT` polls but won't proactively push until you call `pushContextTo`.
52
+
53
+ ### `pushContextTo(iframeWindow): void`
54
+
55
+ Pushes the current context to the iframe and stores the window reference for future pushes (e.g. subroute sync). Call once the iframe has loaded if you called `mount()` with no argument.
56
+
57
+ ### Exports
58
+
59
+ `VibeAppHost`, and the types `VibeAppHostOptions`, `HostContext`, `HostHandlers`, `RequestHandlers`, `ContextPayload`, `VibeApiClientOptions`.
60
+
61
+ ## Mounting inside Next.js (nom-ui)
62
+
63
+ The iframe is server-rendered, so it may start loading before React hydrates, and cross-origin `contentDocument` is always `null`. The robust pattern: `mount()` (no arg) immediately to start listening, then `pushContextTo(iframe.contentWindow)` from the iframe's `load` event.
64
+
65
+ ```tsx
66
+ 'use client'
67
+ import { useEffect, useRef } from 'react'
68
+ import { VibeAppHost } from '@nominalso/vibe-host'
69
+
70
+ function VibeAppFrame({ host, src }: { host: VibeAppHost; src: string }) {
71
+ const iframeRef = useRef<HTMLIFrameElement>(null)
72
+
73
+ useEffect(() => {
74
+ const iframe = iframeRef.current
75
+ if (!iframe) return
76
+ const unmount = host.mount() // listen before the iframe loads
77
+ const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
78
+ iframe.addEventListener('load', onLoad)
79
+ return () => {
80
+ iframe.removeEventListener('load', onLoad)
81
+ unmount()
82
+ }
83
+ }, [host])
84
+
85
+ return (
86
+ <iframe
87
+ ref={iframeRef}
88
+ src={src}
89
+ sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
90
+ />
91
+ )
92
+ }
93
+ ```
94
+
95
+ Calling `contentWindow.postMessage` while the iframe is still at `about:blank` throws a `DOMException` — that's why context is pushed from the `load` event, not on effect setup.
96
+
97
+ ## How it fits together
98
+
99
+ The iframe side is [`@nominalso/vibe-bridge`](https://www.npmjs.com/package/@nominalso/vibe-bridge). See the [repository](https://github.com/nominalso/vibe-apps-sdk#readme) for the full protocol and architecture.
36
100
 
37
101
  ## License
38
102
 
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.0.1";
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
  });