@pellux/goodvibes-transport-realtime 0.18.3 → 0.30.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,24 +1,20 @@
1
1
  # @pellux/goodvibes-transport-realtime
2
2
 
3
- Realtime event-domain connectors for GoodVibes SSE and WebSocket integrations.
3
+ Internal workspace package backing `@pellux/goodvibes-sdk/transport-realtime`.
4
4
 
5
- Install:
5
+ Consumers should install `@pellux/goodvibes-sdk` and import this surface from the umbrella package.
6
6
 
7
- ```bash
8
- npm install @pellux/goodvibes-transport-realtime
9
- ```
10
-
11
- Example:
7
+ Consumer import:
12
8
 
13
9
  ```ts
14
10
  import {
15
11
  createEventSourceConnector,
16
12
  createRemoteRuntimeEvents,
17
- } from '@pellux/goodvibes-transport-realtime';
13
+ } from '@pellux/goodvibes-sdk/transport-realtime';
18
14
 
19
15
  const events = createRemoteRuntimeEvents(
20
16
  createEventSourceConnector('https://goodvibes.example.com', 'token', fetch),
21
17
  );
22
18
  ```
23
19
 
24
- Use this package when you want runtime-event subscriptions without pulling in the full umbrella SDK.
20
+ Use this surface when you want runtime-event subscriptions without pulling in the full umbrella SDK.
@@ -13,6 +13,35 @@ export interface SerializedEventEnvelope<TEvent extends EventLike = EventLike> {
13
13
  }
14
14
  export type DomainEventConnector<TDomain extends string, TEvent extends EventLike = EventLike> = (domain: TDomain, onEnvelope: (envelope: SerializedEventEnvelope<TEvent>) => void) => void | Promise<() => void>;
15
15
  export type DomainEvents<TDomain extends string, TEvent extends EventLike = EventLike> = RuntimeEventFeeds<TDomain, TEvent>;
16
- export declare function createRemoteDomainEvents<TDomain extends string, TEvent extends EventLike = EventLike>(domains: readonly TDomain[], connect: DomainEventConnector<TDomain, TEvent>): DomainEvents<TDomain, TEvent>;
16
+ export interface RemoteDomainEventsOptions<TDomain extends string = string> {
17
+ readonly onConnectionError?: (error: Error, domain: TDomain) => void;
18
+ }
19
+ export declare function createRemoteDomainEvents<TDomain extends string, TEvent extends EventLike = EventLike>(domains: readonly TDomain[], connect: DomainEventConnector<TDomain, TEvent>, options?: RemoteDomainEventsOptions<TDomain>): DomainEvents<TDomain, TEvent>;
20
+ /**
21
+ * Returns a filtered view of the given domain events object where every
22
+ * callback only fires for events whose envelope `sessionId` equals the
23
+ * supplied value.
24
+ *
25
+ * All domain feeds and the `domain()` accessor are pre-filtered. The
26
+ * `domains` list is preserved unchanged.
27
+ *
28
+ * Unsubscribe handles returned by the filtered feeds propagate correctly
29
+ * to the underlying connection.
30
+ *
31
+ * @example
32
+ * const events = sdk.realtime.viaSse();
33
+ * // Without forSession — manual filter:
34
+ * events.turn.onEnvelope('STREAM_DELTA', (e) => {
35
+ * if (e.sessionId !== mySessionId) return;
36
+ * process.stdout.write(e.payload.content);
37
+ * });
38
+ *
39
+ * // With forSession — no filter needed:
40
+ * const sessionEvents = forSession(events, mySessionId);
41
+ * sessionEvents.turn.onEnvelope('STREAM_DELTA', (e) => {
42
+ * process.stdout.write(e.payload.content);
43
+ * });
44
+ */
45
+ export declare function forSession<TDomain extends string, TEvent extends EventLike = EventLike>(events: DomainEvents<TDomain, TEvent>, sessionId: string): DomainEvents<TDomain, TEvent>;
17
46
  export {};
18
47
  //# sourceMappingURL=domain-events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"domain-events.d.ts","sourceRoot":"","sources":["../src/domain-events.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAE1H,KAAK,SAAS,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3C,MAAM,WAAW,uBAAuB,CAAC,MAAM,SAAS,SAAS,GAAG,SAAS;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,MAAM,oBAAoB,CAC9B,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,IAClC,CACF,MAAM,EAAE,OAAO,EACf,UAAU,EAAE,CAAC,QAAQ,EAAE,uBAAuB,CAAC,MAAM,CAAC,KAAK,IAAI,KAC5D,IAAI,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;AAEhC,MAAM,MAAM,YAAY,CACtB,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,IAClC,iBAAiB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;AAmIvC,wBAAgB,wBAAwB,CACtC,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,EAEpC,OAAO,EAAE,SAAS,OAAO,EAAE,EAC3B,OAAO,EAAE,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,GAC7C,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAK/B"}
