@clawswarm/bridge 0.1.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/README.md +68 -0
- package/dist/bridge.d.ts +105 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +297 -0
- package/dist/bridge.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +88 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +122 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/bridge.ts +330 -0
- package/src/index.ts +26 -0
- package/src/router.ts +178 -0
- package/src/types.ts +129 -0
- package/tsconfig.json +18 -0
- package/tsconfig.tsbuildinfo +1 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Org-scoped task router for the ClawSwarm bridge.
|
|
4
|
+
*
|
|
5
|
+
* Routes messages from ClawSwarm events to the appropriate
|
|
6
|
+
* WebSocket clients based on organization ID and client role.
|
|
7
|
+
*
|
|
8
|
+
* @module @clawswarm/bridge/router
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.TaskRouter = void 0;
|
|
12
|
+
// ─── TaskRouter ───────────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Routes ClawSwarm events to connected bridge clients.
|
|
15
|
+
*
|
|
16
|
+
* Provides a high-level API for broadcasting goal/task events
|
|
17
|
+
* to the right clients within an organization.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const bridge = new BridgeServer({ port: 8787 });
|
|
22
|
+
* await bridge.start();
|
|
23
|
+
*
|
|
24
|
+
* const router = new TaskRouter(bridge);
|
|
25
|
+
*
|
|
26
|
+
* // Connect a ClawSwarm instance to the router
|
|
27
|
+
* swarm.on('task:completed', (task) => {
|
|
28
|
+
* router.routeTaskEvent('task:completed', task.goalId, task, 'my-org-id');
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
class TaskRouter {
|
|
33
|
+
bridge;
|
|
34
|
+
rules = new Map();
|
|
35
|
+
constructor(bridge) {
|
|
36
|
+
this.bridge = bridge;
|
|
37
|
+
}
|
|
38
|
+
// ─── Routing Methods ──────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Route a task-related event to all dashboard clients in the org.
|
|
41
|
+
*
|
|
42
|
+
* @param type - Event type
|
|
43
|
+
* @param goalId - Goal ID for context
|
|
44
|
+
* @param payload - Event payload
|
|
45
|
+
* @param orgId - Organization to route to
|
|
46
|
+
*/
|
|
47
|
+
routeTaskEvent(type, goalId, payload, orgId) {
|
|
48
|
+
return this.bridge.broadcast(orgId, this._buildMessage(type, orgId, payload), ['dashboard', 'external']);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Route a goal-related event.
|
|
52
|
+
*
|
|
53
|
+
* @param type - Event type
|
|
54
|
+
* @param payload - Event payload (the Goal object)
|
|
55
|
+
* @param orgId - Organization to route to
|
|
56
|
+
*/
|
|
57
|
+
routeGoalEvent(type, payload, orgId) {
|
|
58
|
+
return this.bridge.broadcast(orgId, this._buildMessage(type, orgId, payload), ['dashboard', 'external']);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Route an agent status update to dashboard clients.
|
|
62
|
+
*/
|
|
63
|
+
routeAgentStatus(agentId, agentType, status, orgId, currentTaskId) {
|
|
64
|
+
return this.bridge.broadcast(orgId, this._buildMessage('agent:status', orgId, { agentId, agentType, status, currentTaskId }), ['dashboard']);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Broadcast a raw message to all clients in an org.
|
|
68
|
+
*/
|
|
69
|
+
broadcast(orgId, message, roles) {
|
|
70
|
+
return this.bridge.broadcast(orgId, message, roles);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Add a routing rule for custom message handling.
|
|
74
|
+
* Rules are evaluated before default routing.
|
|
75
|
+
*
|
|
76
|
+
* @param id - Unique rule identifier
|
|
77
|
+
* @param rule - Route rule definition
|
|
78
|
+
*/
|
|
79
|
+
addRule(id, rule) {
|
|
80
|
+
this.rules.set(id, rule);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Remove a routing rule.
|
|
84
|
+
*/
|
|
85
|
+
removeRule(id) {
|
|
86
|
+
return this.rules.delete(id);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Route a message through all matching rules.
|
|
90
|
+
* Returns the number of clients that received the message.
|
|
91
|
+
*/
|
|
92
|
+
route(orgId, message) {
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const rule of this.rules.values()) {
|
|
95
|
+
if (rule.messageType !== '*' && rule.messageType !== message.type)
|
|
96
|
+
continue;
|
|
97
|
+
if (rule.orgIds && !rule.orgIds.includes(orgId))
|
|
98
|
+
continue;
|
|
99
|
+
count += this.bridge.broadcast(orgId, message, rule.roles);
|
|
100
|
+
}
|
|
101
|
+
// Default: broadcast to all in org if no rules matched
|
|
102
|
+
if (count === 0) {
|
|
103
|
+
count = this.bridge.broadcast(orgId, message);
|
|
104
|
+
}
|
|
105
|
+
return count;
|
|
106
|
+
}
|
|
107
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Build a typed bridge message.
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
_buildMessage(type, orgId, payload) {
|
|
113
|
+
return {
|
|
114
|
+
type,
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
orgId,
|
|
117
|
+
payload,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
exports.TaskRouter = TaskRouter;
|
|
122
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge-specific types for the ClawSwarm WebSocket bridge server.
|
|
3
|
+
* @module @clawswarm/bridge/types
|
|
4
|
+
*/
|
|
5
|
+
/** Role of a connected client */
|
|
6
|
+
export type ClientRole = 'agent' | 'dashboard' | 'external';
|
|
7
|
+
/** A connected WebSocket client */
|
|
8
|
+
export interface BridgeClient {
|
|
9
|
+
/** Unique connection ID */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Organization this client belongs to */
|
|
12
|
+
orgId: string;
|
|
13
|
+
/** Client role */
|
|
14
|
+
role: ClientRole;
|
|
15
|
+
/** ISO timestamp of connection */
|
|
16
|
+
connectedAt: string;
|
|
17
|
+
/** Last ping/pong timestamp */
|
|
18
|
+
lastPingAt?: string;
|
|
19
|
+
/** Whether the client is authenticated */
|
|
20
|
+
authenticated: boolean;
|
|
21
|
+
/** Metadata from the handshake */
|
|
22
|
+
metadata: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/** All message types the bridge can handle */
|
|
25
|
+
export type BridgeMessageType = 'auth' | 'ping' | 'pong' | 'goal:created' | 'goal:planning' | 'goal:completed' | 'goal:failed' | 'task:assigned' | 'task:started' | 'task:completed' | 'task:review' | 'task:rejected' | 'task:rework' | 'task:failed' | 'human:review_required' | 'agent:status' | 'error';
|
|
26
|
+
/** Base shape of all bridge messages */
|
|
27
|
+
export interface BridgeMessage<T = unknown> {
|
|
28
|
+
/** Message type */
|
|
29
|
+
type: BridgeMessageType;
|
|
30
|
+
/** ISO timestamp */
|
|
31
|
+
ts: string;
|
|
32
|
+
/** Organization ID (for routing) */
|
|
33
|
+
orgId?: string;
|
|
34
|
+
/** Message payload */
|
|
35
|
+
payload: T;
|
|
36
|
+
/** Optional correlation ID for request/response pairs */
|
|
37
|
+
correlationId?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Auth message payload (client → server) */
|
|
40
|
+
export interface AuthPayload {
|
|
41
|
+
token: string;
|
|
42
|
+
orgId: string;
|
|
43
|
+
role: ClientRole;
|
|
44
|
+
metadata?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
/** Error message payload */
|
|
47
|
+
export interface ErrorPayload {
|
|
48
|
+
code: string;
|
|
49
|
+
message: string;
|
|
50
|
+
correlationId?: string;
|
|
51
|
+
}
|
|
52
|
+
/** Agent status update payload */
|
|
53
|
+
export interface AgentStatusPayload {
|
|
54
|
+
agentId: string;
|
|
55
|
+
agentType: string;
|
|
56
|
+
status: 'idle' | 'busy' | 'error' | 'offline';
|
|
57
|
+
currentTaskId?: string;
|
|
58
|
+
}
|
|
59
|
+
/** A routing rule that maps message types to handler functions */
|
|
60
|
+
export interface RoutingRule {
|
|
61
|
+
/** Message type to match */
|
|
62
|
+
messageType: BridgeMessageType | '*';
|
|
63
|
+
/** Org IDs to route to (empty = all orgs) */
|
|
64
|
+
orgIds?: string[];
|
|
65
|
+
/** Client roles to route to (empty = all roles) */
|
|
66
|
+
roles?: ClientRole[];
|
|
67
|
+
}
|
|
68
|
+
/** Configuration for the BridgeServer */
|
|
69
|
+
export interface BridgeServerConfig {
|
|
70
|
+
/** Port to listen on (default: 8787) */
|
|
71
|
+
port?: number;
|
|
72
|
+
/** Host to bind to (default: '0.0.0.0') */
|
|
73
|
+
host?: string;
|
|
74
|
+
/** Maximum concurrent connections (default: 1000) */
|
|
75
|
+
maxConnections?: number;
|
|
76
|
+
/** Ping interval in ms (default: 30000) */
|
|
77
|
+
pingIntervalMs?: number;
|
|
78
|
+
/** Allowed auth tokens — empty array means no auth required */
|
|
79
|
+
authTokens?: string[];
|
|
80
|
+
/** Path prefix for WebSocket endpoint (default: '/') */
|
|
81
|
+
path?: string;
|
|
82
|
+
}
|
|
83
|
+
/** Events emitted by BridgeServer */
|
|
84
|
+
export interface BridgeServerEvents {
|
|
85
|
+
'client:connected': (client: BridgeClient) => void;
|
|
86
|
+
'client:disconnected': (clientId: string, reason: string) => void;
|
|
87
|
+
'client:authenticated': (client: BridgeClient) => void;
|
|
88
|
+
'message:received': (client: BridgeClient, message: BridgeMessage) => void;
|
|
89
|
+
'message:sent': (clientId: string, message: BridgeMessage) => void;
|
|
90
|
+
'error': (error: Error) => void;
|
|
91
|
+
'listening': (port: number, host: string) => void;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";AAAA;;;GAGG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clawswarm/bridge",
|
|
3
|
+
"version": "0.1.0-alpha",
|
|
4
|
+
"description": "WebSocket bridge server for ClawSwarm — real-time agent communication and org-scoped task routing",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./bridge": {
|
|
14
|
+
"types": "./dist/bridge.d.ts",
|
|
15
|
+
"import": "./dist/bridge.js",
|
|
16
|
+
"default": "./dist/bridge.js"
|
|
17
|
+
},
|
|
18
|
+
"./router": {
|
|
19
|
+
"types": "./dist/router.d.ts",
|
|
20
|
+
"import": "./dist/router.js",
|
|
21
|
+
"default": "./dist/router.js"
|
|
22
|
+
},
|
|
23
|
+
"./types": {
|
|
24
|
+
"types": "./dist/types.d.ts",
|
|
25
|
+
"import": "./dist/types.js",
|
|
26
|
+
"default": "./dist/types.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
33
|
+
"start": "node dist/index.js",
|
|
34
|
+
"test": "vitest run --passWithNoTests",
|
|
35
|
+
"test:watch": "vitest"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@clawswarm/core": "0.1.0-alpha",
|
|
39
|
+
"eventemitter3": "^5.0.0",
|
|
40
|
+
"ws": "^8.16.0",
|
|
41
|
+
"uuid": "^9.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"@types/ws": "^8.5.10",
|
|
46
|
+
"@types/uuid": "^9.0.0",
|
|
47
|
+
"typescript": "^5.4.0",
|
|
48
|
+
"vitest": "^1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"ai",
|
|
52
|
+
"agents",
|
|
53
|
+
"websocket",
|
|
54
|
+
"bridge",
|
|
55
|
+
"multi-agent",
|
|
56
|
+
"orchestration"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/trietphan/clawswarm.git",
|
|
62
|
+
"directory": "packages/bridge"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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';
|