@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.
- package/CHANGELOG.md +564 -0
- package/LICENSE +661 -0
- package/README.md +85 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +180 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +180 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +195 -0
- package/src/__tests__/payload-channels.test.ts +233 -0
- package/src/__tests__/ws-payload-channel.e2e.test.ts +255 -0
- package/src/__tests__/ws-session-binder.test.ts +271 -0
- package/src/__tests__/ws-session-delivery.test.ts +86 -0
- package/src/__tests__/ws-session-directory.test.ts +97 -0
- package/src/__tests__/ws-transport-auth.test.ts +253 -0
- package/src/__tests__/ws-transport-lifecycle.test.ts +135 -0
- package/src/__tests__/ws-transport.test.ts +117 -0
- package/src/index.ts +7 -0
- package/src/payload-channels.ts +204 -0
- package/src/ws-connection-guards.ts +104 -0
- package/src/ws-message-data.ts +8 -0
- package/src/ws-session-delivery.ts +109 -0
- package/src/ws-transport-config.ts +133 -0
- package/src/ws-transport-configurable.ts +337 -0
- package/src/ws-transport.ts +94 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @robota-sdk/agent-transport-ws
|
|
2
|
+
|
|
3
|
+
WebSocket transport for the Robota SDK. `WsTransport` serves a running agent session on a loopback
|
|
4
|
+
WebSocket server, so a local client (a browser page, a desktop app, another process) can drive the
|
|
5
|
+
session in real time: submit prompts, receive streamed output, answer permission prompts, and read
|
|
6
|
+
history and status. The wire protocol and the session bridge come from `@robota-sdk/agent-transport`;
|
|
7
|
+
this package adds the socket server, connection admission and lifecycle, and pulls in only `ws`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @robota-sdk/agent-transport-ws @robota-sdk/agent-transport
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js 22.12 or later. `@robota-sdk/agent-transport` provides the session and message types
|
|
16
|
+
used below.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { WsTransport } from '@robota-sdk/agent-transport-ws';
|
|
22
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
23
|
+
|
|
24
|
+
declare const session: IProtocolSession; // e.g. an InteractiveSession from @robota-sdk/agent-framework
|
|
25
|
+
|
|
26
|
+
const transport = new WsTransport({ port: 7070 });
|
|
27
|
+
transport.attach(session);
|
|
28
|
+
await transport.start();
|
|
29
|
+
|
|
30
|
+
// Hand both to your client; it connects to ws://127.0.0.1:<port>/?token=<token>
|
|
31
|
+
console.log(transport.boundPort, transport.resolvedToken);
|
|
32
|
+
|
|
33
|
+
await transport.stop();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The server listens on `127.0.0.1` only. If the port is taken it tries the next one, up to `maxRetries`
|
|
37
|
+
times; `boundPort` reports the port actually bound.
|
|
38
|
+
|
|
39
|
+
Every connection must present the token, as a `?token=` query parameter or as the
|
|
40
|
+
`Sec-WebSocket-Protocol` value, or it is closed before any session data is sent. Without a `token`
|
|
41
|
+
option, a random token is minted per launch and exposed as `resolvedToken`. The `Host` header must be a
|
|
42
|
+
loopback name, and a browser's `Origin` must be loopback, so another web page cannot reach the socket.
|
|
43
|
+
Running without a token takes `open: true` with an `openReason`, and is discouraged: any local process
|
|
44
|
+
could then drive the session.
|
|
45
|
+
|
|
46
|
+
Clients exchange JSON text frames typed as `TClientMessage` / `TServerMessage` from
|
|
47
|
+
`@robota-sdk/agent-transport`, whose `./client` entry point also provides browser-safe decoders.
|
|
48
|
+
|
|
49
|
+
## `WsTransport` options
|
|
50
|
+
|
|
51
|
+
| Option | Type | Description |
|
|
52
|
+
| ------------------------------------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `port` | `number` | Port to bind. Default `7070`. |
|
|
54
|
+
| `maxRetries` | `number` | Next ports to try when the port is in use. Default `20`. |
|
|
55
|
+
| `token` | `string` | Credential every connection must present. Omitted: one is minted per launch. |
|
|
56
|
+
| `open` / `openReason` | `boolean` / `string` | Run without a token; `openReason` must say why. Rejected together with `token`. |
|
|
57
|
+
| `allowedHosts` / `allowedOrigins` | `readonly string[]` | Extra `Host` names and browser `Origin`s to accept beyond loopback. |
|
|
58
|
+
| `driverId` / `surface` | `TDriverId` / `TUsageSurface` | Identity recorded on turns submitted through this transport, set by the host rather than the client. |
|
|
59
|
+
| `sessionDirectory` | `ISessionDirectory` | Lets clients list, start and switch the host's sessions. |
|
|
60
|
+
| `sessionBinder` | `ISessionBinder` | Gives each connection its own session binding, so one client's switch does not move the others. |
|
|
61
|
+
| `personalUsageReporter` / `usageReporter` / `storedSessionUsageReporter` | reporters | Host-owned usage read models, available to admitted connections. |
|
|
62
|
+
|
|
63
|
+
`start()` before `attach()` throws, as does a second `start()` while running. `stop()` closes every
|
|
64
|
+
client (forcing any that do not close within a few seconds), is safe to call more than once, and
|
|
65
|
+
requires `attach()` again before the next `start()`.
|
|
66
|
+
|
|
67
|
+
Delivery failures are the connection's problem, not the session's: if a socket is closed or a send fails,
|
|
68
|
+
that connection is cleaned up once and the session operation that produced the event is unaffected.
|
|
69
|
+
|
|
70
|
+
## Other exports
|
|
71
|
+
|
|
72
|
+
- `createWsTransport(options)` — the same session bridge without a server, for a WebSocket you already
|
|
73
|
+
own. Pass a `send` function; after `start()`, feed each inbound text frame to `transport.onMessage`.
|
|
74
|
+
- `registerChannel(descriptor)` on `WsTransport` and the standalone `PayloadChannelRegistry` — carry
|
|
75
|
+
application-defined events and binary frames on the same connection as the agent protocol. Binary
|
|
76
|
+
frames go to channels; text frames go to the agent protocol.
|
|
77
|
+
- Types: `IWsTransportConfig` (the options above), `IWsTransport`, `IWsTransportOptions`,
|
|
78
|
+
`TChannelSink`.
|
|
79
|
+
|
|
80
|
+
## Related
|
|
81
|
+
|
|
82
|
+
- [`@robota-sdk/agent-transport`](../agent-transport/README.md) — the wire protocol and session bridge.
|
|
83
|
+
- [`@robota-sdk/agent-transport-webrtc`](../agent-transport-webrtc/README.md) — the same protocol
|
|
84
|
+
carried peer to peer.
|
|
85
|
+
- [docs/SPEC.md](./docs/SPEC.md) — package contract and invariants.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport"),t=require("node:http"),n=require("ws"),r=require("node:crypto"),i=require("@robota-sdk/agent-transport/node");function a(t){let n=null,r=null,i=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),a={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw i(`not-attached`);if(r)throw i(`already-started`);let o=(0,e.createOutboundDelivery)(t.send,(e,n)=>{s.cleanup(),r=null,a.onMessage=null,t.onDeliveryError?.(e,n)}),s=(0,e.createSessionMessageHandler)({session:n,deliver:o,...t.driverId?{driverId:t.driverId}:{},...t.surface?{surface:t.surface}:{},...t.personalUsageReporter?{personalUsageReporter:t.personalUsageReporter}:{},...t.usageReporter?{usageReporter:t.usageReporter}:{},...t.storedSessionUsageReporter?{storedSessionUsageReporter:t.storedSessionUsageReporter}:{},...t.sessionDirectory?{sessionDirectory:t.sessionDirectory}:{},...t.settingsReporter?{settingsReporter:t.settingsReporter}:{}});r=s.cleanup,this.onMessage=s.onMessage},async stop(){r?.(),r=null,this.onMessage=null,n=null}};return a}var o=class t{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(t){let n=(0,e.decodeChannelFrame)(t);if(!n.ok)return n;let{frame:r}=n,i=this.channels.get(r.channel);if(!i||!i.open)return{ok:!1,error:`No channel registered for "${r.channel}"`};if(r.kind===`binary`){if(i.descriptor.binary!==!0)return{ok:!1,error:`Channel "${r.channel}" did not declare binary payload support`};for(let e of[...i.binaryHandlers])e(r);return{ok:!0,frame:r}}if(!i.declaredEvents.has(r.event))return{ok:!1,error:`Channel "${r.channel}" did not declare event "${r.event}"`};let a=i.eventHandlers.get(r.event);if(a)for(let e of[...a])e(r.payload,r);return{ok:!0,frame:r}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(n,r){return{descriptor:n,sendEvent:(i,a)=>{if(t.assertOpen(r),!r.declaredEvents.has(i))throw Error(`Channel "${n.name}" did not declare event "${i}"`);let o=r.seq;r.seq+=1,this.broadcast((0,e.encodeChannelEventFrame)({kind:`event`,channel:n.name,seq:o,event:i,payload:a}))},sendBinary:i=>{if(t.assertOpen(r),n.binary!==!0)throw Error(`Channel "${n.name}" did not declare binary payload support`);let a=r.seq;r.seq+=1,this.broadcast((0,e.encodeBinaryFrame)({kind:`binary`,channel:n.name,seq:a,payload:i}))},onEvent:(e,i)=>{if(t.assertOpen(r),!r.declaredEvents.has(e))throw Error(`Channel "${n.name}" did not declare event "${e}"`);let a=r.eventHandlers.get(e)??new Set,o=i;return a.add(o),r.eventHandlers.set(e,a),()=>{a.delete(o)}},onBinary:e=>(t.assertOpen(r),r.binaryHandlers.add(e),()=>{r.binaryHandlers.delete(e)}),close:()=>{r.open=!1,r.eventHandlers.clear(),r.binaryHandlers.clear(),this.channels.delete(n.name)}}}};const s=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function c(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function l(e,t){let n=c(e.headers.host);return n===null?!1:s.has(n)||t.has(n)}function u(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return s.has(new URL(n).hostname)}catch{return!1}}function d(e,t){if(!t)return!1;let n=Buffer.from(e),i=Buffer.from(t);return n.length===i.length&&(0,r.timingSafeEqual)(n,i)}function f(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function p(e){return(0,i.resolveAdmission)({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function m(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var h=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=(0,e.createPendingStallClock)();deliver;constructor(t){this.socket=t,this.deliver=(0,e.createOutboundDelivery)(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e.DEFAULT_MAX_PENDING_BYTES,this.stallClock)}deliverBinary=t=>{if(!this.closed&&this.socket.readyState===n.WebSocket.OPEN){if((0,e.isOverPendingBudget)(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(t,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==n.WebSocket.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===n.WebSocket.OPEN||this.socket.readyState===n.WebSocket.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function g(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function _(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...g(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}}}function v(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function y(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var b=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new o;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=p(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=_(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw y(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw y(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw y(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return v(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(r,i){return new Promise((a,o)=>{let s=(0,t.createServer)((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});s.on(`error`,e=>{s.close(),o(e)});let c=this.token,p=this.allowedHosts,g=this.allowedOrigins,_=this.channels;s.listen(i,`127.0.0.1`,()=>{let t=new n.WebSocketServer({server:s,verifyClient:(e,t)=>{if(!l(e.req,p)){t(!1,403,`Forbidden host`);return}if(!u(e.req,g)){t(!1,403,`Forbidden origin`);return}t(!0)}});t.on(`connection`,(t,n)=>{if(c!==void 0&&!d(c,f(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,o=a?.session??r,s=new h(t),l=(0,e.createSessionMessageHandler)({session:o,deliver:s.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});s.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),s.bindSinkDetach(_.addSink(e=>s.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=_.receive(m(e));n.ok||s.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,s.close),t.on(`error`,s.close),s.deliver({type:`messages`,messages:o.getMessages(),...this.handlerOptions.driverId?{driverId:this.handlerOptions.driverId}:{}}),s.deliver({type:`execution_workspace_event`,snapshot:o.getExecutionWorkspaceSnapshot()})}),a({port:i,stop:()=>new Promise(e=>{for(let e of t.clients)e.close(1001,`server shutting down`);let n=setTimeout(()=>{for(let e of t.clients)e.terminate()},5e3);t.close(()=>{clearTimeout(n),s.close(()=>e())})})})})})}};exports.PayloadChannelRegistry=o,exports.WsTransport=b,exports.createWsTransport=a;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { TUsageSurface } from "@robota-sdk/agent-interface-analytics";
|
|
2
|
+
import { ISessionBinder, TDriverId } from "@robota-sdk/agent-interface-session";
|
|
3
|
+
import { IChannelDescriptor, IConfigurableTransport, IPayloadChannel, IPayloadChannelHost, ITransportAdapter, TChannelEventMap, TChannelReceiveResult } from "@robota-sdk/agent-interface-transport";
|
|
4
|
+
import { IProtocolSession, ISessionMessageHandlerOptions, TServerMessage } from "@robota-sdk/agent-transport";
|
|
5
|
+
import { TUniversalValue } from "@robota-sdk/agent-core";
|
|
6
|
+
//#region src/ws-transport.d.ts
|
|
7
|
+
interface IWsTransportOptions {
|
|
8
|
+
/** Send a JSON message to the connected WebSocket client. */
|
|
9
|
+
send: (message: TServerMessage) => void;
|
|
10
|
+
/** Owning socket lifecycle callback for outbound session-event delivery failures. */
|
|
11
|
+
onDeliveryError?: (error: Error, event: string) => void;
|
|
12
|
+
personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
13
|
+
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
14
|
+
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
15
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
16
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
17
|
+
driverId?: TDriverId;
|
|
18
|
+
surface?: TUsageSurface;
|
|
19
|
+
}
|
|
20
|
+
interface IWsTransport extends ITransportAdapter<IProtocolSession> {
|
|
21
|
+
onMessage: ((data: string) => void) | null;
|
|
22
|
+
}
|
|
23
|
+
declare function createWsTransport(options: IWsTransportOptions): IWsTransport;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/ws-transport-config.d.ts
|
|
26
|
+
interface IWsTransportConfig {
|
|
27
|
+
/** Trusted server-assigned identity for turns submitted over this product surface. */
|
|
28
|
+
driverId?: TDriverId;
|
|
29
|
+
/** Trusted product surface, independent from the connection's driver identity. */
|
|
30
|
+
surface?: TUsageSurface;
|
|
31
|
+
/** Host-owned cross-session usage read model, forwarded unchanged to the protocol handler. */
|
|
32
|
+
personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
33
|
+
/** Host-owned current-session trace/cost producer, forwarded unchanged to the protocol handler. */
|
|
34
|
+
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
35
|
+
/** Host-owned stored-session trace/cost producer for personal-usage drill-down. */
|
|
36
|
+
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
37
|
+
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
38
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
39
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
40
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
41
|
+
/**
|
|
42
|
+
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
43
|
+
* so one connection's switch moves only that connection. Released when the connection closes. When
|
|
44
|
+
* set, a connection uses its binding's session and directory instead of the attached session and
|
|
45
|
+
* `sessionDirectory`; without it every connection shares the attached session.
|
|
46
|
+
*/
|
|
47
|
+
sessionBinder?: ISessionBinder<IProtocolSession>;
|
|
48
|
+
port?: number;
|
|
49
|
+
maxRetries?: number;
|
|
50
|
+
/**
|
|
51
|
+
* OPTIONAL explicit loopback auth token. When set, every connection MUST present a matching token
|
|
52
|
+
* (query param `?token=` or the `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any
|
|
53
|
+
* session data is emitted (GUI-002; the host sidecar supplies its configured token). SEC-001: when this is unset
|
|
54
|
+
* AND `open` is not `true`, the transport AUTO-MINTS a random per-launch token (secure by default) —
|
|
55
|
+
* `resolvedToken` exposes it so the surface can deliver it to the co-located client (a `0600` connection
|
|
56
|
+
* file / the served monitor's injected `ws-url`). An explicit token here wins over the auto-mint — but NOT
|
|
57
|
+
* over `open`: SEC-008 makes a token together with `open: true` a contradiction that throws, rather
|
|
58
|
+
* than a precedence one side quietly wins.
|
|
59
|
+
*/
|
|
60
|
+
token?: string;
|
|
61
|
+
/**
|
|
62
|
+
* SEC-001 discouraged opt-out: when `true`, run WITHOUT auth (no token, no auto-mint) — the pre-SEC-001
|
|
63
|
+
* open loopback behavior. NOT RECOMMENDED (any local process or browser page can then drive+authorize the
|
|
64
|
+
* session); mirrors Jupyter's `c.ServerApp.token = ''`.
|
|
65
|
+
*
|
|
66
|
+
* SEC-008: `open: true` together with a non-empty `token` is REJECTED, not resolved by precedence. The
|
|
67
|
+
* two ask for opposite things, and picking a winner silently is how a caller ends up with the admission
|
|
68
|
+
* it did not choose. `resolveAdmission` decides that for every transport, so they cannot differ.
|
|
69
|
+
*/
|
|
70
|
+
open?: boolean;
|
|
71
|
+
/** SEC-008: why running with no credential is correct here. Required when `open` is true. */
|
|
72
|
+
openReason?: string;
|
|
73
|
+
/**
|
|
74
|
+
* SEC-001 defense-in-depth: extra host names (beyond `localhost`/`127.0.0.1`/`::1`) accepted in the
|
|
75
|
+
* upgrade `Host` header. The `Host` allow-list closes DNS-rebinding independently of the token.
|
|
76
|
+
*/
|
|
77
|
+
allowedHosts?: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* SEC-001 defense-in-depth: extra browser `Origin`s (beyond loopback) accepted on the upgrade — e.g. the
|
|
80
|
+
* `apps/agent-web` app origin. A browser sends an unforgeable `Origin`; a non-browser client omits it (and
|
|
81
|
+
* is gated by the token instead). Closes the "any web page in any browser" hole before history is emitted.
|
|
82
|
+
*/
|
|
83
|
+
allowedOrigins?: readonly string[];
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/ws-transport-configurable.d.ts
|
|
87
|
+
/**
|
|
88
|
+
* TRANS-001: the WS transport is a payload-agnostic CARRIER that routes by WebSocket frame opcode —
|
|
89
|
+
* TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the
|
|
90
|
+
* consumer-declared channels. The two profiles share one connection and never constrain each other.
|
|
91
|
+
*/
|
|
92
|
+
declare class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {
|
|
93
|
+
readonly name = "ws";
|
|
94
|
+
readonly lifecycle: Readonly<{
|
|
95
|
+
kind: "service";
|
|
96
|
+
}>;
|
|
97
|
+
readonly defaultEnabled = true;
|
|
98
|
+
readonly optionsSchema: {
|
|
99
|
+
port: {
|
|
100
|
+
type: string;
|
|
101
|
+
description: string;
|
|
102
|
+
default: number;
|
|
103
|
+
};
|
|
104
|
+
maxRetries: {
|
|
105
|
+
type: string;
|
|
106
|
+
description: string;
|
|
107
|
+
default: number;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
private session;
|
|
111
|
+
private stopFn;
|
|
112
|
+
private state;
|
|
113
|
+
private startOperation;
|
|
114
|
+
private startCancelled;
|
|
115
|
+
private port;
|
|
116
|
+
private maxRetries;
|
|
117
|
+
private readonly token?;
|
|
118
|
+
private readonly allowedHosts;
|
|
119
|
+
private readonly allowedOrigins;
|
|
120
|
+
private readonly handlerOptions;
|
|
121
|
+
private readonly sessionBinder;
|
|
122
|
+
private readonly channels;
|
|
123
|
+
private resolvedPort?;
|
|
124
|
+
constructor(config?: IWsTransportConfig);
|
|
125
|
+
attach(session: IProtocolSession): void;
|
|
126
|
+
/**
|
|
127
|
+
* TRANS-001: declare an app-level channel carried on this transport's connections. The channel's
|
|
128
|
+
* events and opaque binary frames ride alongside the text-agent protocol on the SAME socket — the
|
|
129
|
+
* transport never inspects the payloads. Register before or after `start()`; a channel opened
|
|
130
|
+
* later is served by every already-connected client.
|
|
131
|
+
*/
|
|
132
|
+
registerChannel<TEvents extends TChannelEventMap>(descriptor: IChannelDescriptor<TEvents>): IPayloadChannel<TEvents>;
|
|
133
|
+
/** Actually-bound port after start; undefined before start. */
|
|
134
|
+
get boundPort(): number | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* SEC-001: the resolved auth token (explicit or auto-minted), or `undefined` in the discouraged `open`
|
|
137
|
+
* mode. The surface reads this to deliver the token to the co-located client (a `0600` connection file /
|
|
138
|
+
* the served monitor's injected `ws-url`). Never logged by the transport itself.
|
|
139
|
+
*/
|
|
140
|
+
get resolvedToken(): string | undefined;
|
|
141
|
+
start(): Promise<void>;
|
|
142
|
+
stop(): Promise<void>;
|
|
143
|
+
/** TRANS-002 (issue #2480): persisted `port`/`maxRetries` reach the server through here, before start. */
|
|
144
|
+
configure(options: Record<string, TUniversalValue>): void;
|
|
145
|
+
validateOptions(options: Record<string, TUniversalValue>): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host
|
|
148
|
+
* would not bind it (shutting down, say): the socket is then closed before any session data.
|
|
149
|
+
*/
|
|
150
|
+
private bindConnection;
|
|
151
|
+
private bindWithRetry;
|
|
152
|
+
private tryBind;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/payload-channels.d.ts
|
|
156
|
+
/** Delivers an encoded frame to one attached connection. */
|
|
157
|
+
type TChannelSink = (frame: Uint8Array) => void;
|
|
158
|
+
declare class PayloadChannelRegistry implements IPayloadChannelHost {
|
|
159
|
+
private readonly channels;
|
|
160
|
+
private readonly sinks;
|
|
161
|
+
/**
|
|
162
|
+
* Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the
|
|
163
|
+
* returned function detaches (call it on socket close).
|
|
164
|
+
*/
|
|
165
|
+
addSink(sink: TChannelSink): () => void;
|
|
166
|
+
registerChannel<TEvents extends TChannelEventMap>(descriptor: IChannelDescriptor<TEvents>): IPayloadChannel<TEvents>;
|
|
167
|
+
/**
|
|
168
|
+
* Route one inbound encoded frame to its channel's handlers. The result states exactly what
|
|
169
|
+
* happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.
|
|
170
|
+
*/
|
|
171
|
+
receive(bytes: Uint8Array): TChannelReceiveResult;
|
|
172
|
+
/** Fan one encoded frame out to every attached connection. */
|
|
173
|
+
private broadcast;
|
|
174
|
+
/** Assert the channel is still open before any send/subscribe. */
|
|
175
|
+
private static assertOpen;
|
|
176
|
+
private createHandle;
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
export { type IWsTransport, type IWsTransportConfig, type IWsTransportOptions, PayloadChannelRegistry, type TChannelSink, WsTransport, createWsTransport };
|
|
180
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;UCtBhD;;EAEf,WAAW;;EAEX,UAAU;;EAEV,wBAAwB;;EAExB,gBAAgB;;EAEhB,6BAA6B;;EAE7B,mBAAmB;;EAEnB,mBAAmB;;;;;;;EAOnB,gBAAgB,eAAe;EAC/B;EACA;;;;;;;;;;;EAWA;;;;;;;;;;EAUA;;EAEA;;;;;EAKA;;;;;;EAMA;;;;;;;;;cCpBW,uBAAuB,uBAAuB,mBAAmB;WACnE;WACA,WAAS;;;WACT;WACA;;;;;;;;;;;;UASD;UACA;UACA;UACA;UACA;UACA;UACA;mBACS;mBACA;mBACA;mBACA;mBACA;mBACA;UACT;EAEI,YAAA,SAAQ;EAapB,OAAO,SAAS;;;;;;;EAWhB,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;MAKf;;;;;;MASA;EAIE,SAAS;EA8BT,QAAQ;;EAmBd,UAAU,SAAS,eAAe;EAWlC,gBAAgB,SAAS,eAAe;;;;;UAQhC;UAYA;UAYA;;;;;KC3LE,gBAAgB,OAAO;cAqBtB,kCAAkC;mBAC5B;mBACA;;;;;EAMjB,QAAQ,MAAM;EAOd,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;;;;EAoBnB,QAAQ,OAAO,aAAa;;UAmCpB;;iBAKO;UAIP"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { IProtocolSession, ISessionMessageHandlerOptions, TServerMessage } from "@robota-sdk/agent-transport";
|
|
2
|
+
import { TUsageSurface } from "@robota-sdk/agent-interface-analytics";
|
|
3
|
+
import { ISessionBinder, TDriverId } from "@robota-sdk/agent-interface-session";
|
|
4
|
+
import { IChannelDescriptor, IConfigurableTransport, IPayloadChannel, IPayloadChannelHost, ITransportAdapter, TChannelEventMap, TChannelReceiveResult } from "@robota-sdk/agent-interface-transport";
|
|
5
|
+
import { TUniversalValue } from "@robota-sdk/agent-core";
|
|
6
|
+
//#region src/ws-transport.d.ts
|
|
7
|
+
interface IWsTransportOptions {
|
|
8
|
+
/** Send a JSON message to the connected WebSocket client. */
|
|
9
|
+
send: (message: TServerMessage) => void;
|
|
10
|
+
/** Owning socket lifecycle callback for outbound session-event delivery failures. */
|
|
11
|
+
onDeliveryError?: (error: Error, event: string) => void;
|
|
12
|
+
personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
13
|
+
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
14
|
+
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
15
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
16
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
17
|
+
driverId?: TDriverId;
|
|
18
|
+
surface?: TUsageSurface;
|
|
19
|
+
}
|
|
20
|
+
interface IWsTransport extends ITransportAdapter<IProtocolSession> {
|
|
21
|
+
onMessage: ((data: string) => void) | null;
|
|
22
|
+
}
|
|
23
|
+
declare function createWsTransport(options: IWsTransportOptions): IWsTransport;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/ws-transport-config.d.ts
|
|
26
|
+
interface IWsTransportConfig {
|
|
27
|
+
/** Trusted server-assigned identity for turns submitted over this product surface. */
|
|
28
|
+
driverId?: TDriverId;
|
|
29
|
+
/** Trusted product surface, independent from the connection's driver identity. */
|
|
30
|
+
surface?: TUsageSurface;
|
|
31
|
+
/** Host-owned cross-session usage read model, forwarded unchanged to the protocol handler. */
|
|
32
|
+
personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
33
|
+
/** Host-owned current-session trace/cost producer, forwarded unchanged to the protocol handler. */
|
|
34
|
+
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
35
|
+
/** Host-owned stored-session trace/cost producer for personal-usage drill-down. */
|
|
36
|
+
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
37
|
+
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
38
|
+
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
39
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
40
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
41
|
+
/**
|
|
42
|
+
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
43
|
+
* so one connection's switch moves only that connection. Released when the connection closes. When
|
|
44
|
+
* set, a connection uses its binding's session and directory instead of the attached session and
|
|
45
|
+
* `sessionDirectory`; without it every connection shares the attached session.
|
|
46
|
+
*/
|
|
47
|
+
sessionBinder?: ISessionBinder<IProtocolSession>;
|
|
48
|
+
port?: number;
|
|
49
|
+
maxRetries?: number;
|
|
50
|
+
/**
|
|
51
|
+
* OPTIONAL explicit loopback auth token. When set, every connection MUST present a matching token
|
|
52
|
+
* (query param `?token=` or the `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any
|
|
53
|
+
* session data is emitted (GUI-002; the host sidecar supplies its configured token). SEC-001: when this is unset
|
|
54
|
+
* AND `open` is not `true`, the transport AUTO-MINTS a random per-launch token (secure by default) —
|
|
55
|
+
* `resolvedToken` exposes it so the surface can deliver it to the co-located client (a `0600` connection
|
|
56
|
+
* file / the served monitor's injected `ws-url`). An explicit token here wins over the auto-mint — but NOT
|
|
57
|
+
* over `open`: SEC-008 makes a token together with `open: true` a contradiction that throws, rather
|
|
58
|
+
* than a precedence one side quietly wins.
|
|
59
|
+
*/
|
|
60
|
+
token?: string;
|
|
61
|
+
/**
|
|
62
|
+
* SEC-001 discouraged opt-out: when `true`, run WITHOUT auth (no token, no auto-mint) — the pre-SEC-001
|
|
63
|
+
* open loopback behavior. NOT RECOMMENDED (any local process or browser page can then drive+authorize the
|
|
64
|
+
* session); mirrors Jupyter's `c.ServerApp.token = ''`.
|
|
65
|
+
*
|
|
66
|
+
* SEC-008: `open: true` together with a non-empty `token` is REJECTED, not resolved by precedence. The
|
|
67
|
+
* two ask for opposite things, and picking a winner silently is how a caller ends up with the admission
|
|
68
|
+
* it did not choose. `resolveAdmission` decides that for every transport, so they cannot differ.
|
|
69
|
+
*/
|
|
70
|
+
open?: boolean;
|
|
71
|
+
/** SEC-008: why running with no credential is correct here. Required when `open` is true. */
|
|
72
|
+
openReason?: string;
|
|
73
|
+
/**
|
|
74
|
+
* SEC-001 defense-in-depth: extra host names (beyond `localhost`/`127.0.0.1`/`::1`) accepted in the
|
|
75
|
+
* upgrade `Host` header. The `Host` allow-list closes DNS-rebinding independently of the token.
|
|
76
|
+
*/
|
|
77
|
+
allowedHosts?: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* SEC-001 defense-in-depth: extra browser `Origin`s (beyond loopback) accepted on the upgrade — e.g. the
|
|
80
|
+
* `apps/agent-web` app origin. A browser sends an unforgeable `Origin`; a non-browser client omits it (and
|
|
81
|
+
* is gated by the token instead). Closes the "any web page in any browser" hole before history is emitted.
|
|
82
|
+
*/
|
|
83
|
+
allowedOrigins?: readonly string[];
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/ws-transport-configurable.d.ts
|
|
87
|
+
/**
|
|
88
|
+
* TRANS-001: the WS transport is a payload-agnostic CARRIER that routes by WebSocket frame opcode —
|
|
89
|
+
* TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the
|
|
90
|
+
* consumer-declared channels. The two profiles share one connection and never constrain each other.
|
|
91
|
+
*/
|
|
92
|
+
declare class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {
|
|
93
|
+
readonly name = "ws";
|
|
94
|
+
readonly lifecycle: Readonly<{
|
|
95
|
+
kind: "service";
|
|
96
|
+
}>;
|
|
97
|
+
readonly defaultEnabled = true;
|
|
98
|
+
readonly optionsSchema: {
|
|
99
|
+
port: {
|
|
100
|
+
type: string;
|
|
101
|
+
description: string;
|
|
102
|
+
default: number;
|
|
103
|
+
};
|
|
104
|
+
maxRetries: {
|
|
105
|
+
type: string;
|
|
106
|
+
description: string;
|
|
107
|
+
default: number;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
private session;
|
|
111
|
+
private stopFn;
|
|
112
|
+
private state;
|
|
113
|
+
private startOperation;
|
|
114
|
+
private startCancelled;
|
|
115
|
+
private port;
|
|
116
|
+
private maxRetries;
|
|
117
|
+
private readonly token?;
|
|
118
|
+
private readonly allowedHosts;
|
|
119
|
+
private readonly allowedOrigins;
|
|
120
|
+
private readonly handlerOptions;
|
|
121
|
+
private readonly sessionBinder;
|
|
122
|
+
private readonly channels;
|
|
123
|
+
private resolvedPort?;
|
|
124
|
+
constructor(config?: IWsTransportConfig);
|
|
125
|
+
attach(session: IProtocolSession): void;
|
|
126
|
+
/**
|
|
127
|
+
* TRANS-001: declare an app-level channel carried on this transport's connections. The channel's
|
|
128
|
+
* events and opaque binary frames ride alongside the text-agent protocol on the SAME socket — the
|
|
129
|
+
* transport never inspects the payloads. Register before or after `start()`; a channel opened
|
|
130
|
+
* later is served by every already-connected client.
|
|
131
|
+
*/
|
|
132
|
+
registerChannel<TEvents extends TChannelEventMap>(descriptor: IChannelDescriptor<TEvents>): IPayloadChannel<TEvents>;
|
|
133
|
+
/** Actually-bound port after start; undefined before start. */
|
|
134
|
+
get boundPort(): number | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* SEC-001: the resolved auth token (explicit or auto-minted), or `undefined` in the discouraged `open`
|
|
137
|
+
* mode. The surface reads this to deliver the token to the co-located client (a `0600` connection file /
|
|
138
|
+
* the served monitor's injected `ws-url`). Never logged by the transport itself.
|
|
139
|
+
*/
|
|
140
|
+
get resolvedToken(): string | undefined;
|
|
141
|
+
start(): Promise<void>;
|
|
142
|
+
stop(): Promise<void>;
|
|
143
|
+
/** TRANS-002 (issue #2480): persisted `port`/`maxRetries` reach the server through here, before start. */
|
|
144
|
+
configure(options: Record<string, TUniversalValue>): void;
|
|
145
|
+
validateOptions(options: Record<string, TUniversalValue>): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host
|
|
148
|
+
* would not bind it (shutting down, say): the socket is then closed before any session data.
|
|
149
|
+
*/
|
|
150
|
+
private bindConnection;
|
|
151
|
+
private bindWithRetry;
|
|
152
|
+
private tryBind;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/payload-channels.d.ts
|
|
156
|
+
/** Delivers an encoded frame to one attached connection. */
|
|
157
|
+
type TChannelSink = (frame: Uint8Array) => void;
|
|
158
|
+
declare class PayloadChannelRegistry implements IPayloadChannelHost {
|
|
159
|
+
private readonly channels;
|
|
160
|
+
private readonly sinks;
|
|
161
|
+
/**
|
|
162
|
+
* Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the
|
|
163
|
+
* returned function detaches (call it on socket close).
|
|
164
|
+
*/
|
|
165
|
+
addSink(sink: TChannelSink): () => void;
|
|
166
|
+
registerChannel<TEvents extends TChannelEventMap>(descriptor: IChannelDescriptor<TEvents>): IPayloadChannel<TEvents>;
|
|
167
|
+
/**
|
|
168
|
+
* Route one inbound encoded frame to its channel's handlers. The result states exactly what
|
|
169
|
+
* happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.
|
|
170
|
+
*/
|
|
171
|
+
receive(bytes: Uint8Array): TChannelReceiveResult;
|
|
172
|
+
/** Fan one encoded frame out to every attached connection. */
|
|
173
|
+
private broadcast;
|
|
174
|
+
/** Assert the channel is still open before any send/subscribe. */
|
|
175
|
+
private static assertOpen;
|
|
176
|
+
private createHandle;
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
export { type IWsTransport, type IWsTransportConfig, type IWsTransportOptions, PayloadChannelRegistry, type TChannelSink, WsTransport, createWsTransport };
|
|
180
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;UCtBhD;;EAEf,WAAW;;EAEX,UAAU;;EAEV,wBAAwB;;EAExB,gBAAgB;;EAEhB,6BAA6B;;EAE7B,mBAAmB;;EAEnB,mBAAmB;;;;;;;EAOnB,gBAAgB,eAAe;EAC/B;EACA;;;;;;;;;;;EAWA;;;;;;;;;;EAUA;;EAEA;;;;;EAKA;;;;;;EAMA;;;;;;;;;cCpBW,uBAAuB,uBAAuB,mBAAmB;WACnE;WACA,WAAS;;;WACT;WACA;;;;;;;;;;;;UASD;UACA;UACA;UACA;UACA;UACA;UACA;mBACS;mBACA;mBACA;mBACA;mBACA;mBACA;UACT;EAEI,YAAA,SAAQ;EAapB,OAAO,SAAS;;;;;;;EAWhB,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;MAKf;;;;;;MASA;EAIE,SAAS;EA8BT,QAAQ;;EAmBd,UAAU,SAAS,eAAe;EAWlC,gBAAgB,SAAS,eAAe;;;;;UAQhC;UAYA;UAYA;;;;;KC3LE,gBAAgB,OAAO;cAqBtB,kCAAkC;mBAC5B;mBACA;;;;;EAMjB,QAAQ,MAAM;EAOd,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;;;;EAoBnB,QAAQ,OAAO,aAAa;;UAmCpB;;iBAKO;UAIP"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{DEFAULT_MAX_PENDING_BYTES as e,createOutboundDelivery as t,createPendingStallClock as n,createSessionMessageHandler as r,decodeChannelFrame as i,encodeBinaryFrame as a,encodeChannelEventFrame as o,isOverPendingBudget as s}from"@robota-sdk/agent-transport";import{createServer as c}from"node:http";import{WebSocket as l,WebSocketServer as u}from"ws";import{timingSafeEqual as d}from"node:crypto";import{resolveAdmission as f}from"@robota-sdk/agent-transport/node";function p(e){let n=null,i=null,a=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),o={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw a(`not-attached`);if(i)throw a(`already-started`);let s=t(e.send,(t,n)=>{c.cleanup(),i=null,o.onMessage=null,e.onDeliveryError?.(t,n)}),c=r({session:n,deliver:s,...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{},...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}});i=c.cleanup,this.onMessage=c.onMessage},async stop(){i?.(),i=null,this.onMessage=null,n=null}};return o}var m=class e{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(e){let t=i(e);if(!t.ok)return t;let{frame:n}=t,r=this.channels.get(n.channel);if(!r||!r.open)return{ok:!1,error:`No channel registered for "${n.channel}"`};if(n.kind===`binary`){if(r.descriptor.binary!==!0)return{ok:!1,error:`Channel "${n.channel}" did not declare binary payload support`};for(let e of[...r.binaryHandlers])e(n);return{ok:!0,frame:n}}if(!r.declaredEvents.has(n.event))return{ok:!1,error:`Channel "${n.channel}" did not declare event "${n.event}"`};let a=r.eventHandlers.get(n.event);if(a)for(let e of[...a])e(n.payload,n);return{ok:!0,frame:n}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(t,n){return{descriptor:t,sendEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.seq;n.seq+=1,this.broadcast(o({kind:`event`,channel:t.name,seq:a,event:r,payload:i}))},sendBinary:r=>{if(e.assertOpen(n),t.binary!==!0)throw Error(`Channel "${t.name}" did not declare binary payload support`);let i=n.seq;n.seq+=1,this.broadcast(a({kind:`binary`,channel:t.name,seq:i,payload:r}))},onEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.eventHandlers.get(r)??new Set,o=i;return a.add(o),n.eventHandlers.set(r,a),()=>{a.delete(o)}},onBinary:t=>(e.assertOpen(n),n.binaryHandlers.add(t),()=>{n.binaryHandlers.delete(t)}),close:()=>{n.open=!1,n.eventHandlers.clear(),n.binaryHandlers.clear(),this.channels.delete(t.name)}}}};const h=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function g(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function _(e,t){let n=g(e.headers.host);return n===null?!1:h.has(n)||t.has(n)}function v(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return h.has(new URL(n).hostname)}catch{return!1}}function y(e,t){if(!t)return!1;let n=Buffer.from(e),r=Buffer.from(t);return n.length===r.length&&d(n,r)}function b(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function x(e){return f({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function S(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var C=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=n();deliver;constructor(n){this.socket=n,this.deliver=t(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e,this.stallClock)}deliverBinary=e=>{if(!this.closed&&this.socket.readyState===l.OPEN){if(s(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(e,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==l.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===l.OPEN||this.socket.readyState===l.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function w(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function T(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...w(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}}}function E(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function D(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var O=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new m;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=x(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=T(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw D(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw D(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw D(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return E(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(e,t){return new Promise((n,i)=>{let a=c((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});a.on(`error`,e=>{a.close(),i(e)});let o=this.token,s=this.allowedHosts,l=this.allowedOrigins,d=this.channels;a.listen(t,`127.0.0.1`,()=>{let i=new u({server:a,verifyClient:(e,t)=>{if(!_(e.req,s)){t(!1,403,`Forbidden host`);return}if(!v(e.req,l)){t(!1,403,`Forbidden origin`);return}t(!0)}});i.on(`connection`,(t,n)=>{if(o!==void 0&&!y(o,b(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,s=a?.session??e,c=new C(t),l=r({session:s,deliver:c.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});c.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),c.bindSinkDetach(d.addSink(e=>c.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=d.receive(S(e));n.ok||c.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,c.close),t.on(`error`,c.close),c.deliver({type:`messages`,messages:s.getMessages(),...this.handlerOptions.driverId?{driverId:this.handlerOptions.driverId}:{}}),c.deliver({type:`execution_workspace_event`,snapshot:s.getExecutionWorkspaceSnapshot()})}),n({port:t,stop:()=>new Promise(e=>{for(let e of i.clients)e.close(1001,`server shutting down`);let t=setTimeout(()=>{for(let e of i.clients)e.terminate()},5e3);i.close(()=>{clearTimeout(t),a.close(()=>e())})})})})})}};export{m as PayloadChannelRegistry,O as WsTransport,p as createWsTransport};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|