@pellux/goodvibes-transport-realtime 0.30.2 → 0.33.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,8 +1,8 @@
1
1
  # @pellux/goodvibes-transport-realtime
2
2
 
3
- Internal workspace package backing `@pellux/goodvibes-sdk/transport-realtime`.
3
+ Public GoodVibes realtime transport package for event-domain connectors over SSE and WebSocket.
4
4
 
5
- Consumers should install `@pellux/goodvibes-sdk` and import this surface from the umbrella package.
5
+ Most applications should install `@pellux/goodvibes-sdk` and import `@pellux/goodvibes-sdk/transport-realtime`. Install this package directly when you only need realtime connectors.
6
6
 
7
7
  Consumer import:
8
8
 
@@ -17,4 +17,4 @@ const events = createRemoteRuntimeEvents(
17
17
  );
18
18
  ```
19
19
 
20
- Use this surface 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 main SDK.
@@ -1,20 +1,16 @@
1
- import { type RuntimeEventFeeds } from '@pellux/goodvibes-transport-core';
2
- type EventLike = {
3
- readonly type: string;
4
- };
1
+ import { type EventLike, type RuntimeEventFeeds } from '@pellux/goodvibes-transport-core';
5
2
  export interface SerializedEventEnvelope<TEvent extends EventLike = EventLike> {
6
3
  readonly type: string;
7
- readonly timestamp?: number;
8
- readonly ts?: number;
9
- readonly traceId?: string;
10
- readonly sessionId?: string;
11
- readonly source?: string;
4
+ readonly ts?: number | undefined;
5
+ readonly traceId?: string | undefined;
6
+ readonly sessionId?: string | undefined;
7
+ readonly source?: string | undefined;
12
8
  readonly payload: TEvent;
13
9
  }
14
10
  export type DomainEventConnector<TDomain extends string, TEvent extends EventLike = EventLike> = (domain: TDomain, onEnvelope: (envelope: SerializedEventEnvelope<TEvent>) => void) => void | Promise<() => void>;
15
11
  export type DomainEvents<TDomain extends string, TEvent extends EventLike = EventLike> = RuntimeEventFeeds<TDomain, TEvent>;
16
12
  export interface RemoteDomainEventsOptions<TDomain extends string = string> {
17
- readonly onConnectionError?: (error: Error, domain: TDomain) => void;
13
+ readonly onConnectionError?: ((error: Error, domain: TDomain) => void) | undefined;
18
14
  }
19
15
  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
16
  /**
@@ -43,5 +39,4 @@ export declare function createRemoteDomainEvents<TDomain extends string, TEvent
43
39
  * });
44
40
  */
45
41
  export declare function forSession<TDomain extends string, TEvent extends EventLike = EventLike>(events: DomainEvents<TDomain, TEvent>, sessionId: string): DomainEvents<TDomain, TEvent>;
46
- export {};
47
42
  //# sourceMappingURL=domain-events.d.ts.map
@@ -1 +1 @@
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
+ {"version":3,"file":"domain-events.d.ts","sourceRoot":"","sources":["../src/domain-events.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,SAAS,EAEd,KAAK,iBAAiB,EACvB,MAAM,kCAAkC,CAAC;AAE1C,MAAM,WAAW,uBAAuB,CAAC,MAAM,SAAS,SAAS,GAAG,SAAS;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,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,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACpF;AAmJD,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;AA4ED;;;;;;;;;;;;;;;;;;;;;;;;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"}
@@ -32,14 +32,10 @@ function reportUnexpectedConnectionError(error, domain, options) {
32
32
  function toEventEnvelope(envelope) {
33
33
  return {
34
34
  type: envelope.type,
35
- ts: typeof envelope.ts === 'number'
36
- ? envelope.ts
37
- : typeof envelope.timestamp === 'number'
38
- ? envelope.timestamp
39
- : Date.now(),
40
- traceId: typeof envelope.traceId === 'string' ? envelope.traceId : 'transport-trace',
41
- sessionId: typeof envelope.sessionId === 'string' ? envelope.sessionId : 'transport',
42
- source: typeof envelope.source === 'string' ? envelope.source : 'transport',
35
+ ts: typeof envelope.ts === 'number' ? envelope.ts : Date.now(),
36
+ traceId: typeof envelope.traceId === 'string' ? envelope.traceId : undefined,
37
+ sessionId: typeof envelope.sessionId === 'string' ? envelope.sessionId : undefined,
38
+ source: typeof envelope.source === 'string' ? envelope.source : undefined,
43
39
  payload: envelope.payload,
44
40
  };
45
41
  }
@@ -60,15 +56,24 @@ function createRemoteDomainEventFeed(domain, connect, options) {
60
56
  return;
61
57
  const payload = envelope.payload;
62
58
  const typedEnvelope = toEventEnvelope(envelope);
63
- for (const listener of payloadListeners.get(eventType) ?? []) {
59
+ // snapshot Sets before iterating to prevent skipped listeners
60
+ // from concurrent subscribe/unsubscribe during dispatch (project pattern: event-bus-snapshot).
61
+ for (const listener of [...(payloadListeners.get(eventType) ?? [])]) {
64
62
  listener(payload);
65
63
  }
66
- for (const listener of envelopeListeners.get(eventType) ?? []) {
64
+ for (const listener of [...(envelopeListeners.get(eventType) ?? [])]) {
67
65
  listener(typedEnvelope);
68
66
  }
69
67
  })).then((cleanup) => {
70
- if (typeof cleanup !== 'function')
68
+ // when connect() returns a non-function cleanup (void), we have no
69
+ // way to honour a pending disconnect request. Log the gap and bail. Callers
70
+ // must return a cleanup function whenever they establish a real connection.
71
+ if (typeof cleanup !== 'function') {
72
+ if (disconnectPending) {
73
+ reportUnexpectedConnectionError(new Error('Domain event connector resolved without a cleanup function while a disconnect was pending. The connection cannot be closed.'), domain, options);
74
+ }
71
75
  return;
76
+ }
72
77
  if (disconnectPending && !hasListeners()) {
73
78
  cleanup();
74
79
  return;
@@ -123,22 +128,59 @@ export function createRemoteDomainEvents(domains, connect, options = {}) {
123
128
  *
124
129
  * Unsubscribe handles returned by `on` / `onEnvelope` on the filtered feed
125
130
  * correctly remove the underlying listener from the original feed.
131
+ *
132
+ * Uses a single shared envelope listener per event type so that N
133
+ * subscribers for the same (feed, sessionId, type) triple consume only one
134
+ * envelope-listener slot on the underlying feed instead of N.
126
135
  */
127
136
  function createFilteredFeed(feed, sessionId) {
137
+ // Shared listeners: one envelope-level subscription per event type.
138
+ const sharedByType = new Map();
139
+ function getOrCreateShared(type) {
140
+ const existing = sharedByType.get(type);
141
+ if (existing)
142
+ return existing;
143
+ const payloadListeners = new Set();
144
+ const envelopeListeners = new Set();
145
+ const unsub = feed.onEnvelope(type, (envelope) => {
146
+ if (envelope.sessionId !== sessionId)
147
+ return;
148
+ for (const pl of payloadListeners)
149
+ pl(envelope.payload);
150
+ for (const el of envelopeListeners)
151
+ el(envelope);
152
+ });
153
+ const shared = { unsub, payloadListeners, envelopeListeners };
154
+ sharedByType.set(type, shared);
155
+ return shared;
156
+ }
157
+ function removeSharedIfEmpty(type) {
158
+ const shared = sharedByType.get(type);
159
+ if (!shared)
160
+ return;
161
+ if (shared.payloadListeners.size === 0 && shared.envelopeListeners.size === 0) {
162
+ shared.unsub();
163
+ sharedByType.delete(type);
164
+ }
165
+ }
128
166
  return {
129
167
  on(type, listener) {
130
- return feed.onEnvelope(type, (envelope) => {
131
- if (envelope.sessionId !== sessionId)
132
- return;
133
- listener(envelope.payload);
134
- });
168
+ const shared = getOrCreateShared(type);
169
+ const typedListener = listener;
170
+ shared.payloadListeners.add(typedListener);
171
+ return () => {
172
+ shared.payloadListeners.delete(typedListener);
173
+ removeSharedIfEmpty(type);
174
+ };
135
175
  },
136
176
  onEnvelope(type, listener) {
137
- return feed.onEnvelope(type, (envelope) => {
138
- if (envelope.sessionId !== sessionId)
139
- return;
140
- listener(envelope);
141
- });
177
+ const shared = getOrCreateShared(type);
178
+ const typedListener = listener;
179
+ shared.envelopeListeners.add(typedListener);
180
+ return () => {
181
+ shared.envelopeListeners.delete(typedListener);
182
+ removeSharedIfEmpty(type);
183
+ };
142
184
  },
143
185
  };
144
186
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { DomainEventConnector, DomainEvents, RemoteDomainEventsOptions, SerializedEventEnvelope, } from './domain-events.js';
2
2
  export { createRemoteDomainEvents, forSession } from './domain-events.js';
3
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
- export type { RuntimeEventConnectorOptions } from './runtime-events.js';
4
+ export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, DEFAULT_WS_MAX_ATTEMPTS, forSessionRuntime, WebSocketTransportError, } from './runtime-events.js';
5
+ export type { AuthTokenSource, 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,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"}
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,EACjB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,eAAe,EAAE,4BAA4B,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { createRemoteDomainEvents, forSession } from './domain-events.js';
2
- export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, DEFAULT_WS_MAX_ATTEMPTS, forSessionRuntime, } from './runtime-events.js';
2
+ export { buildEventSourceUrl, buildWebSocketUrl, createEventSourceConnector, createRemoteRuntimeEvents, createWebSocketConnector, DEFAULT_WS_MAX_ATTEMPTS, forSessionRuntime, WebSocketTransportError, } from './runtime-events.js';
@@ -1,16 +1,13 @@
1
- import { type RuntimeEventDomain } from '@pellux/goodvibes-contracts';
1
+ import { type RuntimeEventDomain, type RuntimeEventRecord } from '@pellux/goodvibes-contracts';
2
2
  import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
3
3
  import { type AuthTokenResolver, type StreamReconnectPolicy } from '@pellux/goodvibes-transport-http';
4
4
  import { type TransportObserver } from '@pellux/goodvibes-transport-core';
5
5
  import { type DomainEventConnector, type DomainEvents, type SerializedEventEnvelope } from './domain-events.js';
6
- type RuntimeEventRecord = {
7
- readonly type: string;
8
- };
9
6
  export type SerializedRuntimeEnvelope<TEvent extends RuntimeEventRecord = RuntimeEventRecord> = SerializedEventEnvelope<TEvent>;
10
7
  export type RemoteRuntimeEvents<TEvent extends RuntimeEventRecord = RuntimeEventRecord> = DomainEvents<RuntimeEventDomain, TEvent>;
11
8
  export interface RemoteRuntimeEventsOptions {
12
- readonly onError?: (error: Error, domain: RuntimeEventDomain) => void;
13
- readonly observer?: TransportObserver;
9
+ readonly onError?: ((error: Error, domain: RuntimeEventDomain) => void) | undefined;
10
+ readonly observer?: TransportObserver | undefined;
14
11
  }
15
12
  /**
16
13
  * Returns a filtered view of a {@link RemoteRuntimeEvents} object where every
@@ -40,23 +37,41 @@ export interface RemoteRuntimeEventsOptions {
40
37
  */
41
38
  export { forSession as forSessionRuntime } from './domain-events.js';
42
39
  export interface RuntimeEventConnectorOptions {
43
- readonly reconnect?: StreamReconnectPolicy;
44
- readonly onError?: (error: unknown) => void;
45
- readonly observer?: TransportObserver;
40
+ readonly reconnect?: StreamReconnectPolicy | undefined;
41
+ readonly onError?: ((error: unknown) => void) | undefined;
42
+ readonly onOpen?: (() => void) | undefined;
43
+ readonly onReconnect?: ((attempt: number, delayMs: number) => void) | undefined;
44
+ readonly observer?: TransportObserver | undefined;
46
45
  /**
47
46
  * Called once the WebSocket connector is set up, providing an `emitLocal`
48
47
  * function the caller can use to send messages over this connection.
49
48
  * Primarily for tests and local harnesses that need to inject outbound frames.
50
49
  */
51
- readonly onEmitter?: (emitLocal: (data: string) => void) => void;
50
+ readonly onEmitter?: ((emitLocal: (data: string) => void) => void) | undefined;
52
51
  }
53
- type AuthTokenSource = string | null | undefined | AuthTokenResolver;
52
+ export type AuthTokenSource = string | null | undefined | AuthTokenResolver;
54
53
  /** Default max reconnect attempts for WebSocket connections (finite to prevent infinite auth loops). */
55
54
  export declare const DEFAULT_WS_MAX_ATTEMPTS = 10;
56
55
  export declare class WebSocketTransportError extends GoodVibesSdkError {
57
- constructor(message: string, options?: {
56
+ /**
57
+ * WebSocket runtime-event transport error.
58
+ *
59
+ * Canonical internal codes are `WS_CLOSE_ABNORMAL`,
60
+ * `WS_EVENT_ERROR`, `WS_QUEUE_OVERFLOW`, `WS_REMOTE_ERROR`, and
61
+ * `WS_FRAME_TOO_LARGE`.
62
+ *
63
+ * Overrides Symbol.hasInstance to enable cross-realm instanceof checks.
64
+ * Without this, `instanceof WebSocketTransportError` in a different realm
65
+ * (e.g. a Cloudflare Worker or cross-frame context) falls through to the base
66
+ * GoodVibesSdkError brand only and cannot distinguish WS errors from other
67
+ * SDK errors. The brand check here also guards against plain objects with a
68
+ * matching `code`.
69
+ */
70
+ static [Symbol.hasInstance](value: unknown): boolean;
71
+ constructor(message: string, options: {
58
72
  readonly cause?: unknown;
59
73
  readonly hint?: string;
74
+ readonly code: string;
60
75
  });
61
76
  }
62
77
  export declare function createRemoteRuntimeEvents<TEvent extends RuntimeEventRecord = RuntimeEventRecord>(connect: DomainEventConnector<RuntimeEventDomain, TEvent>, options?: RemoteRuntimeEventsOptions): RemoteRuntimeEvents<TEvent>;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-events.d.ts","sourceRoot":"","sources":["../src/runtime-events.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runtime-events.d.ts","sourceRoot":"","sources":["../src/runtime-events.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { RUNTIME_EVENT_DOMAINS } from '@pellux/goodvibes-contracts';
1
+ import { RUNTIME_EVENT_DOMAINS, TypedSerializedEventEnvelopeSchema, } from '@pellux/goodvibes-contracts';
2
2
  import { ConfigurationError, GoodVibesSdkError } from '@pellux/goodvibes-errors';
3
3
  import { normalizeAuthToken, openRawServerSentEventStream as openServerSentEventStream, normalizeStreamReconnectPolicy, getStreamReconnectDelay, } from '@pellux/goodvibes-transport-http';
4
4
  import { buildUrl, normalizeBaseUrl } from '@pellux/goodvibes-transport-http';
@@ -35,6 +35,13 @@ export { forSession as forSessionRuntime } from './domain-events.js';
35
35
  export const DEFAULT_WS_MAX_ATTEMPTS = 10;
36
36
  /** Maximum number of messages that may be queued in the outbound queue before the oldest entry is dropped. */
37
37
  const MAX_OUTBOUND_QUEUE = 1024;
38
+ /** Maximum size for a single queued outbound WebSocket message. */
39
+ const MAX_OUTBOUND_MESSAGE_BYTES = 1024 * 1024;
40
+ /** Maximum total queued outbound WebSocket payload bytes. */
41
+ const MAX_OUTBOUND_QUEUE_BYTES = 16 * 1024 * 1024;
42
+ /** Maximum size accepted for one inbound WebSocket runtime-event frame. */
43
+ const MAX_INBOUND_FRAME_BYTES = 1024 * 1024;
44
+ const textEncoder = new TextEncoder();
38
45
  function getSocketOpenState(WebSocketImpl) {
39
46
  return typeof WebSocketImpl.OPEN === 'number' ? WebSocketImpl.OPEN : 1;
40
47
  }
@@ -42,9 +49,47 @@ function isSocketOpen(socket, WebSocketImpl) {
42
49
  return socket.readyState === getSocketOpenState(WebSocketImpl);
43
50
  }
44
51
  export class WebSocketTransportError extends GoodVibesSdkError {
45
- constructor(message, options = {}) {
52
+ /**
53
+ * WebSocket runtime-event transport error.
54
+ *
55
+ * Canonical internal codes are `WS_CLOSE_ABNORMAL`,
56
+ * `WS_EVENT_ERROR`, `WS_QUEUE_OVERFLOW`, `WS_REMOTE_ERROR`, and
57
+ * `WS_FRAME_TOO_LARGE`.
58
+ *
59
+ * Overrides Symbol.hasInstance to enable cross-realm instanceof checks.
60
+ * Without this, `instanceof WebSocketTransportError` in a different realm
61
+ * (e.g. a Cloudflare Worker or cross-frame context) falls through to the base
62
+ * GoodVibesSdkError brand only and cannot distinguish WS errors from other
63
+ * SDK errors. The brand check here also guards against plain objects with a
64
+ * matching `code`.
65
+ */
66
+ static [Symbol.hasInstance](value) {
67
+ if (this !== WebSocketTransportError) {
68
+ return typeof value === 'object'
69
+ && value !== null
70
+ && this.prototype.isPrototypeOf(value);
71
+ }
72
+ // Require the base SDK brand AND a WS-specific code prefix to prevent plain
73
+ // objects like { code: 'WEBSOCKET_TRANSPORT_ERROR' } from passing.
74
+ // Use an explicit allowlist of canonical WS codes rather than
75
+ // the open-ended 'WS_' prefix check which would match any hand-crafted
76
+ // GoodVibesSdkError with a WS_* code.
77
+ const CANONICAL_WS_CODES = new Set([
78
+ 'WEBSOCKET_TRANSPORT_ERROR',
79
+ 'WS_CLOSE_ABNORMAL',
80
+ 'WS_EVENT_ERROR',
81
+ 'WS_QUEUE_OVERFLOW',
82
+ 'WS_REMOTE_ERROR',
83
+ 'WS_FRAME_TOO_LARGE',
84
+ ]);
85
+ return GoodVibesSdkError[Symbol.hasInstance](value)
86
+ && typeof value.code === 'string'
87
+ && CANONICAL_WS_CODES.has(String(value.code));
88
+ }
89
+ // Use one of: WS_CLOSE_ABNORMAL, WS_EVENT_ERROR, WS_QUEUE_OVERFLOW, WS_REMOTE_ERROR, WS_FRAME_TOO_LARGE.
90
+ constructor(message, options) {
46
91
  super(message, {
47
- code: 'WEBSOCKET_TRANSPORT_ERROR',
92
+ code: options.code,
48
93
  category: 'network',
49
94
  source: 'transport',
50
95
  recoverable: true,
@@ -57,7 +102,7 @@ export class WebSocketTransportError extends GoodVibesSdkError {
57
102
  export function createRemoteRuntimeEvents(connect, options = {}) {
58
103
  const domainOptions = {
59
104
  onConnectionError: (error, domain) => {
60
- invokeTransportObserver(() => options.observer?.onError?.(error));
105
+ invokeTransportObserver(() => options.observer?.onError?.(error), options.observer?.onObserverError);
61
106
  options.onError?.(error, domain);
62
107
  },
63
108
  };
@@ -69,6 +114,9 @@ export function buildEventSourceUrl(baseUrl, domain) {
69
114
  return url.toString();
70
115
  }
71
116
  export function buildWebSocketUrl(baseUrl, domains) {
117
+ // protocol validation and error messaging here mirrors normalizeBaseUrl in
118
+ // transport-http/src/paths.ts. These two code paths enforce the same rule with
119
+ // different error messages. If the supported protocol set ever changes, update both.
72
120
  const url = new URL('/api/control-plane/ws', normalizeBaseUrl(baseUrl));
73
121
  if (url.protocol === 'http:') {
74
122
  url.protocol = 'ws:';
@@ -114,7 +162,7 @@ export function createEventSourceConnector(baseUrl, token, fetchImpl, options =
114
162
  const sseHeaders = {};
115
163
  await injectTraceparentAsync(sseHeaders);
116
164
  // Notify observer of outbound SSE connection attempt.
117
- invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'sse' }));
165
+ invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'sse' }), observer?.onObserverError);
118
166
  try {
119
167
  return await openServerSentEventStream(fetchImpl, url, {
120
168
  onEvent: (eventName, payload) => {
@@ -130,11 +178,11 @@ export function createEventSourceConnector(baseUrl, token, fetchImpl, options =
130
178
  if (envelope.payload) {
131
179
  observer?.onEvent?.(envelope.payload);
132
180
  }
133
- });
181
+ }, observer?.onObserverError);
134
182
  },
135
183
  onError: (err) => {
136
184
  const streamError = transportErrorFromUnknown(err, 'SSE runtime event stream error');
137
- invokeTransportObserver(() => observer?.onError?.(streamError));
185
+ invokeTransportObserver(() => observer?.onError?.(streamError), observer?.onObserverError);
138
186
  handleError?.(streamError);
139
187
  },
140
188
  }, {
@@ -145,7 +193,7 @@ export function createEventSourceConnector(baseUrl, token, fetchImpl, options =
145
193
  }
146
194
  catch (error) {
147
195
  const connectionError = transportErrorFromUnknown(error, 'SSE runtime event connection failed');
148
- invokeTransportObserver(() => observer?.onError?.(connectionError));
196
+ invokeTransportObserver(() => observer?.onError?.(connectionError), observer?.onObserverError);
149
197
  handleError?.(connectionError);
150
198
  throw connectionError;
151
199
  }
@@ -168,12 +216,18 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
168
216
  let hasReceivedMessage = false;
169
217
  let socket = null;
170
218
  let reconnectTimer = null;
171
- // Bounded outbound message queue — max MAX_OUTBOUND_QUEUE entries, drop-oldest policy.
219
+ // Bounded outbound message queue — max entries and total bytes, drop-oldest policy.
172
220
  // Messages pushed while the socket is not yet open or is reconnecting are buffered here
173
221
  // and flushed on the next successful open event.
174
222
  const outboundQueue = [];
223
+ let outboundQueueBytes = 0;
175
224
  let droppedOutboundCount = 0;
225
+ // track overflow notification count so we fire on every overflow burst
226
+ // rather than once per connection lifetime. queueOverflowNotified is reset in
227
+ // flushOutboundQueue when the connection restores.
176
228
  let queueOverflowNotified = false;
229
+ let overflowEventCount = 0;
230
+ const getAuthToken = normalizeAuthToken(token ?? undefined);
177
231
  /**
178
232
  * Enqueue a message for delivery over this WebSocket connection.
179
233
  *
@@ -181,9 +235,11 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
181
235
  * If the socket is not yet open (or is reconnecting), the message is
182
236
  * buffered and will be flushed once the connection is re-established.
183
237
  *
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.
238
+ * When the buffer is full by count or byte budget, the oldest pending
239
+ * message is dropped and a counter is incremented. A single message larger
240
+ * than MAX_OUTBOUND_MESSAGE_BYTES is rejected instead of being queued.
241
+ * Callers that need back-pressure should check `socket?.readyState` before
242
+ * calling.
187
243
  *
188
244
  * @param data - Serialised message string to send.
189
245
  */
@@ -192,24 +248,52 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
192
248
  socket.send(data);
193
249
  return;
194
250
  }
195
- if (outboundQueue.length >= MAX_OUTBOUND_QUEUE) {
196
- // Drop oldest message to make room (drop-oldest policy).
197
- outboundQueue.shift();
251
+ const sizeBytes = textEncoder.encode(data).byteLength;
252
+ if (sizeBytes > MAX_OUTBOUND_MESSAGE_BYTES) {
253
+ droppedOutboundCount += 1;
254
+ overflowEventCount += 1;
255
+ // fire on first overflow and every 10th thereafter so bursts between
256
+ // reconnects stay observable after the first.
257
+ if (overflowEventCount === 1 || overflowEventCount % 10 === 0) {
258
+ queueOverflowNotified = true;
259
+ options.onError?.(new WebSocketTransportError(`WebSocket outbound message too large (${sizeBytes} bytes, limit ${MAX_OUTBOUND_MESSAGE_BYTES}). Dropping the message while the socket reconnects.`, {
260
+ code: 'WS_QUEUE_OVERFLOW',
261
+ hint: 'Split large runtime event frames before enqueueing them while the WebSocket is disconnected.',
262
+ }));
263
+ }
264
+ return;
265
+ }
266
+ while (outboundQueue.length >= MAX_OUTBOUND_QUEUE
267
+ || outboundQueueBytes + sizeBytes > MAX_OUTBOUND_QUEUE_BYTES) {
268
+ const dropped = outboundQueue.shift();
269
+ if (!dropped)
270
+ break;
271
+ outboundQueueBytes -= dropped.sizeBytes;
198
272
  droppedOutboundCount += 1;
199
- if (!queueOverflowNotified) {
273
+ overflowEventCount += 1;
274
+ // fire on first overflow and every 10th thereafter.
275
+ if (overflowEventCount === 1 || overflowEventCount % 10 === 0) {
200
276
  queueOverflowNotified = true;
201
- options.onError?.(new WebSocketTransportError(`WebSocket outbound queue full (limit ${MAX_OUTBOUND_QUEUE}). Dropping oldest messages until the socket reconnects.`, {
277
+ options.onError?.(new WebSocketTransportError(`WebSocket outbound queue full (limit ${MAX_OUTBOUND_QUEUE} messages / ${MAX_OUTBOUND_QUEUE_BYTES} bytes). Dropping oldest messages until the socket reconnects.`, {
278
+ code: 'WS_QUEUE_OVERFLOW',
202
279
  hint: 'Wait for the runtime event WebSocket to reconnect before sending more local frames.',
203
280
  }));
204
281
  }
205
282
  }
206
- outboundQueue.push(data);
283
+ outboundQueue.push({ data, sizeBytes });
284
+ outboundQueueBytes += sizeBytes;
207
285
  };
208
286
  // Notify caller of the emitter handle for tests and local frame injection.
209
287
  options.onEmitter?.(emitLocal);
210
288
  const closeSocket = () => {
211
289
  if (!socket)
212
290
  return;
291
+ // clear any pending reconnect timer so close() cannot schedule
292
+ // a second reconnect while one is already pending.
293
+ if (reconnectTimer) {
294
+ clearTimeout(reconnectTimer);
295
+ reconnectTimer = null;
296
+ }
213
297
  socket.removeEventListener('open', onOpen);
214
298
  socket.removeEventListener('message', onMessage);
215
299
  socket.removeEventListener('close', onClose);
@@ -226,24 +310,37 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
226
310
  reconnectAttempt = nextAttempt;
227
311
  // Use shared backoff helper so WS and SSE are on identical schedule.
228
312
  const delayMs = getStreamReconnectDelay(nextAttempt, reconnectPolicy);
313
+ options.onReconnect?.(nextAttempt, delayMs);
229
314
  reconnectTimer = setTimeout(() => {
230
315
  reconnectTimer = null;
231
316
  void connect().catch((error) => {
232
317
  const connectionError = transportErrorFromUnknown(error, 'WebSocket runtime event reconnect failed');
233
- invokeTransportObserver(() => observer?.onError?.(connectionError));
318
+ invokeTransportObserver(() => observer?.onError?.(connectionError), observer?.onObserverError);
234
319
  options.onError?.(connectionError);
235
320
  scheduleReconnect();
236
321
  });
237
322
  }, delayMs);
323
+ // Do not keep Node/Bun processes alive solely to wait for a reconnect.
238
324
  reconnectTimer.unref?.();
239
325
  };
240
326
  const flushOutboundQueue = (ws) => {
327
+ // re-check socket open before each send so queued messages are not
328
+ // lost when the socket closes between the auth frame and the drain loop.
329
+ // Messages that cannot be sent are left in the queue for the next reconnect cycle.
241
330
  while (outboundQueue.length > 0) {
242
- const data = outboundQueue.shift();
243
- if (data === undefined)
331
+ if (!isSocketOpen(ws, WebSocketImpl)) {
332
+ // Socket closed mid-drain; leave the remaining items for the next reconnect.
244
333
  break;
245
- ws.send(data);
334
+ }
335
+ const item = outboundQueue.shift();
336
+ if (!item)
337
+ break;
338
+ outboundQueueBytes -= item.sizeBytes;
339
+ ws.send(item.data);
246
340
  }
341
+ // Reset overflow state on successful reconnect so the next burst is reported.
342
+ queueOverflowNotified = false;
343
+ overflowEventCount = 0;
247
344
  };
248
345
  const onOpen = async (event) => {
249
346
  const candidateSocket = event.currentTarget;
@@ -251,11 +348,18 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
251
348
  ? candidateSocket
252
349
  : socket;
253
350
  try {
254
- const authToken = (await normalizeAuthToken(token ?? undefined)()) ?? null;
255
- if (!authToken || !openedSocket || stopped || socket !== openedSocket)
351
+ const authToken = (await getAuthToken()) ?? null;
352
+ // surface a diagnostic error when the token resolver returns null
353
+ // before a reconnect loop is scheduled.
354
+ if (authToken === null || authToken === undefined) {
355
+ options.onError?.(new ConfigurationError('WebSocket auth token resolver returned null. Check transport options.authToken / options.getAuthToken.', { code: 'SDK_AUTH_TOKEN_MISSING', source: 'config' }));
356
+ closeSocket();
357
+ return;
358
+ }
359
+ if (!openedSocket || stopped || socket !== openedSocket)
256
360
  return;
257
361
  // Notify observer of outbound WS connection.
258
- invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'ws' }));
362
+ invokeTransportObserver(() => observer?.onTransportActivity?.({ direction: 'send', url, kind: 'ws' }), observer?.onObserverError);
259
363
  // Send auth frame first, then drain any messages buffered during resolution.
260
364
  // Inject traceparent into the auth frame for W3C Trace Context propagation over WebSocket.
261
365
  const wsTraceHeaders = {};
@@ -270,10 +374,11 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
270
374
  ...(wsTraceHeaders['tracestate'] ? { tracestate: wsTraceHeaders['tracestate'] } : {}),
271
375
  }));
272
376
  flushOutboundQueue(openedSocket);
377
+ options.onOpen?.();
273
378
  }
274
379
  catch (error) {
275
380
  const sendError = transportErrorFromUnknown(error, 'WebSocket send failed');
276
- invokeTransportObserver(() => observer?.onError?.(sendError));
381
+ invokeTransportObserver(() => observer?.onError?.(sendError), observer?.onObserverError);
277
382
  options.onError?.(sendError);
278
383
  closeSocket();
279
384
  scheduleReconnect();
@@ -281,14 +386,33 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
281
386
  };
282
387
  const onMessage = (event) => {
283
388
  try {
389
+ if (typeof event.data !== 'string') {
390
+ throw new WebSocketTransportError('WebSocket runtime event frame was not a string payload.', { code: 'WS_EVENT_ERROR' });
391
+ }
392
+ // cheap pre-check (1 byte per char worst case) avoids allocating
393
+ // the full UTF-8 buffer for clearly-oversized frames. Only fall through
394
+ // to textEncoder.encode when within the fast bound.
395
+ if (event.data.length > MAX_INBOUND_FRAME_BYTES) {
396
+ throw new WebSocketTransportError(`WebSocket runtime event frame too large (>${MAX_INBOUND_FRAME_BYTES} bytes, limit ${MAX_INBOUND_FRAME_BYTES}).`, { code: 'WS_FRAME_TOO_LARGE' });
397
+ }
398
+ const frameBytes = textEncoder.encode(event.data).byteLength;
399
+ if (frameBytes > MAX_INBOUND_FRAME_BYTES) {
400
+ throw new WebSocketTransportError(`WebSocket runtime event frame too large (${frameBytes} bytes, limit ${MAX_INBOUND_FRAME_BYTES}).`, { code: 'WS_FRAME_TOO_LARGE' });
401
+ }
284
402
  const frame = JSON.parse(event.data);
285
403
  if (!hasReceivedMessage) {
286
404
  hasReceivedMessage = true;
287
405
  reconnectAttempt = 0;
288
406
  }
289
- queueOverflowNotified = false;
290
407
  if (frame.type === 'event' && frame.event === domain && frame.payload && typeof frame.payload === 'object') {
291
- const wsPayload = frame.payload;
408
+ const parsedPayload = TypedSerializedEventEnvelopeSchema.safeParse(frame.payload);
409
+ if (!parsedPayload.success) {
410
+ throw new WebSocketTransportError('WebSocket runtime event payload failed schema validation.', {
411
+ code: 'WS_EVENT_ERROR',
412
+ cause: parsedPayload.error,
413
+ });
414
+ }
415
+ const wsPayload = parsedPayload.data;
292
416
  onEnvelope(wsPayload);
293
417
  // Notify observer of inbound WS event.
294
418
  invokeTransportObserver(() => {
@@ -296,7 +420,7 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
296
420
  if (wsPayload.payload) {
297
421
  observer?.onEvent?.(wsPayload.payload);
298
422
  }
299
- });
423
+ }, observer?.onObserverError);
300
424
  }
301
425
  }
302
426
  catch (error) {
@@ -306,7 +430,7 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
306
430
  recoverable: true,
307
431
  cause: error,
308
432
  });
309
- invokeTransportObserver(() => observer?.onError?.(malformed));
433
+ invokeTransportObserver(() => observer?.onError?.(malformed), observer?.onObserverError);
310
434
  options.onError?.(malformed);
311
435
  }
312
436
  };
@@ -314,15 +438,15 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
314
438
  hasReceivedMessage = false;
315
439
  if (!stopped && event.code !== 1000 && event.code !== 1005) {
316
440
  const closeError = webSocketCloseError(event);
317
- invokeTransportObserver(() => observer?.onError?.(closeError));
441
+ invokeTransportObserver(() => observer?.onError?.(closeError), observer?.onObserverError);
318
442
  options.onError?.(closeError);
319
443
  }
320
444
  closeSocket();
321
445
  scheduleReconnect();
322
446
  };
323
447
  const onError = (event) => {
324
- const streamError = webSocketEventError(event);
325
- invokeTransportObserver(() => observer?.onError?.(streamError));
448
+ const streamError = webSocketEventError(event, socket, url);
449
+ invokeTransportObserver(() => observer?.onError?.(streamError), observer?.onObserverError);
326
450
  options.onError?.(streamError);
327
451
  };
328
452
  const connect = async () => {
@@ -336,26 +460,28 @@ export function createWebSocketConnector(baseUrl, token, WebSocketImpl, options
336
460
  nextSocket.addEventListener('close', onClose);
337
461
  nextSocket.addEventListener('error', onError);
338
462
  };
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
- }
463
+ // connect() is async but new WebSocket() does not throw synchronously;
464
+ // transport-level failures surface through onError/onClose. The try/catch
465
+ // here was dead code. Remove it and rely entirely on those event handlers.
466
+ void connect();
348
467
  return () => {
349
468
  stopped = true;
350
469
  if (reconnectTimer) {
351
470
  clearTimeout(reconnectTimer);
352
471
  }
353
472
  closeSocket();
473
+ // reset the outbound buffer on disposal so a future reconnect
474
+ // (or re-use of the returned cleanup path in tests) does not accumulate
475
+ // stale byte totals. Both must be reset together to prevent accounting drift.
476
+ outboundQueue.length = 0;
477
+ outboundQueueBytes = 0;
354
478
  };
355
479
  };
356
480
  }
357
481
  function webSocketCloseError(event) {
358
- const reason = typeof event.reason === 'string' ? event.reason.trim() : '';
482
+ // Cap reason length to prevent oversized error messages.
483
+ const rawReason = typeof event.reason === 'string' ? event.reason.trim() : '';
484
+ const reason = rawReason.length > 256 ? `${rawReason.slice(0, 256)}…` : rawReason;
359
485
  const code = typeof event.code === 'number' ? event.code : 1005;
360
486
  const wasClean = typeof event.wasClean === 'boolean' ? event.wasClean : false;
361
487
  const detail = [
@@ -364,18 +490,41 @@ function webSocketCloseError(event) {
364
490
  `wasClean=${wasClean}`,
365
491
  ].filter(Boolean).join(' ');
366
492
  return new WebSocketTransportError(`WebSocket runtime event stream closed unexpectedly: ${detail}`, {
367
- cause: event,
493
+ code: 'WS_CLOSE_ABNORMAL',
494
+ cause: { code, reason, wasClean },
368
495
  });
369
496
  }
370
- function webSocketEventError(event) {
497
+ function webSocketEventError(event, socket, url) {
498
+ // We cast to ErrorEvent to access `error`/`message` fields.
499
+ // Per the WHATWG spec, WebSocket `error` events are plain Events — not ErrorEvents
500
+ // — so `candidate.error` and `candidate.message` may be undefined in compliant
501
+ // browsers. The safe-extract path below handles both cases: if `candidate.error`
502
+ // is defined we treat it as an ErrorEvent (V8/Bun do populate it on some failures);
503
+ // if not, we fall through to the generic `describeUnknownTransportError` branch.
504
+ // Using `socket.onerror` directly instead would lose the envelope-unwrap logic here.
371
505
  const candidate = event;
506
+ // avoid retaining the raw event.error (which may hold currentTarget/target
507
+ // back-references to the WebSocket, creating retention chains over many reconnects).
508
+ // Capture only name+message from Error instances; stringify anything else.
509
+ const safeError = candidate.error instanceof Error
510
+ ? { name: candidate.error.name, message: candidate.error.message }
511
+ : candidate.error !== undefined && candidate.error !== null
512
+ ? String(candidate.error)
513
+ : undefined;
514
+ const cause = {
515
+ eventType: event.type,
516
+ url,
517
+ readyState: socket?.readyState,
518
+ message: typeof candidate.message === 'string' ? candidate.message : undefined,
519
+ error: safeError,
520
+ };
372
521
  if (candidate.error) {
373
- return transportErrorFromUnknown(candidate.error, 'WebSocket runtime event stream error');
522
+ return new WebSocketTransportError(`WebSocket runtime event stream error: ${transportErrorFromUnknown(candidate.error, 'WebSocket error').message}`, { code: 'WS_REMOTE_ERROR', cause });
374
523
  }
375
524
  const eventMessage = typeof candidate.message === 'string' && candidate.message.trim().length > 0
376
525
  ? candidate.message.trim()
377
526
  : undefined;
378
527
  return new WebSocketTransportError(eventMessage
379
528
  ? `WebSocket runtime event stream error: ${eventMessage}`
380
- : `WebSocket runtime event stream error: ${describeUnknownTransportError(event)}`, { cause: event });
529
+ : `WebSocket runtime event stream error: ${describeUnknownTransportError(event)}`, { code: 'WS_REMOTE_ERROR', cause });
381
530
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-transport-realtime",
3
- "version": "0.30.2",
3
+ "version": "0.33.0",
4
4
  "engines": {
5
- "node": ">=20.0.0"
5
+ "bun": "1.3.10",
6
+ "node": ">=22.0.0"
6
7
  },
7
8
  "description": "Realtime event-domain connectors for GoodVibes SSE and WebSocket integrations.",
8
9
  "type": "module",
@@ -44,10 +45,10 @@
44
45
  "events"
45
46
  ],
46
47
  "dependencies": {
47
- "@pellux/goodvibes-contracts": "0.30.2",
48
- "@pellux/goodvibes-errors": "0.30.2",
49
- "@pellux/goodvibes-transport-core": "0.30.2",
50
- "@pellux/goodvibes-transport-http": "0.30.2"
48
+ "@pellux/goodvibes-contracts": "0.33.0",
49
+ "@pellux/goodvibes-errors": "0.33.0",
50
+ "@pellux/goodvibes-transport-core": "0.33.0",
51
+ "@pellux/goodvibes-transport-http": "0.33.0"
51
52
  },
52
53
  "publishConfig": {
53
54
  "access": "public"