@clawswarm/bridge 0.1.0-alpha → 0.2.0-alpha

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/src/bridge.ts DELETED
@@ -1,330 +0,0 @@
1
- /**
2
- * WebSocket bridge server for ClawSwarm.
3
- *
4
- * Provides real-time bidirectional communication between agents,
5
- * dashboard clients, and external consumers. Handles org-scoped
6
- * message routing, authentication, and connection lifecycle.
7
- *
8
- * @module @clawswarm/bridge/bridge
9
- */
10
-
11
- import { WebSocketServer, WebSocket } from 'ws';
12
- import { v4 as uuidv4 } from 'uuid';
13
- import EventEmitter from 'eventemitter3';
14
- import {
15
- BridgeClient,
16
- BridgeMessage,
17
- BridgeServerConfig,
18
- BridgeServerEvents,
19
- AuthPayload,
20
- } from './types.js';
21
-
22
- // ─── Defaults ─────────────────────────────────────────────────────────────────
23
-
24
- const DEFAULT_PORT = 8787;
25
- const DEFAULT_HOST = '0.0.0.0';
26
- const DEFAULT_MAX_CONNECTIONS = 1000;
27
- const DEFAULT_PING_INTERVAL_MS = 30_000;
28
-
29
- // ─── BridgeServer ─────────────────────────────────────────────────────────────
30
-
31
- /**
32
- * The ClawSwarm bridge server.
33
- *
34
- * Manages WebSocket connections, authenticates clients, and routes
35
- * messages between agents and dashboard consumers within org boundaries.
36
- *
37
- * @example
38
- * ```typescript
39
- * const bridge = new BridgeServer({ port: 8787 });
40
- *
41
- * bridge.on('client:connected', (client) => {
42
- * console.log('Connected:', client.id, 'org:', client.orgId);
43
- * });
44
- *
45
- * await bridge.start();
46
- * console.log('Bridge listening on ws://localhost:8787');
47
- * ```
48
- */
49
- export class BridgeServer extends (EventEmitter as new () => EventEmitter<BridgeServerEvents>) {
50
- private readonly config: Required<BridgeServerConfig>;
51
- private wss: WebSocketServer | null = null;
52
- private clients: Map<string, { client: BridgeClient; socket: WebSocket }> = new Map();
53
- private pingTimer: ReturnType<typeof setInterval> | null = null;
54
-
55
- constructor(config: BridgeServerConfig = {}) {
56
- super();
57
- this.config = {
58
- port: config.port ?? DEFAULT_PORT,
59
- host: config.host ?? DEFAULT_HOST,
60
- maxConnections: config.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
61
- pingIntervalMs: config.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS,
62
- authTokens: config.authTokens ?? [],
63
- path: config.path ?? '/',
64
- };
65
- }
66
-
67
- // ─── Public API ───────────────────────────────────────────────────────────
68
-
69
- /**
70
- * Start the WebSocket server.
71
- * Returns a promise that resolves once the server is listening.
72
- */
73
- async start(): Promise<void> {
74
- if (this.wss) throw new Error('BridgeServer is already running');
75
-
76
- return new Promise((resolve, reject) => {
77
- this.wss = new WebSocketServer({
78
- port: this.config.port,
79
- host: this.config.host,
80
- path: this.config.path,
81
- });
82
-
83
- this.wss.on('connection', (socket, req) => this._onConnection(socket, req));
84
- this.wss.on('error', (err) => {
85
- this.emit('error', err);
86
- reject(err);
87
- });
88
-
89
- this.wss.on('listening', () => {
90
- this._startPingTimer();
91
- this.emit('listening', this.config.port, this.config.host);
92
- resolve();
93
- });
94
- });
95
- }
96
-
97
- /**
98
- * Stop the WebSocket server and disconnect all clients.
99
- */
100
- async stop(): Promise<void> {
101
- if (!this.wss) return;
102
-
103
- if (this.pingTimer) {
104
- clearInterval(this.pingTimer);
105
- this.pingTimer = null;
106
- }
107
-
108
- // Close all client connections
109
- for (const [id, { socket }] of this.clients) {
110
- socket.close(1001, 'Server shutting down');
111
- this.clients.delete(id);
112
- }
113
-
114
- return new Promise((resolve) => {
115
- this.wss!.close(() => {
116
- this.wss = null;
117
- resolve();
118
- });
119
- });
120
- }
121
-
122
- /**
123
- * Send a message to a specific client by ID.
124
- * Returns false if the client is not found or not connected.
125
- */
126
- send(clientId: string, message: BridgeMessage): boolean {
127
- const entry = this.clients.get(clientId);
128
- if (!entry || entry.socket.readyState !== WebSocket.OPEN) return false;
129
-
130
- try {
131
- entry.socket.send(JSON.stringify(message));
132
- this.emit('message:sent', clientId, message);
133
- return true;
134
- } catch {
135
- return false;
136
- }
137
- }
138
-
139
- /**
140
- * Broadcast a message to all clients in an organization.
141
- * Optionally filter by role.
142
- *
143
- * @param orgId - Organization to broadcast to ('*' for all orgs)
144
- * @param message - Message to send
145
- * @param roles - Optional role filter
146
- * @returns Number of clients reached
147
- */
148
- broadcast(
149
- orgId: string,
150
- message: BridgeMessage,
151
- roles?: BridgeClient['role'][]
152
- ): number {
153
- let count = 0;
154
- for (const [, { client, socket }] of this.clients) {
155
- if (socket.readyState !== WebSocket.OPEN) continue;
156
- if (orgId !== '*' && client.orgId !== orgId) continue;
157
- if (roles && !roles.includes(client.role)) continue;
158
- if (!client.authenticated) continue;
159
-
160
- socket.send(JSON.stringify(message));
161
- count++;
162
- }
163
- return count;
164
- }
165
-
166
- /**
167
- * Get all connected clients (optionally filtered by org).
168
- */
169
- getClients(orgId?: string): BridgeClient[] {
170
- const all = Array.from(this.clients.values()).map(e => e.client);
171
- return orgId ? all.filter(c => c.orgId === orgId) : all;
172
- }
173
-
174
- /**
175
- * Get server stats.
176
- */
177
- stats(): { connections: number; orgs: number; uptime: boolean } {
178
- const orgs = new Set(Array.from(this.clients.values()).map(e => e.client.orgId));
179
- return {
180
- connections: this.clients.size,
181
- orgs: orgs.size,
182
- uptime: this.wss !== null,
183
- };
184
- }
185
-
186
- // ─── Private ──────────────────────────────────────────────────────────────
187
-
188
- /**
189
- * Handle a new WebSocket connection.
190
- * @internal
191
- */
192
- private _onConnection(socket: WebSocket, _req: import('http').IncomingMessage): void {
193
- if (this.clients.size >= this.config.maxConnections) {
194
- socket.close(1013, 'Server at capacity');
195
- return;
196
- }
197
-
198
- const clientId = uuidv4();
199
- const client: BridgeClient = {
200
- id: clientId,
201
- orgId: 'unknown',
202
- role: 'external',
203
- connectedAt: new Date().toISOString(),
204
- authenticated: this.config.authTokens.length === 0, // no auth if no tokens configured
205
- metadata: {},
206
- };
207
-
208
- this.clients.set(clientId, { client, socket });
209
- this.emit('client:connected', client);
210
-
211
- socket.on('message', (data) => this._onMessage(clientId, data));
212
- socket.on('close', (code, reason) => this._onClose(clientId, code, reason.toString()));
213
- socket.on('error', (err) => this.emit('error', err));
214
- socket.on('pong', () => {
215
- const entry = this.clients.get(clientId);
216
- if (entry) entry.client.lastPingAt = new Date().toISOString();
217
- });
218
- }
219
-
220
- /**
221
- * Handle an incoming message from a client.
222
- * @internal
223
- */
224
- private _onMessage(clientId: string, data: import('ws').RawData): void {
225
- const entry = this.clients.get(clientId);
226
- if (!entry) return;
227
-
228
- let message: BridgeMessage;
229
- try {
230
- message = JSON.parse(data.toString()) as BridgeMessage;
231
- } catch {
232
- this.send(clientId, this._errorMessage('PARSE_ERROR', 'Invalid JSON'));
233
- return;
234
- }
235
-
236
- // Handle auth message
237
- if (message.type === 'auth') {
238
- this._handleAuth(clientId, message.payload as AuthPayload);
239
- return;
240
- }
241
-
242
- // Reject unauthenticated messages
243
- if (!entry.client.authenticated) {
244
- this.send(clientId, this._errorMessage('UNAUTHORIZED', 'Authenticate first'));
245
- return;
246
- }
247
-
248
- // Handle ping
249
- if (message.type === 'ping') {
250
- this.send(clientId, { type: 'pong', ts: new Date().toISOString(), payload: {} });
251
- return;
252
- }
253
-
254
- this.emit('message:received', entry.client, message);
255
- }
256
-
257
- /**
258
- * Handle an auth message from a client.
259
- * @internal
260
- */
261
- private _handleAuth(clientId: string, payload: AuthPayload): void {
262
- const entry = this.clients.get(clientId);
263
- if (!entry) return;
264
-
265
- const { token, orgId, role, metadata } = payload;
266
-
267
- // Validate token if auth is configured
268
- if (
269
- this.config.authTokens.length > 0 &&
270
- !this.config.authTokens.includes(token)
271
- ) {
272
- this.send(clientId, this._errorMessage('INVALID_TOKEN', 'Invalid auth token'));
273
- entry.socket.close(1008, 'Unauthorized');
274
- return;
275
- }
276
-
277
- entry.client.authenticated = true;
278
- entry.client.orgId = orgId;
279
- entry.client.role = role;
280
- entry.client.metadata = metadata ?? {};
281
-
282
- this.send(clientId, {
283
- type: 'pong', // using pong as ack
284
- ts: new Date().toISOString(),
285
- payload: { authenticated: true, clientId },
286
- });
287
-
288
- this.emit('client:authenticated', entry.client);
289
- }
290
-
291
- /**
292
- * Handle a client disconnection.
293
- * @internal
294
- */
295
- private _onClose(clientId: string, code: number, reason: string): void {
296
- this.clients.delete(clientId);
297
- this.emit('client:disconnected', clientId, reason || String(code));
298
- }
299
-
300
- /**
301
- * Build an error message.
302
- * @internal
303
- */
304
- private _errorMessage(code: string, message: string): BridgeMessage {
305
- return {
306
- type: 'error',
307
- ts: new Date().toISOString(),
308
- payload: { code, message },
309
- };
310
- }
311
-
312
- /**
313
- * Start the ping timer for keep-alive.
314
- * @internal
315
- */
316
- private _startPingTimer(): void {
317
- this.pingTimer = setInterval(() => {
318
- const now = new Date().toISOString();
319
- for (const [id, { socket, client }] of this.clients) {
320
- if (socket.readyState === WebSocket.OPEN) {
321
- socket.ping();
322
- client.lastPingAt = now;
323
- } else {
324
- // Clean up stale connections
325
- this.clients.delete(id);
326
- }
327
- }
328
- }, this.config.pingIntervalMs);
329
- }
330
- }
package/src/index.ts DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * @clawswarm/bridge — Public API
3
- *
4
- * @example
5
- * ```typescript
6
- * import { BridgeServer, TaskRouter } from '@clawswarm/bridge';
7
- * ```
8
- *
9
- * @module @clawswarm/bridge
10
- */
11
-
12
- export { BridgeServer } from './bridge.js';
13
- export { TaskRouter } from './router.js';
14
-
15
- export type {
16
- BridgeClient,
17
- ClientRole,
18
- BridgeMessage,
19
- BridgeMessageType,
20
- BridgeServerConfig,
21
- BridgeServerEvents,
22
- AuthPayload,
23
- ErrorPayload,
24
- AgentStatusPayload,
25
- RoutingRule,
26
- } from './types.js';
package/src/router.ts DELETED
@@ -1,178 +0,0 @@
1
- /**
2
- * Org-scoped task router for the ClawSwarm bridge.
3
- *
4
- * Routes messages from ClawSwarm events to the appropriate
5
- * WebSocket clients based on organization ID and client role.
6
- *
7
- * @module @clawswarm/bridge/router
8
- */
9
-
10
- import { BridgeServer } from './bridge.js';
11
- import { BridgeMessage, BridgeMessageType, ClientRole } from './types.js';
12
-
13
- // ─── TaskRouter ───────────────────────────────────────────────────────────────
14
-
15
- /**
16
- * Routes ClawSwarm events to connected bridge clients.
17
- *
18
- * Provides a high-level API for broadcasting goal/task events
19
- * to the right clients within an organization.
20
- *
21
- * @example
22
- * ```typescript
23
- * const bridge = new BridgeServer({ port: 8787 });
24
- * await bridge.start();
25
- *
26
- * const router = new TaskRouter(bridge);
27
- *
28
- * // Connect a ClawSwarm instance to the router
29
- * swarm.on('task:completed', (task) => {
30
- * router.routeTaskEvent('task:completed', task.goalId, task, 'my-org-id');
31
- * });
32
- * ```
33
- */
34
- export class TaskRouter {
35
- private readonly bridge: BridgeServer;
36
- private readonly rules: Map<string, RouteRule> = new Map();
37
-
38
- constructor(bridge: BridgeServer) {
39
- this.bridge = bridge;
40
- }
41
-
42
- // ─── Routing Methods ──────────────────────────────────────────────────────
43
-
44
- /**
45
- * Route a task-related event to all dashboard clients in the org.
46
- *
47
- * @param type - Event type
48
- * @param goalId - Goal ID for context
49
- * @param payload - Event payload
50
- * @param orgId - Organization to route to
51
- */
52
- routeTaskEvent(
53
- type: BridgeMessageType,
54
- goalId: string,
55
- payload: unknown,
56
- orgId: string
57
- ): number {
58
- return this.bridge.broadcast(
59
- orgId,
60
- this._buildMessage(type, orgId, payload),
61
- ['dashboard', 'external']
62
- );
63
- }
64
-
65
- /**
66
- * Route a goal-related event.
67
- *
68
- * @param type - Event type
69
- * @param payload - Event payload (the Goal object)
70
- * @param orgId - Organization to route to
71
- */
72
- routeGoalEvent(
73
- type: BridgeMessageType,
74
- payload: unknown,
75
- orgId: string
76
- ): number {
77
- return this.bridge.broadcast(
78
- orgId,
79
- this._buildMessage(type, orgId, payload),
80
- ['dashboard', 'external']
81
- );
82
- }
83
-
84
- /**
85
- * Route an agent status update to dashboard clients.
86
- */
87
- routeAgentStatus(
88
- agentId: string,
89
- agentType: string,
90
- status: 'idle' | 'busy' | 'error' | 'offline',
91
- orgId: string,
92
- currentTaskId?: string
93
- ): number {
94
- return this.bridge.broadcast(
95
- orgId,
96
- this._buildMessage('agent:status', orgId, { agentId, agentType, status, currentTaskId }),
97
- ['dashboard']
98
- );
99
- }
100
-
101
- /**
102
- * Broadcast a raw message to all clients in an org.
103
- */
104
- broadcast(
105
- orgId: string,
106
- message: BridgeMessage,
107
- roles?: ClientRole[]
108
- ): number {
109
- return this.bridge.broadcast(orgId, message, roles);
110
- }
111
-
112
- /**
113
- * Add a routing rule for custom message handling.
114
- * Rules are evaluated before default routing.
115
- *
116
- * @param id - Unique rule identifier
117
- * @param rule - Route rule definition
118
- */
119
- addRule(id: string, rule: RouteRule): void {
120
- this.rules.set(id, rule);
121
- }
122
-
123
- /**
124
- * Remove a routing rule.
125
- */
126
- removeRule(id: string): boolean {
127
- return this.rules.delete(id);
128
- }
129
-
130
- /**
131
- * Route a message through all matching rules.
132
- * Returns the number of clients that received the message.
133
- */
134
- route(orgId: string, message: BridgeMessage): number {
135
- let count = 0;
136
-
137
- for (const rule of this.rules.values()) {
138
- if (rule.messageType !== '*' && rule.messageType !== message.type) continue;
139
- if (rule.orgIds && !rule.orgIds.includes(orgId)) continue;
140
-
141
- count += this.bridge.broadcast(orgId, message, rule.roles);
142
- }
143
-
144
- // Default: broadcast to all in org if no rules matched
145
- if (count === 0) {
146
- count = this.bridge.broadcast(orgId, message);
147
- }
148
-
149
- return count;
150
- }
151
-
152
- // ─── Private ──────────────────────────────────────────────────────────────
153
-
154
- /**
155
- * Build a typed bridge message.
156
- * @internal
157
- */
158
- private _buildMessage(
159
- type: BridgeMessageType,
160
- orgId: string,
161
- payload: unknown
162
- ): BridgeMessage {
163
- return {
164
- type,
165
- ts: new Date().toISOString(),
166
- orgId,
167
- payload,
168
- };
169
- }
170
- }
171
-
172
- // ─── Internal Types ───────────────────────────────────────────────────────────
173
-
174
- interface RouteRule {
175
- messageType: BridgeMessageType | '*';
176
- orgIds?: string[];
177
- roles?: ClientRole[];
178
- }
package/src/types.ts DELETED
@@ -1,129 +0,0 @@
1
- /**
2
- * Bridge-specific types for the ClawSwarm WebSocket bridge server.
3
- * @module @clawswarm/bridge/types
4
- */
5
-
6
- // ─── Client Connection ────────────────────────────────────────────────────────
7
-
8
- /** Role of a connected client */
9
- export type ClientRole = 'agent' | 'dashboard' | 'external';
10
-
11
- /** A connected WebSocket client */
12
- export interface BridgeClient {
13
- /** Unique connection ID */
14
- id: string;
15
- /** Organization this client belongs to */
16
- orgId: string;
17
- /** Client role */
18
- role: ClientRole;
19
- /** ISO timestamp of connection */
20
- connectedAt: string;
21
- /** Last ping/pong timestamp */
22
- lastPingAt?: string;
23
- /** Whether the client is authenticated */
24
- authenticated: boolean;
25
- /** Metadata from the handshake */
26
- metadata: Record<string, string>;
27
- }
28
-
29
- // ─── Messages ─────────────────────────────────────────────────────────────────
30
-
31
- /** All message types the bridge can handle */
32
- export type BridgeMessageType =
33
- | 'auth'
34
- | 'ping'
35
- | 'pong'
36
- | 'goal:created'
37
- | 'goal:planning'
38
- | 'goal:completed'
39
- | 'goal:failed'
40
- | 'task:assigned'
41
- | 'task:started'
42
- | 'task:completed'
43
- | 'task:review'
44
- | 'task:rejected'
45
- | 'task:rework'
46
- | 'task:failed'
47
- | 'human:review_required'
48
- | 'agent:status'
49
- | 'error';
50
-
51
- /** Base shape of all bridge messages */
52
- export interface BridgeMessage<T = unknown> {
53
- /** Message type */
54
- type: BridgeMessageType;
55
- /** ISO timestamp */
56
- ts: string;
57
- /** Organization ID (for routing) */
58
- orgId?: string;
59
- /** Message payload */
60
- payload: T;
61
- /** Optional correlation ID for request/response pairs */
62
- correlationId?: string;
63
- }
64
-
65
- /** Auth message payload (client → server) */
66
- export interface AuthPayload {
67
- token: string;
68
- orgId: string;
69
- role: ClientRole;
70
- metadata?: Record<string, string>;
71
- }
72
-
73
- /** Error message payload */
74
- export interface ErrorPayload {
75
- code: string;
76
- message: string;
77
- correlationId?: string;
78
- }
79
-
80
- /** Agent status update payload */
81
- export interface AgentStatusPayload {
82
- agentId: string;
83
- agentType: string;
84
- status: 'idle' | 'busy' | 'error' | 'offline';
85
- currentTaskId?: string;
86
- }
87
-
88
- // ─── Router ───────────────────────────────────────────────────────────────────
89
-
90
- /** A routing rule that maps message types to handler functions */
91
- export interface RoutingRule {
92
- /** Message type to match */
93
- messageType: BridgeMessageType | '*';
94
- /** Org IDs to route to (empty = all orgs) */
95
- orgIds?: string[];
96
- /** Client roles to route to (empty = all roles) */
97
- roles?: ClientRole[];
98
- }
99
-
100
- // ─── Server Config ────────────────────────────────────────────────────────────
101
-
102
- /** Configuration for the BridgeServer */
103
- export interface BridgeServerConfig {
104
- /** Port to listen on (default: 8787) */
105
- port?: number;
106
- /** Host to bind to (default: '0.0.0.0') */
107
- host?: string;
108
- /** Maximum concurrent connections (default: 1000) */
109
- maxConnections?: number;
110
- /** Ping interval in ms (default: 30000) */
111
- pingIntervalMs?: number;
112
- /** Allowed auth tokens — empty array means no auth required */
113
- authTokens?: string[];
114
- /** Path prefix for WebSocket endpoint (default: '/') */
115
- path?: string;
116
- }
117
-
118
- // ─── Events ───────────────────────────────────────────────────────────────────
119
-
120
- /** Events emitted by BridgeServer */
121
- export interface BridgeServerEvents {
122
- 'client:connected': (client: BridgeClient) => void;
123
- 'client:disconnected': (clientId: string, reason: string) => void;
124
- 'client:authenticated': (client: BridgeClient) => void;
125
- 'message:received': (client: BridgeClient, message: BridgeMessage) => void;
126
- 'message:sent': (clientId: string, message: BridgeMessage) => void;
127
- 'error': (error: Error) => void;
128
- 'listening': (port: number, host: string) => void;
129
- }
package/tsconfig.json DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/tsconfig",
3
- "extends": "../../tsconfig.base.json",
4
- "compilerOptions": {
5
- "outDir": "dist",
6
- "rootDir": "src",
7
- "composite": true,
8
- "paths": {
9
- "@clawswarm/core": ["../core/src/index.ts"],
10
- "@clawswarm/core/*": ["../core/src/*.ts"]
11
- }
12
- },
13
- "references": [
14
- { "path": "../core" }
15
- ],
16
- "include": ["src/**/*.ts"],
17
- "exclude": ["node_modules", "dist", "**/__tests__/**"]
18
- }