@pellux/goodvibes-transport-realtime 0.18.3 → 0.30.1
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 +5 -9
- package/dist/domain-events.d.ts +30 -1
- package/dist/domain-events.d.ts.map +1 -1
- package/dist/domain-events.js +76 -10
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/runtime-events.d.ts +51 -4
- package/dist/runtime-events.d.ts.map +1 -1
- package/dist/runtime-events.js +295 -48
- package/package.json +18 -6
package/README.md
CHANGED
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
# @pellux/goodvibes-transport-realtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Internal workspace package backing `@pellux/goodvibes-sdk/transport-realtime`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Consumers should install `@pellux/goodvibes-sdk` and import this surface from the umbrella package.
|
|
6
6
|
|
|
7
|
-
|
|
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
|
|
20
|
+
Use this surface when you want runtime-event subscriptions without pulling in the full umbrella SDK.
|
package/dist/domain-events.d.ts
CHANGED
|
@@ -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
|
|
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":"
|
|
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"}
|
package/dist/domain-events.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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';
|
package/dist/runtime-events.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
18
|
-
export declare function createWebSocketConnector(baseUrl: string, token: AuthTokenSource, WebSocketImpl: typeof WebSocket, options?: RuntimeEventConnectorOptions): DomainEventConnector<RuntimeEventDomain,
|
|
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":"
|
|
1
|
+
{"version":3,"file":"runtime-events.d.ts","sourceRoot":"","sources":["../src/runtime-events.ts"],"names":[],"mappings":"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"}
|
package/dist/runtime-events.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
108
|
+
const { observer } = options;
|
|
109
|
+
const handleError = options.onError;
|
|
31
110
|
return async (domain, onEnvelope) => {
|
|
32
111
|
const url = buildEventSourceUrl(baseUrl, domain);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
73
|
-
if (nextAttempt >= maxAttempts)
|
|
224
|
+
if (nextAttempt > reconnectPolicy.maxAttempts)
|
|
74
225
|
return;
|
|
75
226
|
reconnectAttempt = nextAttempt;
|
|
76
|
-
|
|
77
|
-
const
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.30.1",
|
|
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.1",
|
|
48
|
+
"@pellux/goodvibes-errors": "0.30.1",
|
|
49
|
+
"@pellux/goodvibes-transport-core": "0.30.1",
|
|
50
|
+
"@pellux/goodvibes-transport-http": "0.30.1"
|
|
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
|
}
|