@robota-sdk/agent-transport-ws 3.0.0-beta.100

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Payload-agnostic channel registry (TRANS-001) — the transport-side half of
3
+ * {@link IPayloadChannelHost}.
4
+ *
5
+ * Owns the multiplexing the carrier needs and nothing else: which channels exist, what each one
6
+ * declared, per-channel outbound `seq`, the attached connection sinks, and inbound routing. It is
7
+ * completely blind to payload semantics — a channel carrying audio, a file, or app events is the
8
+ * same object to this class.
9
+ *
10
+ * Every rejection is EXPLICIT: an undeclared event, binary traffic on a text-only channel, or a
11
+ * frame for an unregistered channel comes back as a stated error the carrier reports to the peer.
12
+ * Nothing is silently dropped or defaulted.
13
+ */
14
+
15
+ import {
16
+ decodeChannelFrame,
17
+ encodeBinaryFrame,
18
+ encodeChannelEventFrame,
19
+ } from '@robota-sdk/agent-transport';
20
+
21
+ import type {
22
+ IBinaryFrame,
23
+ IChannelDescriptor,
24
+ IChannelEventFrame,
25
+ IPayloadChannel,
26
+ IPayloadChannelHost,
27
+ TChannelEventMap,
28
+ TChannelReceiveResult,
29
+ } from '@robota-sdk/agent-interface-transport';
30
+
31
+ /** Delivers an encoded frame to one attached connection. */
32
+ export type TChannelSink = (frame: Uint8Array) => void;
33
+
34
+ type TBinaryHandler = (frame: IBinaryFrame) => void;
35
+ /**
36
+ * Storage type for a consumer's event handler. `never` parameters make every concrete
37
+ * `(payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void` assignable to it, so the
38
+ * per-channel handler sets stay heterogeneous without an `unknown` double-cast. The declared-event
39
+ * check at both `onEvent` and `receive` is what keeps the erased types honest at runtime.
40
+ */
41
+ type TEventHandler = (payload: never, frame: never) => void;
42
+
43
+ /** Per-channel mutable state. Kept private to the registry — consumers only see `IPayloadChannel`. */
44
+ interface IChannelState {
45
+ readonly descriptor: IChannelDescriptor;
46
+ readonly declaredEvents: ReadonlySet<string>;
47
+ readonly eventHandlers: Map<string, Set<TEventHandler>>;
48
+ readonly binaryHandlers: Set<TBinaryHandler>;
49
+ seq: number;
50
+ open: boolean;
51
+ }
52
+
53
+ export class PayloadChannelRegistry implements IPayloadChannelHost {
54
+ private readonly channels = new Map<string, IChannelState>();
55
+ private readonly sinks = new Set<TChannelSink>();
56
+
57
+ /**
58
+ * Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the
59
+ * returned function detaches (call it on socket close).
60
+ */
61
+ addSink(sink: TChannelSink): () => void {
62
+ this.sinks.add(sink);
63
+ return (): void => {
64
+ this.sinks.delete(sink);
65
+ };
66
+ }
67
+
68
+ registerChannel<TEvents extends TChannelEventMap>(
69
+ descriptor: IChannelDescriptor<TEvents>,
70
+ ): IPayloadChannel<TEvents> {
71
+ if (this.channels.has(descriptor.name)) {
72
+ throw new Error(`Channel "${descriptor.name}" is already registered on this transport`);
73
+ }
74
+ const state: IChannelState = {
75
+ descriptor: descriptor as IChannelDescriptor,
76
+ declaredEvents: new Set<string>(descriptor.events),
77
+ eventHandlers: new Map(),
78
+ binaryHandlers: new Set(),
79
+ seq: 0,
80
+ open: true,
81
+ };
82
+ this.channels.set(descriptor.name, state);
83
+ return this.createHandle<TEvents>(descriptor, state);
84
+ }
85
+
86
+ /**
87
+ * Route one inbound encoded frame to its channel's handlers. The result states exactly what
88
+ * happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.
89
+ */
90
+ receive(bytes: Uint8Array): TChannelReceiveResult {
91
+ const decoded = decodeChannelFrame(bytes);
92
+ if (!decoded.ok) return decoded;
93
+
94
+ const { frame } = decoded;
95
+ const state = this.channels.get(frame.channel);
96
+ if (!state || !state.open) {
97
+ return { ok: false, error: `No channel registered for "${frame.channel}"` };
98
+ }
99
+
100
+ if (frame.kind === 'binary') {
101
+ if (state.descriptor.binary !== true) {
102
+ return {
103
+ ok: false,
104
+ error: `Channel "${frame.channel}" did not declare binary payload support`,
105
+ };
106
+ }
107
+ for (const handler of [...state.binaryHandlers]) handler(frame);
108
+ return { ok: true, frame };
109
+ }
110
+
111
+ if (!state.declaredEvents.has(frame.event)) {
112
+ return {
113
+ ok: false,
114
+ error: `Channel "${frame.channel}" did not declare event "${frame.event}"`,
115
+ };
116
+ }
117
+ const handlers = state.eventHandlers.get(frame.event);
118
+ if (handlers) {
119
+ for (const handler of [...handlers]) handler(frame.payload as never, frame as never);
120
+ }
121
+ return { ok: true, frame };
122
+ }
123
+
124
+ /** Fan one encoded frame out to every attached connection. */
125
+ private broadcast(frame: Uint8Array): void {
126
+ for (const sink of [...this.sinks]) sink(frame);
127
+ }
128
+
129
+ /** Assert the channel is still open before any send/subscribe. */
130
+ private static assertOpen(state: IChannelState): void {
131
+ if (!state.open) throw new Error(`Channel "${state.descriptor.name}" is closed`);
132
+ }
133
+
134
+ private createHandle<TEvents extends TChannelEventMap>(
135
+ descriptor: IChannelDescriptor<TEvents>,
136
+ state: IChannelState,
137
+ ): IPayloadChannel<TEvents> {
138
+ return {
139
+ descriptor,
140
+
141
+ sendEvent: <K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void => {
142
+ PayloadChannelRegistry.assertOpen(state);
143
+ if (!state.declaredEvents.has(event)) {
144
+ throw new Error(`Channel "${descriptor.name}" did not declare event "${event}"`);
145
+ }
146
+ const seq = state.seq;
147
+ state.seq += 1;
148
+ this.broadcast(
149
+ encodeChannelEventFrame({
150
+ kind: 'event',
151
+ channel: descriptor.name,
152
+ seq,
153
+ event,
154
+ payload,
155
+ }),
156
+ );
157
+ },
158
+
159
+ sendBinary: (payload: Uint8Array): void => {
160
+ PayloadChannelRegistry.assertOpen(state);
161
+ if (descriptor.binary !== true) {
162
+ throw new Error(`Channel "${descriptor.name}" did not declare binary payload support`);
163
+ }
164
+ const seq = state.seq;
165
+ state.seq += 1;
166
+ this.broadcast(
167
+ encodeBinaryFrame({ kind: 'binary', channel: descriptor.name, seq, payload }),
168
+ );
169
+ },
170
+
171
+ onEvent: <K extends keyof TEvents & string>(
172
+ event: K,
173
+ handler: (payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void,
174
+ ): (() => void) => {
175
+ PayloadChannelRegistry.assertOpen(state);
176
+ if (!state.declaredEvents.has(event)) {
177
+ throw new Error(`Channel "${descriptor.name}" did not declare event "${event}"`);
178
+ }
179
+ const set = state.eventHandlers.get(event) ?? new Set<TEventHandler>();
180
+ const typed: TEventHandler = handler;
181
+ set.add(typed);
182
+ state.eventHandlers.set(event, set);
183
+ return (): void => {
184
+ set.delete(typed);
185
+ };
186
+ },
187
+
188
+ onBinary: (handler: (frame: IBinaryFrame) => void): (() => void) => {
189
+ PayloadChannelRegistry.assertOpen(state);
190
+ state.binaryHandlers.add(handler);
191
+ return (): void => {
192
+ state.binaryHandlers.delete(handler);
193
+ };
194
+ },
195
+
196
+ close: (): void => {
197
+ state.open = false;
198
+ state.eventHandlers.clear();
199
+ state.binaryHandlers.clear();
200
+ this.channels.delete(descriptor.name);
201
+ },
202
+ };
203
+ }
204
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Connection-admission guards for the WS transport (SEC-001 / GUI-002).
3
+ *
4
+ * Pure, socket-free predicates over the HTTP upgrade request — token minting/comparison plus the
5
+ * `Host` and `Origin` allow-lists. Split out of `ws-transport-configurable.ts` so the transport file
6
+ * carries lifecycle and frame routing only; the admission policy is independently readable and the
7
+ * transport module stays within the file-size ratchet.
8
+ */
9
+
10
+ import { timingSafeEqual } from 'node:crypto';
11
+
12
+ import { resolveAdmission } from '@robota-sdk/agent-transport/node';
13
+
14
+ import type { ITransportAdmission } from '@robota-sdk/agent-interface-transport';
15
+ import type { IncomingMessage } from 'node:http';
16
+
17
+ /**
18
+ * Host names that are always loopback (port stripped by the caller). IPv4 bind only, so `[::1]`
19
+ * inbound is moot, but accepting it is harmless.
20
+ */
21
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
22
+
23
+ /** Strip the `:port` suffix from a Host/authority (port-agnostic — `bindWithRetry` can walk the port). */
24
+ function hostname(hostHeader: string | undefined): string | null {
25
+ if (!hostHeader) return null;
26
+ if (hostHeader.startsWith('[')) return hostHeader.slice(0, hostHeader.indexOf(']') + 1) || null;
27
+ const colon = hostHeader.lastIndexOf(':');
28
+ return colon === -1 ? hostHeader : hostHeader.slice(0, colon);
29
+ }
30
+
31
+ /**
32
+ * The upgrade `Host` must be a loopback name (port-agnostic) or an explicitly allowed host — closes
33
+ * DNS rebinding. A missing `Host` is rejected (a well-formed HTTP/1.1 client always sends one).
34
+ */
35
+ export function hostAllowed(req: IncomingMessage, allowedHosts: ReadonlySet<string>): boolean {
36
+ const host = hostname(req.headers.host);
37
+ if (host === null) return false;
38
+ return LOOPBACK_HOSTS.has(host) || allowedHosts.has(host);
39
+ }
40
+
41
+ /**
42
+ * A browser sends an unforgeable `Origin`; require it to be loopback or explicitly allowed. A
43
+ * non-browser client omits `Origin` (allowed here — the token is its gate). Closes the browser
44
+ * drive-by hole.
45
+ */
46
+ export function originAllowed(req: IncomingMessage, allowedOrigins: ReadonlySet<string>): boolean {
47
+ const origin = req.headers.origin;
48
+ if (origin === undefined) return true; // non-browser client; token still required
49
+ if (allowedOrigins.has(origin)) return true;
50
+ try {
51
+ return LOOPBACK_HOSTS.has(new URL(origin).hostname);
52
+ } catch {
53
+ return false; // malformed Origin → reject
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Constant-time token comparison. Returns false on any length mismatch (never throws), so an
59
+ * absent/short/long presented token is a plain reject, not an error.
60
+ */
61
+ export function tokenMatches(expected: string, presented: string | null | undefined): boolean {
62
+ if (!presented) return false;
63
+ const a = Buffer.from(expected);
64
+ const b = Buffer.from(presented);
65
+ if (a.length !== b.length) return false;
66
+ return timingSafeEqual(a, b);
67
+ }
68
+
69
+ /** Read the presented token from the upgrade request: `?token=` query param, else the WS subprotocol. */
70
+ export function presentedToken(req: IncomingMessage): string | null {
71
+ try {
72
+ const url = new URL(req.url ?? '', 'ws://127.0.0.1');
73
+ const q = url.searchParams.get('token');
74
+ if (q) return q;
75
+ } catch {
76
+ // malformed URL → fall through to the subprotocol header
77
+ }
78
+ const proto = req.headers['sec-websocket-protocol'];
79
+ return typeof proto === 'string' ? proto.split(',')[0]?.trim() || null : null;
80
+ }
81
+
82
+ /**
83
+ * This transport's admission decision, asked of the shared seam.
84
+ *
85
+ * Lives here rather than in the transport file because this module already owns the admission
86
+ * policy — token comparison and the Host/Origin allow-lists — and the transport file carries
87
+ * lifecycle and frame routing. The split is also what keeps that file inside its size ratchet.
88
+ */
89
+ export function resolveWsAdmission(config: {
90
+ token?: string;
91
+ open?: boolean;
92
+ openReason?: string;
93
+ }): ITransportAdmission {
94
+ // No fabricated reason. The first version filled one in for any caller that omitted it, which made
95
+ // WS the one transport where `{ open: true }` alone was accepted — HTTP and WebRTC both refuse it.
96
+ // Review found that: a reason invented on the caller's behalf reads, to the next person, as a
97
+ // decision somebody made. The whole point of requiring it is that nobody can produce one by
98
+ // accident, so the seam is asked the question exactly as it was given.
99
+ return resolveAdmission({
100
+ ...(config.token !== undefined ? { token: config.token } : {}),
101
+ ...(config.open === true ? { open: true } : {}),
102
+ ...(config.openReason !== undefined ? { openReason: config.openReason } : {}),
103
+ });
104
+ }
@@ -0,0 +1,8 @@
1
+ import type { RawData } from 'ws';
2
+
3
+ /** Normalize the `ws` inbound payload variants to one binary view. */
4
+ export function toBytes(data: RawData): Uint8Array {
5
+ if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
6
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
7
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
8
+ }
@@ -0,0 +1,109 @@
1
+ import {
2
+ createOutboundDelivery,
3
+ createPendingStallClock,
4
+ DEFAULT_MAX_PENDING_BYTES,
5
+ isOverPendingBudget,
6
+ } from '@robota-sdk/agent-transport';
7
+ import { WebSocket } from 'ws';
8
+
9
+ import type {
10
+ IPendingStallClock,
11
+ TOutboundDeliver,
12
+ TServerMessage,
13
+ } from '@robota-sdk/agent-transport';
14
+
15
+ /**
16
+ * Connection-scoped session delivery lifecycle shared by sync and async WebSocket failures.
17
+ *
18
+ * ARCH-030: {@link deliver} is the ONLY way out of this class. The raw sink — the `readyState` check
19
+ * plus `socket.send` — is private, so no caller can put a frame on this socket outside the connection's
20
+ * one outbound boundary. That is not stylistic: the previous shape exposed a public `send` that threw
21
+ * on a closed socket, and every reply the protocol handler produced went through it unguarded.
22
+ */
23
+ export class WsSessionDelivery {
24
+ private cleanupProtocol = (): void => undefined;
25
+ private detachSink = (): void => undefined;
26
+ private closed = false;
27
+ /**
28
+ * Issue #2306: ONE drain clock for the socket. `bufferedAmount` does not distinguish text from
29
+ * binary, so both halves observe the same clock — a slow reader of payload frames is caught when a
30
+ * JSON reply is attempted, and the reverse.
31
+ */
32
+ private readonly stallClock: IPendingStallClock = createPendingStallClock();
33
+
34
+ /**
35
+ * The connection's outbound boundary. Built here, from this class's own sink and its own `close`
36
+ * policy, and passed DOWN into `createSessionMessageHandler` — the carrier owns both halves, so it builds the
37
+ * boundary rather than handing the protocol layer a raw sink to wrap.
38
+ */
39
+ readonly deliver: TOutboundDeliver;
40
+
41
+ constructor(private readonly socket: WebSocket) {
42
+ this.deliver = createOutboundDelivery(
43
+ (message) => this.rawSend(message),
44
+ () => this.close(),
45
+ // ARCH-030 / issue #1734: the socket's own count of what it has accepted and not yet written.
46
+ // Read at call time — it changes underneath.
47
+ () => this.socket.bufferedAmount,
48
+ DEFAULT_MAX_PENDING_BYTES,
49
+ this.stallClock,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Put a TRANS-001 payload frame on this socket, under the SAME budget and the same close policy as
55
+ * the text protocol (ARCH-030 / issue #1734).
56
+ *
57
+ * A WebSocket carries two kinds of outbound frame over one socket, and until this existed the
58
+ * binary half went straight to `socket.send` — outside the boundary, with no budget, and with a
59
+ * failure that could only surface as a throw inside a listener. `bufferedAmount` does not
60
+ * distinguish the two, so a non-reading peer accumulating payload frames was invisible to a budget
61
+ * that only guarded the other half.
62
+ *
63
+ * It is not `deliver`: that boundary is typed for `TServerMessage` and its brand is what makes a
64
+ * raw send unusable where a protocol frame is required. Widening it to accept bytes would remove
65
+ * the property the brand exists for.
66
+ */
67
+ readonly deliverBinary = (frame: Uint8Array): void => {
68
+ if (this.closed) return;
69
+ if (this.socket.readyState !== WebSocket.OPEN) return;
70
+ if (
71
+ isOverPendingBudget(this.socket.bufferedAmount) ||
72
+ this.stallClock.observe(this.socket.bufferedAmount) !== undefined
73
+ ) {
74
+ this.close();
75
+ return;
76
+ }
77
+ this.socket.send(frame, { binary: true }, (error) => {
78
+ if (error) this.close();
79
+ });
80
+ };
81
+
82
+ private rawSend(message: TServerMessage): void {
83
+ if (this.socket.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not open');
84
+ this.socket.send(JSON.stringify(message), (error) => {
85
+ if (error) this.close();
86
+ });
87
+ }
88
+
89
+ bindProtocolCleanup(cleanup: () => void): void {
90
+ this.cleanupProtocol = cleanup;
91
+ }
92
+
93
+ bindSinkDetach(detach: () => void): void {
94
+ this.detachSink = detach;
95
+ }
96
+
97
+ readonly close = (): void => {
98
+ if (this.closed) return;
99
+ this.closed = true;
100
+ this.detachSink();
101
+ this.cleanupProtocol();
102
+ if (
103
+ this.socket.readyState === WebSocket.OPEN ||
104
+ this.socket.readyState === WebSocket.CONNECTING
105
+ ) {
106
+ this.socket.close(1011, 'session event delivery failed');
107
+ }
108
+ };
109
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The construction contract for `WsTransport` — what a caller decides before the server exists.
3
+ *
4
+ * Split from `ws-transport-configurable.ts` under the file-size ceiling, and the seam is the
5
+ * honest one: everything here is a decision the CALLER makes (port, retries, admission — SEC-008's
6
+ * `token`/`open`/`openReason` contract lives in these doc comments), while the class next door is
7
+ * what the transport DOES with those decisions.
8
+ */
9
+
10
+ import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
11
+ import type { ISessionBinder, TDriverId } from '@robota-sdk/agent-interface-session';
12
+ import type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';
13
+ import type { IProtocolSession, ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';
14
+
15
+ export const DEFAULT_PORT = 7070;
16
+ export const DEFAULT_MAX_RETRIES = 20;
17
+
18
+ export interface IWsTransportConfig {
19
+ /** Trusted server-assigned identity for turns submitted over this product surface. */
20
+ driverId?: TDriverId;
21
+ /** Trusted product surface, independent from the connection's driver identity. */
22
+ surface?: TUsageSurface;
23
+ /** Host-owned cross-session usage read model, forwarded unchanged to the protocol handler. */
24
+ personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
25
+ /** Host-owned current-session trace/cost producer, forwarded unchanged to the protocol handler. */
26
+ usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
27
+ /** Host-owned stored-session trace/cost producer for personal-usage drill-down. */
28
+ storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
29
+ /** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
30
+ sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
31
+ /** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
32
+ settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
33
+ /**
34
+ * #3189: gives each connection a binding of its own — its own session and its own directory view —
35
+ * so one connection's switch moves only that connection. Released when the connection closes. When
36
+ * set, a connection uses its binding's session and directory instead of the attached session and
37
+ * `sessionDirectory`; without it every connection shares the attached session.
38
+ */
39
+ sessionBinder?: ISessionBinder<IProtocolSession>;
40
+ port?: number;
41
+ maxRetries?: number;
42
+ /**
43
+ * OPTIONAL explicit loopback auth token. When set, every connection MUST present a matching token
44
+ * (query param `?token=` or the `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any
45
+ * session data is emitted (GUI-002; the host sidecar supplies its configured token). SEC-001: when this is unset
46
+ * AND `open` is not `true`, the transport AUTO-MINTS a random per-launch token (secure by default) —
47
+ * `resolvedToken` exposes it so the surface can deliver it to the co-located client (a `0600` connection
48
+ * file / the served monitor's injected `ws-url`). An explicit token here wins over the auto-mint — but NOT
49
+ * over `open`: SEC-008 makes a token together with `open: true` a contradiction that throws, rather
50
+ * than a precedence one side quietly wins.
51
+ */
52
+ token?: string;
53
+ /**
54
+ * SEC-001 discouraged opt-out: when `true`, run WITHOUT auth (no token, no auto-mint) — the pre-SEC-001
55
+ * open loopback behavior. NOT RECOMMENDED (any local process or browser page can then drive+authorize the
56
+ * session); mirrors Jupyter's `c.ServerApp.token = ''`.
57
+ *
58
+ * SEC-008: `open: true` together with a non-empty `token` is REJECTED, not resolved by precedence. The
59
+ * two ask for opposite things, and picking a winner silently is how a caller ends up with the admission
60
+ * it did not choose. `resolveAdmission` decides that for every transport, so they cannot differ.
61
+ */
62
+ open?: boolean;
63
+ /** SEC-008: why running with no credential is correct here. Required when `open` is true. */
64
+ openReason?: string;
65
+ /**
66
+ * SEC-001 defense-in-depth: extra host names (beyond `localhost`/`127.0.0.1`/`::1`) accepted in the
67
+ * upgrade `Host` header. The `Host` allow-list closes DNS-rebinding independently of the token.
68
+ */
69
+ allowedHosts?: readonly string[];
70
+ /**
71
+ * SEC-001 defense-in-depth: extra browser `Origin`s (beyond loopback) accepted on the upgrade — e.g. the
72
+ * `apps/agent-web` app origin. A browser sends an unforgeable `Origin`; a non-browser client omits it (and
73
+ * is gated by the token instead). Closes the "any web page in any browser" hole before history is emitted.
74
+ */
75
+ allowedOrigins?: readonly string[];
76
+ }
77
+
78
+ type TUsageReporters = Pick<
79
+ ISessionMessageHandlerOptions,
80
+ 'personalUsageReporter' | 'usageReporter' | 'storedSessionUsageReporter'
81
+ >;
82
+
83
+ /** Remove absent reporters so exact-optional handler options remain truthful. */
84
+ function configuredUsageReporters(config: IWsTransportConfig): TUsageReporters {
85
+ return {
86
+ ...(config.personalUsageReporter
87
+ ? { personalUsageReporter: config.personalUsageReporter }
88
+ : {}),
89
+ ...(config.usageReporter ? { usageReporter: config.usageReporter } : {}),
90
+ ...(config.storedSessionUsageReporter
91
+ ? { storedSessionUsageReporter: config.storedSessionUsageReporter }
92
+ : {}),
93
+ };
94
+ }
95
+
96
+ type TConfiguredHandlerOptions = Pick<
97
+ ISessionMessageHandlerOptions,
98
+ | 'driverId'
99
+ | 'surface'
100
+ | 'personalUsageReporter'
101
+ | 'usageReporter'
102
+ | 'storedSessionUsageReporter'
103
+ | 'sessionDirectory'
104
+ | 'settingsReporter'
105
+ >;
106
+
107
+ /** Project immutable transport attribution/reporters into each protocol handler. */
108
+ export function configuredWsHandlerOptions(config: IWsTransportConfig): TConfiguredHandlerOptions {
109
+ return {
110
+ ...(config.driverId ? { driverId: config.driverId } : {}),
111
+ ...(config.surface ? { surface: config.surface } : {}),
112
+ ...configuredUsageReporters(config),
113
+ ...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),
114
+ ...(config.settingsReporter ? { settingsReporter: config.settingsReporter } : {}),
115
+ };
116
+ }
117
+
118
+ export function validTransportOptions(options: Readonly<Record<string, unknown>>): boolean {
119
+ const { port, maxRetries } = options;
120
+ if (port !== undefined && (typeof port !== 'number' || port < 1 || port > 65535)) return false;
121
+ return maxRetries === undefined || (typeof maxRetries === 'number' && maxRetries >= 0);
122
+ }
123
+
124
+ export function transportLifecycleError(
125
+ transportName: string,
126
+ code: ITransportLifecycleError['code'],
127
+ ): ITransportLifecycleError {
128
+ return Object.assign(new Error(`${transportName} ${code}.`), {
129
+ name: 'TransportLifecycleError' as const,
130
+ code,
131
+ transportName,
132
+ });
133
+ }