1
+ {"version":3,"file":"domain-events.d.ts","sourceRoot":"","sources":["../src/domain-events.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,kCAAkC,CAAC;AAE1C,KAAK,SAAS,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3C,MAAM,WAAW,uBAAuB,CAAC,MAAM,SAAS,SAAS,GAAG,SAAS;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,MAAM,oBAAoB,CAC9B,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,IAClC,CACF,MAAM,EAAE,OAAO,EACf,UAAU,EAAE,CAAC,QAAQ,EAAE,uBAAuB,CAAC,MAAM,CAAC,KAAK,IAAI,KAC5D,IAAI,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;AAEhC,MAAM,MAAM,YAAY,CACtB,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,IAClC,iBAAiB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;AAEvC,MAAM,WAAW,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM;IACxE,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACtE;AAyID,wBAAgB,wBAAwB,CACtC,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,EAEpC,OAAO,EAAE,SAAS,OAAO,EAAE,EAC3B,OAAO,EAAE,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,EAC9C,OAAO,GAAE,yBAAyB,CAAC,OAAO,CAAM,GAC/C,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAK/B;AAmCD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,UAAU,CACxB,OAAO,SAAS,MAAM,EACtB,MAAM,SAAS,SAAS,GAAG,SAAS,EAEpC,MAAM,EAAE,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,EACrC,SAAS,EAAE,MAAM,GAChB,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAY/B"}
@@ -1,4 +1,4 @@
1
- import { createRuntimeEventFeeds } from '@pellux/goodvibes-transport-core';
1
+ import { createRuntimeEventFeeds, isAbortError, transportErrorFromUnknown, } from '@pellux/goodvibes-transport-core';
2
2
  function addListener(map, type, listener) {
3
3
  const listeners = map.get(type) ?? new Set();
4
4
  listeners.add(listener);
@@ -20,11 +20,14 @@ function hasAnyListener(map) {
20
20
  }
21
21
  return false;
22
22
  }
23
- function isExpectedDisconnectError(error) {
24
- return (error instanceof DOMException && error.name === 'AbortError') || (typeof error === 'object'
25
- && error !== null
26
- && 'name' in error
27
- && error.name === 'AbortError');
23
+ const isExpectedDisconnectError = isAbortError;
24
+ function normalizeConnectionError(error, domain) {
25
+ return error instanceof Error
26
+ ? error
27
+ : transportErrorFromUnknown(error, `Remote domain event connection for "${domain}" failed`);
28
+ }
29
+ function reportUnexpectedConnectionError(error, domain, options) {
30
+ options.onConnectionError?.(normalizeConnectionError(error, domain), domain);
28
31
  }
29
32
  function toEventEnvelope(envelope) {
30
33
  return {
@@ -40,7 +43,7 @@ function toEventEnvelope(envelope) {
40
43
  payload: envelope.payload,
41
44
  };
42
45
  }
43
- function createRemoteDomainEventFeed(domain, connect) {
46
+ function createRemoteDomainEventFeed(domain, connect, options) {
44
47
  const payloadListeners = new Map();
45
48
  const envelopeListeners = new Map();
46
49
  let disconnect = null;
@@ -73,7 +76,7 @@ function createRemoteDomainEventFeed(domain, connect) {
73
76
  disconnect = cleanup;
74
77
  }).catch((error) => {
75
78
  if (!isExpectedDisconnectError(error)) {
76
- throw error;
79
+ reportUnexpectedConnectionError(error, domain, options);
77
80
  }
78
81
  }).finally(() => {
79
82
  connectPromise = null;
@@ -111,6 +114,69 @@ function createRemoteDomainEventFeed(domain, connect) {
111
114
  },
112
115
  };
113
116
  }
114
- export function createRemoteDomainEvents(domains, connect) {
115
- return createRuntimeEventFeeds(domains, (domain) => createRemoteDomainEventFeed(domain, connect));
117
+ export function createRemoteDomainEvents(domains, connect, options = {}) {
118
+ return createRuntimeEventFeeds(domains, (domain) => createRemoteDomainEventFeed(domain, connect, options));
119
+ }
120
+ /**
121
+ * Wraps an existing {@link RuntimeEventFeed} and returns a filtered feed whose
122
+ * callbacks only fire for envelopes whose `sessionId` matches the given value.
123
+ *
124
+ * Unsubscribe handles returned by `on` / `onEnvelope` on the filtered feed
125
+ * correctly remove the underlying listener from the original feed.
126
+ */
127
+ function createFilteredFeed(feed, sessionId) {
128
+ return {
129
+ on(type, listener) {
130
+ return feed.onEnvelope(type, (envelope) => {
131
+ if (envelope.sessionId !== sessionId)
132
+ return;
133
+ listener(envelope.payload);
134
+ });
135
+ },
136
+ onEnvelope(type, listener) {
137
+ return feed.onEnvelope(type, (envelope) => {
138
+ if (envelope.sessionId !== sessionId)
139
+ return;
140
+ listener(envelope);
141
+ });
142
+ },
143
+ };
144
+ }
145
+ /**
146
+ * Returns a filtered view of the given domain events object where every
147
+ * callback only fires for events whose envelope `sessionId` equals the
148
+ * supplied value.
149
+ *
150
+ * All domain feeds and the `domain()` accessor are pre-filtered. The
151
+ * `domains` list is preserved unchanged.
152
+ *
153
+ * Unsubscribe handles returned by the filtered feeds propagate correctly
154
+ * to the underlying connection.
155
+ *
156
+ * @example
157
+ * const events = sdk.realtime.viaSse();
158
+ * // Without forSession — manual filter:
159
+ * events.turn.onEnvelope('STREAM_DELTA', (e) => {
160
+ * if (e.sessionId !== mySessionId) return;
161
+ * process.stdout.write(e.payload.content);
162
+ * });
163
+ *
164
+ * // With forSession — no filter needed:
165
+ * const sessionEvents = forSession(events, mySessionId);
166
+ * sessionEvents.turn.onEnvelope('STREAM_DELTA', (e) => {
167
+ * process.stdout.write(e.payload.content);
168
+ * });
169
+ */
170
+ export function forSession(events, sessionId) {
171
+ const filteredFeeds = {};
172
+ for (const domain of events.domains) {
173
+ filteredFeeds[domain] = createFilteredFeed(events[domain], sessionId);
174
+ }
175
+ return Object.freeze({
176
+ ...filteredFeeds,
177
+ domains: events.domains,
178
+ domain(d) {
179
+ return filteredFeeds[d];
180
+ },
181
+ });
116
182
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type { DomainEventConnector, DomainEvents, SerializedEventEnvelope, } from './domain-events.js';
2
- export { createRemoteDomainEvents } from './domain-events.js';
3
- export type { RemoteRuntimeEvents, SerializedRuntimeEnvelope } from './runtime-events.js';
4
- export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, } from './runtime-events.js';
1
+ export type { DomainEventConnector, DomainEvents, RemoteDomainEventsOptions, SerializedEventEnvelope, } from './domain-events.js';
2
+ export { createRemoteDomainEvents, forSession } from './domain-events.js';
3
+ export type { RemoteRuntimeEvents, RemoteRuntimeEventsOptions, SerializedRuntimeEnvelope } from './runtime-events.js';
4
+ export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, DEFAULT_WS_MAX_ATTEMPTS, forSessionRuntime, } from './runtime-events.js';
5
5
  export type { RuntimeEventConnectorOptions } from './runtime-events.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,YAAY,EACZ,uBAAuB,GACxB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,YAAY,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAC1F,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,0BAA0B,EAC1B,yBAAyB,EACzB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,4BAA4B,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,oBAAoB,EACpB,YAAY,EACZ,yBAAyB,EACzB,uBAAuB,GACxB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,wBAAwB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AACtH,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,0BAA0B,EAC1B,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,4BAA4B,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { createRemoteDomainEvents } from './domain-events.js';
2
- export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, } from './runtime-events.js';
1
+ export { createRemoteDomainEvents, forSession } from './domain-events.js';
2
+ export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, DEFAULT_WS_MAX_ATTEMPTS, forSessionRuntime, } from './runtime-events.js';
@@ -1,20 +1,67 @@
1
1
  import { type RuntimeEventDomain } from '@pellux/goodvibes-contracts';
2
+ import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
2
3
  import { type AuthTokenResolver, type StreamReconnectPolicy } from '@pellux/goodvibes-transport-http';
4
+ import { type TransportObserver } from '@pellux/goodvibes-transport-core';
3
5
  import { type DomainEventConnector, type DomainEvents, type SerializedEventEnvelope } from './domain-events.js';
4
6
  type RuntimeEventRecord = {
5
7
  readonly type: string;
6
8
  };
7
9
  export type SerializedRuntimeEnvelope<TEvent extends RuntimeEventRecord = RuntimeEventRecord> = SerializedEventEnvelope<TEvent>;
8
10
  export type RemoteRuntimeEvents<TEvent extends RuntimeEventRecord = RuntimeEventRecord> = DomainEvents<RuntimeEventDomain, TEvent>;
11
+ export interface RemoteRuntimeEventsOptions {
12
+ readonly onError?: (error: Error, domain: RuntimeEventDomain) => void;
13
+ readonly observer?: TransportObserver;
14
+ }
15
+ /**
16
+ * Returns a filtered view of a {@link RemoteRuntimeEvents} object where every
17
+ * callback only fires for events whose envelope `sessionId` equals the given
18
+ * session identifier.
19
+ *
20
+ * This is a convenience wrapper around {@link forSession} scoped to the
21
+ * canonical runtime-event domains. Use it instead of manually checking
22
+ * `e.sessionId` in every callback.
23
+ *
24
+ * @example
25
+ * const events = sdk.realtime.viaSse();
26
+ * const session = await sdk.operator.sessions.create({ title: 'demo' });
27
+ * const sessionId = session.session.id;
28
+ *
29
+ * // Before forSession — repeated manual guard:
30
+ * events.turn.onEnvelope('STREAM_DELTA', (e) => {
31
+ * if (e.sessionId !== sessionId) return;
32
+ * process.stdout.write(e.payload.content);
33
+ * });
34
+ *
35
+ * // After forSession — clean, session-scoped subscription:
36
+ * const sessionEvents = forSessionRuntime(events, sessionId);
37
+ * sessionEvents.turn.onEnvelope('STREAM_DELTA', (e) => {
38
+ * process.stdout.write(e.payload.content);
39
+ * });
40
+ */
41
+ export { forSession as forSessionRuntime } from './domain-events.js';
9
42
  export interface RuntimeEventConnectorOptions {
10
43
  readonly reconnect?: StreamReconnectPolicy;
11
44
  readonly onError?: (error: unknown) => void;
45
+ readonly observer?: TransportObserver;
46
+ /**
47
+ * Called once the WebSocket connector is set up, providing an `emitLocal`
48
+ * function the caller can use to send messages over this connection.
49
+ * Primarily for tests and local harnesses that need to inject outbound frames.
50
+ */
51
+ readonly onEmitter?: (emitLocal: (data: string) => void) => void;
12
52
  }
13
53
  type AuthTokenSource = string | null | undefined | AuthTokenResolver;
14
- export declare function createRemoteRuntimeEvents<TEvent extends RuntimeEventRecord = RuntimeEventRecord>(connect: DomainEventConnector<RuntimeEventDomain, TEvent>): RemoteRuntimeEvents<TEvent>;
54
+ /** Default max reconnect attempts for WebSocket connections (finite to prevent infinite auth loops). */
55
+ export declare const DEFAULT_WS_MAX_ATTEMPTS = 10;
56
+ export declare class WebSocketTransportError extends GoodVibesSdkError {
57
+ constructor(message: string, options?: {
58
+ readonly cause?: unknown;
59
+ readonly hint?: string;
60
+ });
61
+ }
62
+ export declare function createRemoteRuntimeEvents<TEvent extends RuntimeEventRecord = RuntimeEventRecord>(connect: DomainEventConnector<RuntimeEventDomain, TEvent>, options?: RemoteRuntimeEventsOptions): RemoteRuntimeEvents<TEvent>;
15
63
  export declare function buildEventSourceUrl(baseUrl: string, domain: RuntimeEventDomain): string;
16
64
  export declare function buildWebSocketUrl(baseUrl: string, domains: readonly RuntimeEventDomain[]): string;
17
- export declare function createEventSourceConnector(baseUrl: string, token: AuthTokenSource, fetchImpl: typeof fetch, options?: RuntimeEventConnectorOptions): DomainEventConnector<RuntimeEventDomain, RuntimeEventRecord>;
18
- export declare function createWebSocketConnector(baseUrl: string, token: AuthTokenSource, WebSocketImpl: typeof WebSocket, options?: RuntimeEventConnectorOptions): DomainEventConnector<RuntimeEventDomain, RuntimeEventRecord>;
19
- export {};
65
+ export declare function createEventSourceConnector<TEvent extends RuntimeEventRecord = RuntimeEventRecord>(baseUrl: string, token: AuthTokenSource, fetchImpl: typeof fetch, options?: RuntimeEventConnectorOptions): DomainEventConnector<RuntimeEventDomain, TEvent>;
66
+ export declare function createWebSocketConnector<TEvent extends RuntimeEventRecord = RuntimeEventRecord>(baseUrl: string, token: AuthTokenSource, WebSocketImpl: typeof WebSocket, options?: RuntimeEventConnectorOptions): DomainEventConnector<RuntimeEventDomain, TEvent>;
20
67
  //# sourceMappingURL=runtime-events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-events.d.ts","sourceRoot":"","sources":["../src/runtime-events.ts"],"names":[],"mappings":"AACA,OAAO,EAAyB,KAAK,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAC7F,OAAO,EAAoB,KAAK,iBAAiB,EAAE,KAAK,qBAAqB,EAA6D,MAAM,kCAAkC,CAAC;AAEnL,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,YAAY,EACjB,KAAK,uBAAuB,EAC7B,MAAM,oBAAoB,CAAC;AAE5B,KAAK,kBAAkB,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD,MAAM,MAAM,yBAAyB,CAAC,MAAM,SAAS,kBAAkB,GAAG,kBAAkB,IAC1F,uBAAuB,CAAC,MAAM,CAAC,CAAC;AAElC,MAAM,MAAM,mBAAmB,CAAC,MAAM,SAAS,kBAAkB,GAAG,kBAAkB,IACpF,YAAY,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;AAE3C,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC7C;AAED,KAAK,eAAe,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,iBAAiB,CAAC;AASrE,wBAAgB,yBAAyB,CAAC,MAAM,SAAS,kBAAkB,GAAG,kBAAkB,EAC9F,OAAO,EAAE,oBAAoB,CAAC,kBAAkB,EAAE,MAAM,CAAC,GACxD,mBAAmB,CAAC,MAAM,CAAC,CAK7B;AAED,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,kBAAkB,GACzB,MAAM,CAIR;AAED,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,SAAS,kBAAkB,EAAE,GACrC,MAAM,CAQR;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,OAAO,KAAK,EACvB,OAAO,GAAE,4BAAiC,GACzC,oBAAoB,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAiB9D;AAED,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,EACtB,aAAa,EAAE,OAAO,SAAS,EAC/B,OAAO,GAAE,4BAAiC,GACzC,oBAAoB,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAuF9D"}
1
+ {"version":3,"file":"runtime-events.d.ts","sourceRoot":"","sources":["../src/runtime-events.ts"],"names":[],"mappings":"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"}
@@ -1,16 +1,67 @@
1
- // Synced from goodvibes-tui/src/runtime/transports/runtime-events-client.ts
2
1
  import { RUNTIME_EVENT_DOMAINS } from '@pellux/goodvibes-contracts';
3
- import { resolveAuthToken, openRawServerSentEventStream as openServerSentEventStream } from '@pellux/goodvibes-transport-http';
2
+ import { ConfigurationError, GoodVibesSdkError } from '@pellux/goodvibes-errors';
3
+ import { normalizeAuthToken, openRawServerSentEventStream as openServerSentEventStream, normalizeStreamReconnectPolicy, getStreamReconnectDelay, } from '@pellux/goodvibes-transport-http';
4
4
  import { buildUrl, normalizeBaseUrl } from '@pellux/goodvibes-transport-http';
5
+ import { describeUnknownTransportError, injectTraceparentAsync, invokeTransportObserver, transportErrorFromUnknown, } from '@pellux/goodvibes-transport-core';
5
6
  import { createRemoteDomainEvents, } from './domain-events.js';
6
- async function resolveAuthTokenSource(source) {
7
- if (typeof source === 'function') {
8
- return await resolveAuthToken(null, source);
7
+ /**
8
+ * Returns a filtered view of a {@link RemoteRuntimeEvents} object where every
9
+ * callback only fires for events whose envelope `sessionId` equals the given
10
+ * session identifier.
11
+ *
12
+ * This is a convenience wrapper around {@link forSession} scoped to the
13
+ * canonical runtime-event domains. Use it instead of manually checking
14
+ * `e.sessionId` in every callback.
15
+ *
16
+ * @example
17
+ * const events = sdk.realtime.viaSse();
18
+ * const session = await sdk.operator.sessions.create({ title: 'demo' });
19
+ * const sessionId = session.session.id;
20
+ *
21
+ * // Before forSession — repeated manual guard:
22
+ * events.turn.onEnvelope('STREAM_DELTA', (e) => {
23
+ * if (e.sessionId !== sessionId) return;
24
+ * process.stdout.write(e.payload.content);
25
+ * });
26
+ *
27
+ * // After forSession — clean, session-scoped subscription:
28
+ * const sessionEvents = forSessionRuntime(events, sessionId);
29
+ * sessionEvents.turn.onEnvelope('STREAM_DELTA', (e) => {
30
+ * process.stdout.write(e.payload.content);
31
+ * });
32
+ */
33
+ export { forSession as forSessionRuntime } from './domain-events.js';
34
+ /** Default max reconnect attempts for WebSocket connections (finite to prevent infinite auth loops). */
35
+ export const DEFAULT_WS_MAX_ATTEMPTS = 10;
36
+ /** Maximum number of messages that may be queued in the outbound queue before the oldest entry is dropped. */
37
+ const MAX_OUTBOUND_QUEUE = 1024;
38
+ function getSocketOpenState(WebSocketImpl) {
39
+ return typeof WebSocketImpl.OPEN === 'number' ? WebSocketImpl.OPEN : 1;
40
+ }
41
+ function isSocketOpen(socket, WebSocketImpl) {
42
+ return socket.readyState === getSocketOpenState(WebSocketImpl);
43
+ }
44
+ export class WebSocketTransportError extends GoodVibesSdkError {
45
+ constructor(message, options = {}) {
46
+ super(message, {
47
+ code: 'WEBSOCKET_TRANSPORT_ERROR',
48
+ category: 'network',
49
+ source: 'transport',
50
+ recoverable: true,
51
+ hint: options.hint,
52
+ cause: options.cause,
53
+ });
54
+ this.name = 'WebSocketTransportError';
9
55
  }
10
- return source ?? null;
11
56
  }
12
- export function createRemoteRuntimeEvents(connect) {
13
- return createRemoteDomainEvents(RUNTIME_EVENT_DOMAINS, connect);
57
+ export function createRemoteRuntimeEvents(connect, options = {}) {
58
+ const domainOptions = {
59
+ onConnectionError: (error, domain) => {
60
+ invokeTransportObserver(() => options.observer?.onError?.(error));
61
+ options.onError?.(error, domain);
62
+ },
63
+ };
64
+ return createRemoteDomainEvents(RUNTIME_EVENT_DOMAINS, connect, domainOptions);
14
65
  }
15
66
  export function buildEventSourceUrl(baseUrl, domain) {
16
67
  const url = new URL(buildUrl(baseUrl, '/api/control-plane/events'));
@@ -18,43 +69,144 @@ export function buildEventSourceUrl(baseUrl, domain) {
18
69
  return url.toString();
19
70
  }
20
71
  export function buildWebSocketUrl(baseUrl, domains) {
21
- const base = normalizeBaseUrl(baseUrl);
22
- const url = new URL('/api/control-plane/ws', base.replace(/^http(s?):\/\//, 'ws$1://'));
72
+ const url = new URL('/api/control-plane/ws', normalizeBaseUrl(baseUrl));
73
+ if (url.protocol === 'http:') {
74
+ url.protocol = 'ws:';
75
+ }
76
+ else if (url.protocol === 'https:') {
77
+ url.protocol = 'wss:';
78
+ }
79
+ else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
80
+ throw new ConfigurationError(`Unsupported WebSocket base URL protocol: ${url.protocol}`, {
81
+ source: 'transport',
82
+ hint: 'Runtime event WebSocket clients require http, https, ws, or wss base URLs.',
83
+ });
84
+ }
85
+ assertWebSocketAuthTransportIsSafe(url);
23
86
  url.searchParams.set('clientKind', 'web');
24
87
  if (domains.length > 0) {
25
88
  url.searchParams.set('domains', domains.join(','));
26
89
  }
27
90
  return url.toString();
28
91
  }
92
+ function assertWebSocketAuthTransportIsSafe(url) {
93
+ if (url.protocol !== 'ws:')
94
+ return;
95
+ const host = url.hostname.toLowerCase();
96
+ const isLoopback = host === 'localhost'
97
+ || host === '127.0.0.1'
98
+ || host === '::1'
99
+ || host.startsWith('127.');
100
+ if (!isLoopback) {
101
+ throw new ConfigurationError('Refusing to send GoodVibes WebSocket authentication over insecure ws:// transport. Use https:// or wss://.', {
102
+ source: 'transport',
103
+ hint: 'Use https:// or wss:// for non-loopback WebSocket runtime event connections.',
104
+ });
105
+ }
106
+ }
29
107
  export function createEventSourceConnector(baseUrl, token, fetchImpl, options = {}) {
30
- const handleError = options.onError ?? (options.reconnect?.enabled ? (() => { }) : undefined);
108
+ const { observer } = options;
109
+ const handleError = options.onError;
31
110
  return async (domain, onEnvelope) => {
32
111
  const url = buildEventSourceUrl(baseUrl, domain);
33
- return await openServerSentEventStream(fetchImpl, url, {
34
- onEvent: (eventName, payload) => {
35
- if (eventName !== domain)
36
- return;
37
- if (!payload || typeof payload !== 'object')
38
- return;
39
- onEnvelope(payload);
40
- },
41
- onError: handleError,
42
- }, {
43
- reconnect: options.reconnect,
44
- getAuthToken: typeof token === 'function' ? token : undefined,
45
- authToken: typeof token === 'function' ? null : token,
46
- });
112
+ const getAuthToken = normalizeAuthToken(token ?? undefined);
113
+ // Inject W3C traceparent if OTel is active (async probe for SSE cold-start).
114
+ const sseHeaders = {};
115
+ await injectTraceparentAsync(sseHeaders);
116
+ // Notify observer of outbound SSE connection attempt.
117
+ invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'sse' }));
118
+ try {
119
+ return await openServerSentEventStream(fetchImpl, url, {
120
+ onEvent: (eventName, payload) => {
121
+ if (eventName !== domain)
122
+ return;
123
+ if (!payload || typeof payload !== 'object')
124
+ return;
125
+ const envelope = payload;
126
+ onEnvelope(envelope);
127
+ // Notify observer of inbound event.
128
+ invokeTransportObserver(() => {
129
+ observer?.onTransportActivity?.({ direction: 'recv', url, kind: 'sse' });
130
+ if (envelope.payload) {
131
+ observer?.onEvent?.(envelope.payload);
132
+ }
133
+ });
134
+ },
135
+ onError: (err) => {
136
+ const streamError = transportErrorFromUnknown(err, 'SSE runtime event stream error');
137
+ invokeTransportObserver(() => observer?.onError?.(streamError));
138
+ handleError?.(streamError);
139
+ },
140
+ }, {
141
+ reconnect: options.reconnect,
142
+ getAuthToken,
143
+ headers: Object.keys(sseHeaders).length > 0 ? sseHeaders : undefined,
144
+ });
145
+ }
146
+ catch (error) {
147
+ const connectionError = transportErrorFromUnknown(error, 'SSE runtime event connection failed');
148
+ invokeTransportObserver(() => observer?.onError?.(connectionError));
149
+ handleError?.(connectionError);
150
+ throw connectionError;
151
+ }
47
152
  };
48
153
  }
49
154
  export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options = {}) {
155
+ const { observer } = options;
50
156
  return async (domain, onEnvelope) => {
51
157
  const url = buildWebSocketUrl(baseUrl, [domain]);
52
158
  const reconnect = options.reconnect;
53
159
  const enabled = reconnect?.enabled ?? false;
160
+ // Normalize reconnect policy, defaulting maxAttempts to a finite value to prevent
161
+ // infinite auth-failure loops. Callers can opt-in to a higher limit explicitly.
162
+ const reconnectPolicy = normalizeStreamReconnectPolicy({
163
+ ...reconnect,
164
+ maxAttempts: reconnect?.maxAttempts ?? DEFAULT_WS_MAX_ATTEMPTS,
165
+ });
54
166
  let stopped = false;
55
167
  let reconnectAttempt = 0;
168
+ let hasReceivedMessage = false;
56
169
  let socket = null;
57
170
  let reconnectTimer = null;
171
+ // Bounded outbound message queue — max MAX_OUTBOUND_QUEUE entries, drop-oldest policy.
172
+ // Messages pushed while the socket is not yet open or is reconnecting are buffered here
173
+ // and flushed on the next successful open event.
174
+ const outboundQueue = [];
175
+ let droppedOutboundCount = 0;
176
+ let queueOverflowNotified = false;
177
+ /**
178
+ * Enqueue a message for delivery over this WebSocket connection.
179
+ *
180
+ * If the socket is currently open the message is sent immediately.
181
+ * If the socket is not yet open (or is reconnecting), the message is
182
+ * buffered and will be flushed once the connection is re-established.
183
+ *
184
+ * When the buffer is full (> MAX_OUTBOUND_QUEUE), the oldest pending
185
+ * message is silently dropped and a counter is incremented. Callers that
186
+ * need back-pressure should check `socket?.readyState` before calling.
187
+ *
188
+ * @param data - Serialised message string to send.
189
+ */
190
+ const emitLocal = (data) => {
191
+ if (socket && isSocketOpen(socket, WebSocketImpl)) {
192
+ socket.send(data);
193
+ return;
194
+ }
195
+ if (outboundQueue.length >= MAX_OUTBOUND_QUEUE) {
196
+ // Drop oldest message to make room (drop-oldest policy).
197
+ outboundQueue.shift();
198
+ droppedOutboundCount += 1;
199
+ if (!queueOverflowNotified) {
200
+ queueOverflowNotified = true;
201
+ options.onError?.(new WebSocketTransportError(`WebSocket outbound queue full (limit ${MAX_OUTBOUND_QUEUE}). Dropping oldest messages until the socket reconnects.`, {
202
+ hint: 'Wait for the runtime event WebSocket to reconnect before sending more local frames.',
203
+ }));
204
+ }
205
+ }
206
+ outboundQueue.push(data);
207
+ };
208
+ // Notify caller of the emitter handle for tests and local frame injection.
209
+ options.onEmitter?.(emitLocal);
58
210
  const closeSocket = () => {
59
211
  if (!socket)
60
212
  return;
@@ -69,47 +221,109 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
69
221
  if (!enabled || stopped)
70
222
  return;
71
223
  const nextAttempt = reconnectAttempt + 1;
72
- const maxAttempts = reconnect?.maxAttempts ?? Number.POSITIVE_INFINITY;
73
- if (nextAttempt >= maxAttempts)
224
+ if (nextAttempt > reconnectPolicy.maxAttempts)
74
225
  return;
75
226
  reconnectAttempt = nextAttempt;
76
- const baseDelayMs = reconnect?.baseDelayMs ?? 500;
77
- const maxDelayMs = reconnect?.maxDelayMs ?? 5_000;
78
- const backoffFactor = reconnect?.backoffFactor ?? 2;
79
- const delayMs = Math.min(maxDelayMs, Math.floor(baseDelayMs * (backoffFactor ** Math.max(0, nextAttempt - 1))));
227
+ // Use shared backoff helper so WS and SSE are on identical schedule.
228
+ const delayMs = getStreamReconnectDelay(nextAttempt, reconnectPolicy);
80
229
  reconnectTimer = setTimeout(() => {
81
230
  reconnectTimer = null;
82
- void connect();
231
+ void connect().catch((error) => {
232
+ const connectionError = transportErrorFromUnknown(error, 'WebSocket runtime event reconnect failed');
233
+ invokeTransportObserver(() => observer?.onError?.(connectionError));
234
+ options.onError?.(connectionError);
235
+ scheduleReconnect();
236
+ });
83
237
  }, delayMs);
238
+ reconnectTimer.unref?.();
84
239
  };
85
- const onOpen = async () => {
86
- reconnectAttempt = 0;
87
- const authToken = await resolveAuthTokenSource(token);
88
- if (!authToken || !socket)
89
- return;
90
- socket.send(JSON.stringify({
91
- type: 'auth',
92
- token: authToken,
93
- domains: [domain],
94
- }));
240
+ const flushOutboundQueue = (ws) => {
241
+ while (outboundQueue.length > 0) {
242
+ const data = outboundQueue.shift();
243
+ if (data === undefined)
244
+ break;
245
+ ws.send(data);
246
+ }
247
+ };
248
+ const onOpen = async (event) => {
249
+ const candidateSocket = event.currentTarget;
250
+ const openedSocket = candidateSocket && typeof candidateSocket.send === 'function'
251
+ ? candidateSocket
252
+ : socket;
253
+ try {
254
+ const authToken = (await normalizeAuthToken(token ?? undefined)()) ?? null;
255
+ if (!authToken || !openedSocket || stopped || socket !== openedSocket)
256
+ return;
257
+ // Notify observer of outbound WS connection.
258
+ invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'ws' }));
259
+ // Send auth frame first, then drain any messages buffered during resolution.
260
+ // Inject traceparent into the auth frame for W3C Trace Context propagation over WebSocket.
261
+ const wsTraceHeaders = {};
262
+ await injectTraceparentAsync(wsTraceHeaders);
263
+ if (stopped || socket !== openedSocket)
264
+ return;
265
+ openedSocket.send(JSON.stringify({
266
+ type: 'auth',
267
+ token: authToken,
268
+ domains: [domain],
269
+ ...(wsTraceHeaders['traceparent'] ? { traceparent: wsTraceHeaders['traceparent'] } : {}),
270
+ ...(wsTraceHeaders['tracestate'] ? { tracestate: wsTraceHeaders['tracestate'] } : {}),
271
+ }));
272
+ flushOutboundQueue(openedSocket);
273
+ }
274
+ catch (error) {
275
+ const sendError = transportErrorFromUnknown(error, 'WebSocket send failed');
276
+ invokeTransportObserver(() => observer?.onError?.(sendError));
277
+ options.onError?.(sendError);
278
+ closeSocket();
279
+ scheduleReconnect();
280
+ }
95
281
  };
96
282
  const onMessage = (event) => {
97
283
  try {
98
284
  const frame = JSON.parse(event.data);
285
+ if (!hasReceivedMessage) {
286
+ hasReceivedMessage = true;
287
+ reconnectAttempt = 0;
288
+ }
289
+ queueOverflowNotified = false;
99
290
  if (frame.type === 'event' && frame.event === domain && frame.payload && typeof frame.payload === 'object') {
100
- onEnvelope(frame.payload);
291
+ const wsPayload = frame.payload;
292
+ onEnvelope(wsPayload);
293
+ // Notify observer of inbound WS event.
294
+ invokeTransportObserver(() => {
295
+ observer?.onTransportActivity?.({ direction: 'recv', url, kind: 'ws' });
296
+ if (wsPayload.payload) {
297
+ observer?.onEvent?.(wsPayload.payload);
298
+ }
299
+ });
101
300
  }
102
301
  }
103
- catch {
104
- // Ignore malformed frames.
302
+ catch (error) {
303
+ const malformed = new GoodVibesSdkError(`Malformed WebSocket runtime event frame: ${transportErrorFromUnknown(error, 'parse error').message}`, {
304
+ category: 'protocol',
305
+ source: 'transport',
306
+ recoverable: true,
307
+ cause: error,
308
+ });
309
+ invokeTransportObserver(() => observer?.onError?.(malformed));
310
+ options.onError?.(malformed);
105
311
  }
106
312
  };
107
- const onClose = () => {
313
+ const onClose = (event) => {
314
+ hasReceivedMessage = false;
315
+ if (!stopped && event.code !== 1000 && event.code !== 1005) {
316
+ const closeError = webSocketCloseError(event);
317
+ invokeTransportObserver(() => observer?.onError?.(closeError));
318
+ options.onError?.(closeError);
319
+ }
108
320
  closeSocket();
109
321
  scheduleReconnect();
110
322
  };
111
323
  const onError = (event) => {
112
- options.onError?.(event);
324
+ const streamError = webSocketEventError(event);
325
+ invokeTransportObserver(() => observer?.onError?.(streamError));
326
+ options.onError?.(streamError);
113
327
  };
114
328
  const connect = async () => {
115
329
  if (stopped)
@@ -122,7 +336,15 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
122
336
  nextSocket.addEventListener('close', onClose);
123
337
  nextSocket.addEventListener('error', onError);
124
338
  };
125
- await connect();
339
+ try {
340
+ await connect();
341
+ }
342
+ catch (error) {
343
+ const connectionError = transportErrorFromUnknown(error, 'WebSocket runtime event connection failed');
344
+ invokeTransportObserver(() => observer?.onError?.(connectionError));
345
+ options.onError?.(connectionError);
346
+ throw connectionError;
347
+ }
126
348
  return () => {
127
349
  stopped = true;
128
350
  if (reconnectTimer) {
@@ -132,3 +354,28 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
132
354
  };
133
355
  };
134
356
  }
357
+ function webSocketCloseError(event) {
358
+ const reason = typeof event.reason === 'string' ? event.reason.trim() : '';
359
+ const code = typeof event.code === 'number' ? event.code : 1005;
360
+ const wasClean = typeof event.wasClean === 'boolean' ? event.wasClean : false;
361
+ const detail = [
362
+ `code=${code}`,
363
+ reason ? `reason=${reason}` : undefined,
364
+ `wasClean=${wasClean}`,
365
+ ].filter(Boolean).join(' ');
366
+ return new WebSocketTransportError(`WebSocket runtime event stream closed unexpectedly: ${detail}`, {
367
+ cause: event,
368
+ });
369
+ }
370
+ function webSocketEventError(event) {
371
+ const candidate = event;
372
+ if (candidate.error) {
373
+ return transportErrorFromUnknown(candidate.error, 'WebSocket runtime event stream error');
374
+ }
375
+ const eventMessage = typeof candidate.message === 'string' && candidate.message.trim().length > 0
376
+ ? candidate.message.trim()
377
+ : undefined;
378
+ return new WebSocketTransportError(eventMessage
379
+ ? `WebSocket runtime event stream error: ${eventMessage}`
380
+ : `WebSocket runtime event stream error: ${describeUnknownTransportError(event)}`, { cause: event });
381
+ }
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-transport-realtime",
3
- "version": "0.18.3",
3
+ "version": "0.30.0",
4
+ "engines": {
5
+ "node": ">=20.0.0"
6
+ },
4
7
  "description": "Realtime event-domain connectors for GoodVibes SSE and WebSocket integrations.",
5
8
  "type": "module",
6
9
  "main": "./dist/index.js",
@@ -10,6 +13,14 @@
10
13
  "types": "./dist/index.d.ts",
11
14
  "import": "./dist/index.js"
12
15
  },
16
+ "./domain-events": {
17
+ "types": "./dist/domain-events.d.ts",
18
+ "import": "./dist/domain-events.js"
19
+ },
20
+ "./runtime-events": {
21
+ "types": "./dist/runtime-events.d.ts",
22
+ "import": "./dist/runtime-events.js"
23
+ },
13
24
  "./package.json": "./package.json"
14
25
  },
15
26
  "files": [
@@ -32,12 +43,13 @@
32
43
  "websocket",
33
44
  "events"
34
45
  ],
46
+ "dependencies": {
47
+ "@pellux/goodvibes-contracts": "0.30.0",
48
+ "@pellux/goodvibes-errors": "0.30.0",
49
+ "@pellux/goodvibes-transport-core": "0.30.0",
50
+ "@pellux/goodvibes-transport-http": "0.30.0"
51
+ },
35
52
  "publishConfig": {
36
53
  "access": "public"
37
- },
38
- "dependencies": {
39
- "@pellux/goodvibes-contracts": "0.18.3",
40
- "@pellux/goodvibes-transport-core": "0.18.3",
41
- "@pellux/goodvibes-transport-http": "0.18.3"
42
54
  }
43
55
  }