@pellux/goodvibes-transport-realtime 0.30.3 → 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 +3 -3
- package/dist/domain-events.d.ts +6 -11
- package/dist/domain-events.d.ts.map +1 -1
- package/dist/domain-events.js +63 -21
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime-events.d.ts +27 -12
- package/dist/runtime-events.d.ts.map +1 -1
- package/dist/runtime-events.js +196 -47
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @pellux/goodvibes-transport-realtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Public GoodVibes realtime transport package for event-domain connectors over SSE and WebSocket.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
20
|
+
Use this surface when you want runtime-event subscriptions without pulling in the full main SDK.
|
package/dist/domain-events.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
readonly
|
|
9
|
-
readonly
|
|
10
|
-
readonly
|
|
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,
|
|
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"}
|
package/dist/domain-events.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
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,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,
|
|
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';
|
package/dist/runtime-events.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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":"AAAA,OAAO,
|
|
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,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
|
-
|
|
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:
|
|
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
|
|
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
|
|
185
|
-
* message is
|
|
186
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
331
|
+
if (!isSocketOpen(ws, WebSocketImpl)) {
|
|
332
|
+
// Socket closed mid-drain; leave the remaining items for the next reconnect.
|
|
244
333
|
break;
|
|
245
|
-
|
|
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
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)}`, {
|
|
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.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"engines": {
|
|
5
|
-
"
|
|
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.
|
|
48
|
-
"@pellux/goodvibes-errors": "0.
|
|
49
|
-
"@pellux/goodvibes-transport-core": "0.
|
|
50
|
-
"@pellux/goodvibes-transport-http": "0.
|
|
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"
|