@agentuity/runtime 1.0.1 → 1.0.3
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/dist/handlers/cron.d.ts +32 -6
- package/dist/handlers/cron.d.ts.map +1 -1
- package/dist/handlers/cron.js +64 -30
- package/dist/handlers/cron.js.map +1 -1
- package/dist/handlers/index.d.ts +2 -1
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +1 -0
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/sse.d.ts +73 -1
- package/dist/handlers/sse.d.ts.map +1 -1
- package/dist/handlers/sse.js +14 -40
- package/dist/handlers/sse.js.map +1 -1
- package/dist/handlers/webrtc.d.ts +49 -0
- package/dist/handlers/webrtc.d.ts.map +1 -0
- package/dist/handlers/webrtc.js +109 -0
- package/dist/handlers/webrtc.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +1 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1 -0
- package/dist/router.js.map +1 -1
- package/dist/webrtc-signaling.d.ts +80 -0
- package/dist/webrtc-signaling.d.ts.map +1 -0
- package/dist/webrtc-signaling.js +237 -0
- package/dist/webrtc-signaling.js.map +1 -0
- package/package.json +7 -7
- package/src/handlers/cron.ts +129 -7
- package/src/handlers/index.ts +2 -0
- package/src/handlers/sse.ts +102 -2
- package/src/handlers/webrtc.ts +125 -0
- package/src/index.ts +14 -1
- package/src/router.ts +1 -0
- package/src/webrtc-signaling.ts +288 -0
package/src/handlers/sse.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import type { Context, Handler } from 'hono';
|
|
2
2
|
import { stream as honoStream } from 'hono/streaming';
|
|
3
3
|
import { context as otelContext, ROOT_CONTEXT } from '@opentelemetry/api';
|
|
4
|
+
import { StructuredError } from '@agentuity/core';
|
|
5
|
+
import type { Schema } from '@agentuity/schema';
|
|
4
6
|
import { getAgentAsyncLocalStorage } from '../_context';
|
|
5
7
|
import type { Env } from '../app';
|
|
6
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Error thrown when sse() is called without a handler function.
|
|
11
|
+
*/
|
|
12
|
+
const SSEHandlerMissingError = StructuredError(
|
|
13
|
+
'SSEHandlerMissingError',
|
|
14
|
+
'An SSE handler function is required. Use sse(handler) or sse({ output: schema }, handler).'
|
|
15
|
+
);
|
|
16
|
+
|
|
7
17
|
/**
|
|
8
18
|
* Context variable key for stream completion promise.
|
|
9
19
|
* Used by middleware to defer session/thread saving until stream completes.
|
|
@@ -60,6 +70,43 @@ export type SSEHandler<E extends Env = Env> = (
|
|
|
60
70
|
stream: SSEStream
|
|
61
71
|
) => void | Promise<void>;
|
|
62
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Options for configuring SSE middleware.
|
|
75
|
+
*
|
|
76
|
+
* @template TOutput - The type of data that will be sent through the SSE stream.
|
|
77
|
+
* This is used for type inference in generated route registries and does not
|
|
78
|
+
* perform runtime validation (SSE data is serialized via JSON.stringify).
|
|
79
|
+
*/
|
|
80
|
+
export interface SSEOptions<TOutput = unknown> {
|
|
81
|
+
/**
|
|
82
|
+
* Schema defining the output type for SSE events.
|
|
83
|
+
*
|
|
84
|
+
* This schema is used for:
|
|
85
|
+
* - Type inference in generated `routes.ts` registry
|
|
86
|
+
* - Automatic typing of `useEventStream` hook's `data` property
|
|
87
|
+
*
|
|
88
|
+
* The schema is NOT used for runtime validation - SSE messages are sent
|
|
89
|
+
* as-is through the stream. Use this for TypeScript type safety only.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* import { s } from '@agentuity/schema';
|
|
94
|
+
*
|
|
95
|
+
* const StreamEventSchema = s.object({
|
|
96
|
+
* type: s.enum(['token', 'complete', 'error']),
|
|
97
|
+
* content: s.optional(s.string()),
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* router.get('/stream', sse({ output: StreamEventSchema }, async (c, stream) => {
|
|
101
|
+
* await stream.writeSSE({ data: JSON.stringify({ type: 'token', content: 'Hello' }) });
|
|
102
|
+
* await stream.writeSSE({ data: JSON.stringify({ type: 'complete' }) });
|
|
103
|
+
* stream.close();
|
|
104
|
+
* }));
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
output: Schema<TOutput, TOutput>;
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
/**
|
|
64
111
|
* Format an SSE message according to the SSE specification.
|
|
65
112
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
|
|
@@ -95,7 +142,7 @@ function formatSSEMessage(message: SSEMessage): string {
|
|
|
95
142
|
*
|
|
96
143
|
* Use with router.get() to create an SSE endpoint:
|
|
97
144
|
*
|
|
98
|
-
* @example
|
|
145
|
+
* @example Basic SSE without typed output
|
|
99
146
|
* ```typescript
|
|
100
147
|
* import { createRouter, sse } from '@agentuity/runtime';
|
|
101
148
|
*
|
|
@@ -120,11 +167,64 @@ function formatSSEMessage(message: SSEMessage): string {
|
|
|
120
167
|
* }));
|
|
121
168
|
* ```
|
|
122
169
|
*
|
|
170
|
+
* @example SSE with typed output schema
|
|
171
|
+
* ```typescript
|
|
172
|
+
* import { createRouter, sse } from '@agentuity/runtime';
|
|
173
|
+
* import { s } from '@agentuity/schema';
|
|
174
|
+
*
|
|
175
|
+
* // Define your SSE event schema
|
|
176
|
+
* export const outputSchema = s.object({
|
|
177
|
+
* type: s.enum(['token', 'complete', 'error']),
|
|
178
|
+
* content: s.optional(s.string()),
|
|
179
|
+
* });
|
|
180
|
+
*
|
|
181
|
+
* const router = createRouter();
|
|
182
|
+
*
|
|
183
|
+
* // Pass schema as first argument for typed SSE routes
|
|
184
|
+
* router.get('/stream', sse({ output: outputSchema }, async (c, stream) => {
|
|
185
|
+
* await stream.writeSSE({ data: JSON.stringify({ type: 'token', content: 'Hello' }) });
|
|
186
|
+
* await stream.writeSSE({ data: JSON.stringify({ type: 'complete' }) });
|
|
187
|
+
* stream.close();
|
|
188
|
+
* }));
|
|
189
|
+
*
|
|
190
|
+
* // On the frontend, useEventStream will now have typed data:
|
|
191
|
+
* // const { data } = useEventStream('/api/stream');
|
|
192
|
+
* // data.type is 'token' | 'complete' | 'error'
|
|
193
|
+
* ```
|
|
194
|
+
*
|
|
123
195
|
* @param handler - Handler function receiving context and SSE stream
|
|
196
|
+
* @param options - Optional configuration with output schema for type inference
|
|
124
197
|
* @returns Hono handler for SSE streaming
|
|
125
198
|
* @see https://github.com/agentuity/sdk/issues/471
|
|
199
|
+
* @see https://github.com/agentuity/sdk/issues/855
|
|
200
|
+
*/
|
|
201
|
+
export function sse<E extends Env = Env>(handler: SSEHandler<E>): Handler<E>;
|
|
202
|
+
/**
|
|
203
|
+
* Creates an SSE middleware with typed output schema.
|
|
204
|
+
*
|
|
205
|
+
* @param options - Configuration object containing the output schema
|
|
206
|
+
* @param handler - Handler function receiving context and SSE stream
|
|
207
|
+
* @returns Hono handler for SSE streaming
|
|
126
208
|
*/
|
|
127
|
-
export function sse<E extends Env = Env
|
|
209
|
+
export function sse<E extends Env = Env, TOutput = unknown>(
|
|
210
|
+
options: SSEOptions<TOutput>,
|
|
211
|
+
handler: SSEHandler<E>
|
|
212
|
+
): Handler<E>;
|
|
213
|
+
export function sse<E extends Env = Env, TOutput = unknown>(
|
|
214
|
+
handlerOrOptions: SSEHandler<E> | SSEOptions<TOutput>,
|
|
215
|
+
maybeHandler?: SSEHandler<E>
|
|
216
|
+
): Handler<E> {
|
|
217
|
+
// Determine if first arg is options or handler
|
|
218
|
+
const handler: SSEHandler<E> | undefined =
|
|
219
|
+
typeof handlerOrOptions === 'function' ? handlerOrOptions : maybeHandler;
|
|
220
|
+
|
|
221
|
+
// Validate handler is provided - catches sse({ output }) without handler
|
|
222
|
+
if (!handler) {
|
|
223
|
+
throw new SSEHandlerMissingError();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Note: options.output is captured for type inference but not used at runtime
|
|
227
|
+
// The CLI extracts this during build to generate typed route registries
|
|
128
228
|
return (c: Context<E>) => {
|
|
129
229
|
const asyncLocalStorage = getAgentAsyncLocalStorage();
|
|
130
230
|
const capturedContext = asyncLocalStorage.getStore();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { upgradeWebSocket } from 'hono/bun';
|
|
3
|
+
import { context as otelContext, ROOT_CONTEXT } from '@opentelemetry/api';
|
|
4
|
+
import { getAgentAsyncLocalStorage } from '../_context';
|
|
5
|
+
import type { Env } from '../app';
|
|
6
|
+
import { WebRTCRoomManager, type WebRTCOptions } from '../webrtc-signaling';
|
|
7
|
+
import type { WebSocketConnection } from './websocket';
|
|
8
|
+
|
|
9
|
+
export type { WebRTCOptions };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handler function for WebRTC signaling connections.
|
|
13
|
+
* Receives the Hono context and WebRTCRoomManager.
|
|
14
|
+
*/
|
|
15
|
+
export type WebRTCHandler<E extends Env = Env> = (
|
|
16
|
+
c: Context<E>,
|
|
17
|
+
roomManager: WebRTCRoomManager
|
|
18
|
+
) => void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a WebRTC signaling middleware for peer-to-peer communication.
|
|
22
|
+
*
|
|
23
|
+
* This middleware sets up a WebSocket-based signaling server that handles:
|
|
24
|
+
* - Room membership and peer discovery
|
|
25
|
+
* - SDP offer/answer relay
|
|
26
|
+
* - ICE candidate relay
|
|
27
|
+
*
|
|
28
|
+
* Use with router.get() to create a WebRTC signaling endpoint:
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { createRouter, webrtc } from '@agentuity/runtime';
|
|
33
|
+
*
|
|
34
|
+
* const router = createRouter();
|
|
35
|
+
*
|
|
36
|
+
* // Basic signaling endpoint
|
|
37
|
+
* router.get('/call/signal', webrtc());
|
|
38
|
+
*
|
|
39
|
+
* // With options
|
|
40
|
+
* router.get('/call/signal', webrtc({ maxPeers: 4 }));
|
|
41
|
+
*
|
|
42
|
+
* // With callbacks for monitoring
|
|
43
|
+
* router.get('/call/signal', webrtc({
|
|
44
|
+
* maxPeers: 2,
|
|
45
|
+
* callbacks: {
|
|
46
|
+
* onRoomCreated: (roomId) => console.log(`Room ${roomId} created`),
|
|
47
|
+
* onPeerJoin: (peerId, roomId) => console.log(`${peerId} joined ${roomId}`),
|
|
48
|
+
* onPeerLeave: (peerId, roomId, reason) => {
|
|
49
|
+
* console.log(`${peerId} left ${roomId}: ${reason}`);
|
|
50
|
+
* },
|
|
51
|
+
* },
|
|
52
|
+
* }));
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @param options - Configuration options for WebRTC signaling
|
|
56
|
+
* @returns Hono middleware handler for WebSocket upgrade
|
|
57
|
+
*/
|
|
58
|
+
export function webrtc<E extends Env = Env>(options?: WebRTCOptions): MiddlewareHandler<E> {
|
|
59
|
+
const roomManager = new WebRTCRoomManager(options);
|
|
60
|
+
|
|
61
|
+
const wsHandler = upgradeWebSocket((_c: Context<E>) => {
|
|
62
|
+
let currentWs: WebSocketConnection | undefined;
|
|
63
|
+
// we need a Privder interface here with AsyncLocalStorage and KV
|
|
64
|
+
const asyncLocalStorage = getAgentAsyncLocalStorage();
|
|
65
|
+
const capturedContext = asyncLocalStorage.getStore();
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
onOpen: (_event: Event, ws: any) => {
|
|
70
|
+
otelContext.with(ROOT_CONTEXT, () => {
|
|
71
|
+
if (capturedContext) {
|
|
72
|
+
asyncLocalStorage.run(capturedContext, () => {
|
|
73
|
+
currentWs = {
|
|
74
|
+
onOpen: () => {},
|
|
75
|
+
onMessage: () => {},
|
|
76
|
+
onClose: () => {},
|
|
77
|
+
send: (data) => ws.send(data),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
currentWs = {
|
|
82
|
+
onOpen: () => {},
|
|
83
|
+
onMessage: () => {},
|
|
84
|
+
onClose: () => {},
|
|
85
|
+
send: (data) => ws.send(data),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
onMessage: (event: MessageEvent, _ws: any) => {
|
|
92
|
+
if (currentWs) {
|
|
93
|
+
otelContext.with(ROOT_CONTEXT, () => {
|
|
94
|
+
if (capturedContext) {
|
|
95
|
+
asyncLocalStorage.run(capturedContext, () => {
|
|
96
|
+
roomManager.handleMessage(currentWs!, String(event.data));
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
roomManager.handleMessage(currentWs!, String(event.data));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
105
|
+
onClose: (_event: CloseEvent, _ws: any) => {
|
|
106
|
+
if (currentWs) {
|
|
107
|
+
otelContext.with(ROOT_CONTEXT, () => {
|
|
108
|
+
if (capturedContext) {
|
|
109
|
+
asyncLocalStorage.run(capturedContext, () => {
|
|
110
|
+
roomManager.handleDisconnect(currentWs!);
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
roomManager.handleDisconnect(currentWs!);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const middleware: MiddlewareHandler<E> = (c, next) =>
|
|
122
|
+
(wsHandler as unknown as MiddlewareHandler<E>)(c, next);
|
|
123
|
+
|
|
124
|
+
return middleware;
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -77,7 +77,7 @@ export { registerDevModeRoutes } from './devmode';
|
|
|
77
77
|
// router.ts exports
|
|
78
78
|
export { type HonoEnv, type WebSocketConnection, createRouter } from './router';
|
|
79
79
|
|
|
80
|
-
// protocol handler exports (websocket, sse, stream, cron)
|
|
80
|
+
// protocol handler exports (websocket, sse, stream, cron, webrtc)
|
|
81
81
|
export {
|
|
82
82
|
websocket,
|
|
83
83
|
type WebSocketHandler,
|
|
@@ -85,13 +85,26 @@ export {
|
|
|
85
85
|
type SSEMessage,
|
|
86
86
|
type SSEStream,
|
|
87
87
|
type SSEHandler,
|
|
88
|
+
type SSEOptions,
|
|
88
89
|
stream,
|
|
89
90
|
type StreamHandler,
|
|
90
91
|
cron,
|
|
91
92
|
type CronHandler,
|
|
92
93
|
type CronMetadata,
|
|
94
|
+
webrtc,
|
|
95
|
+
type WebRTCHandler,
|
|
93
96
|
} from './handlers';
|
|
94
97
|
|
|
98
|
+
// webrtc-signaling.ts exports
|
|
99
|
+
export {
|
|
100
|
+
type SignalMessage,
|
|
101
|
+
type SDPDescription,
|
|
102
|
+
type ICECandidate,
|
|
103
|
+
type WebRTCOptions,
|
|
104
|
+
type WebRTCSignalingCallbacks,
|
|
105
|
+
WebRTCRoomManager,
|
|
106
|
+
} from './webrtc-signaling';
|
|
107
|
+
|
|
95
108
|
// eval.ts exports
|
|
96
109
|
export {
|
|
97
110
|
EvalHandlerResultSchema,
|
package/src/router.ts
CHANGED
|
@@ -83,6 +83,7 @@ declare module 'hono' {
|
|
|
83
83
|
* - **sse()** - Server-Sent Events (import { sse } from '@agentuity/runtime')
|
|
84
84
|
* - **stream()** - Streaming responses (import { stream } from '@agentuity/runtime')
|
|
85
85
|
* - **cron()** - Scheduled tasks (import { cron } from '@agentuity/runtime')
|
|
86
|
+
* - **webrtc()** - WebRTC signaling (import { webrtc } from '@agentuity/runtime')
|
|
86
87
|
*
|
|
87
88
|
* @template E - Environment type (Hono Env)
|
|
88
89
|
* @template S - Schema type for route definitions
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { WebSocketConnection } from './handlers/websocket';
|
|
2
|
+
import type {
|
|
3
|
+
SDPDescription,
|
|
4
|
+
ICECandidate,
|
|
5
|
+
SignalMessage,
|
|
6
|
+
WebRTCSignalingCallbacks,
|
|
7
|
+
} from '@agentuity/core';
|
|
8
|
+
|
|
9
|
+
export type { SDPDescription, ICECandidate, SignalMessage, WebRTCSignalingCallbacks };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Configuration options for WebRTC signaling.
|
|
13
|
+
*/
|
|
14
|
+
export interface WebRTCOptions {
|
|
15
|
+
/** Maximum number of peers per room (default: 2) */
|
|
16
|
+
maxPeers?: number;
|
|
17
|
+
/** Callbacks for signaling events */
|
|
18
|
+
callbacks?: WebRTCSignalingCallbacks;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PeerConnection {
|
|
22
|
+
ws: WebSocketConnection;
|
|
23
|
+
roomId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* In-memory room manager for WebRTC signaling.
|
|
28
|
+
* Tracks rooms and their connected peers.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createRouter, webrtc } from '@agentuity/runtime';
|
|
33
|
+
*
|
|
34
|
+
* const router = createRouter();
|
|
35
|
+
*
|
|
36
|
+
* // Basic usage
|
|
37
|
+
* router.get('/call/signal', webrtc());
|
|
38
|
+
*
|
|
39
|
+
* // With callbacks for monitoring
|
|
40
|
+
* router.get('/call/signal', webrtc({
|
|
41
|
+
* maxPeers: 2,
|
|
42
|
+
* callbacks: {
|
|
43
|
+
* onRoomCreated: (roomId) => console.log(`Room ${roomId} created`),
|
|
44
|
+
* onPeerJoin: (peerId, roomId) => console.log(`${peerId} joined ${roomId}`),
|
|
45
|
+
* onPeerLeave: (peerId, roomId, reason) => {
|
|
46
|
+
* analytics.track('peer_left', { peerId, roomId, reason });
|
|
47
|
+
* },
|
|
48
|
+
* onMessage: (type, from, to, roomId) => {
|
|
49
|
+
* metrics.increment(`webrtc.${type}`);
|
|
50
|
+
* },
|
|
51
|
+
* },
|
|
52
|
+
* }));
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class WebRTCRoomManager {
|
|
56
|
+
// roomId -> Map<peerId, PeerConnection>
|
|
57
|
+
private rooms = new Map<string, Map<string, PeerConnection>>();
|
|
58
|
+
// ws -> peerId (reverse lookup for cleanup)
|
|
59
|
+
private wsToPeer = new Map<WebSocketConnection, { peerId: string; roomId: string }>();
|
|
60
|
+
private maxPeers: number;
|
|
61
|
+
private peerIdCounter = 0;
|
|
62
|
+
private callbacks: WebRTCSignalingCallbacks;
|
|
63
|
+
|
|
64
|
+
constructor(options?: WebRTCOptions) {
|
|
65
|
+
this.maxPeers = options?.maxPeers ?? 2;
|
|
66
|
+
this.callbacks = options?.callbacks ?? {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private generatePeerId(): string {
|
|
70
|
+
return `peer-${Date.now()}-${++this.peerIdCounter}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private send(ws: WebSocketConnection, msg: SignalMessage): void {
|
|
74
|
+
ws.send(JSON.stringify(msg));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private broadcast(roomId: string, msg: SignalMessage, excludePeerId?: string): void {
|
|
78
|
+
const room = this.rooms.get(roomId);
|
|
79
|
+
if (!room) return;
|
|
80
|
+
|
|
81
|
+
for (const [peerId, peer] of room) {
|
|
82
|
+
if (peerId !== excludePeerId) {
|
|
83
|
+
this.send(peer.ws, msg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handle a peer joining a room
|
|
90
|
+
*/
|
|
91
|
+
handleJoin(ws: WebSocketConnection, roomId: string): void {
|
|
92
|
+
let room = this.rooms.get(roomId);
|
|
93
|
+
const isNewRoom = !room;
|
|
94
|
+
|
|
95
|
+
// Create room if it doesn't exist
|
|
96
|
+
if (!room) {
|
|
97
|
+
room = new Map();
|
|
98
|
+
this.rooms.set(roomId, room);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check room capacity
|
|
102
|
+
if (room.size >= this.maxPeers) {
|
|
103
|
+
const error = new Error(`Room is full (max ${this.maxPeers} peers)`);
|
|
104
|
+
this.callbacks.onError?.(error, undefined, roomId);
|
|
105
|
+
this.send(ws, { t: 'error', message: error.message });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const peerId = this.generatePeerId();
|
|
110
|
+
const existingPeers = Array.from(room.keys());
|
|
111
|
+
|
|
112
|
+
// Add peer to room
|
|
113
|
+
room.set(peerId, { ws, roomId });
|
|
114
|
+
this.wsToPeer.set(ws, { peerId, roomId });
|
|
115
|
+
|
|
116
|
+
// Fire callbacks
|
|
117
|
+
if (isNewRoom) {
|
|
118
|
+
this.callbacks.onRoomCreated?.(roomId);
|
|
119
|
+
}
|
|
120
|
+
this.callbacks.onPeerJoin?.(peerId, roomId);
|
|
121
|
+
|
|
122
|
+
// Send joined confirmation with list of existing peers
|
|
123
|
+
this.send(ws, { t: 'joined', peerId, roomId, peers: existingPeers });
|
|
124
|
+
|
|
125
|
+
// Notify existing peers about new peer
|
|
126
|
+
this.broadcast(roomId, { t: 'peer-joined', peerId }, peerId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle a peer disconnecting
|
|
131
|
+
*/
|
|
132
|
+
handleDisconnect(ws: WebSocketConnection): void {
|
|
133
|
+
const peerInfo = this.wsToPeer.get(ws);
|
|
134
|
+
if (!peerInfo) return;
|
|
135
|
+
|
|
136
|
+
const { peerId, roomId } = peerInfo;
|
|
137
|
+
const room = this.rooms.get(roomId);
|
|
138
|
+
|
|
139
|
+
if (room) {
|
|
140
|
+
room.delete(peerId);
|
|
141
|
+
|
|
142
|
+
// Fire callback
|
|
143
|
+
this.callbacks.onPeerLeave?.(peerId, roomId, 'disconnect');
|
|
144
|
+
|
|
145
|
+
// Notify remaining peers
|
|
146
|
+
this.broadcast(roomId, { t: 'peer-left', peerId });
|
|
147
|
+
|
|
148
|
+
// Clean up empty room
|
|
149
|
+
if (room.size === 0) {
|
|
150
|
+
this.rooms.delete(roomId);
|
|
151
|
+
this.callbacks.onRoomDestroyed?.(roomId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.wsToPeer.delete(ws);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Relay SDP message to target peer(s)
|
|
160
|
+
*/
|
|
161
|
+
handleSDP(ws: WebSocketConnection, to: string | undefined, description: SDPDescription): void {
|
|
162
|
+
const peerInfo = this.wsToPeer.get(ws);
|
|
163
|
+
if (!peerInfo) {
|
|
164
|
+
const error = new Error('Not in a room');
|
|
165
|
+
this.callbacks.onError?.(error);
|
|
166
|
+
this.send(ws, { t: 'error', message: error.message });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { peerId, roomId } = peerInfo;
|
|
171
|
+
const room = this.rooms.get(roomId);
|
|
172
|
+
if (!room) return;
|
|
173
|
+
|
|
174
|
+
// Fire callback
|
|
175
|
+
this.callbacks.onMessage?.('sdp', peerId, to, roomId);
|
|
176
|
+
|
|
177
|
+
// Server injects 'from' to prevent spoofing
|
|
178
|
+
const msg: SignalMessage = { t: 'sdp', from: peerId, description };
|
|
179
|
+
|
|
180
|
+
if (to) {
|
|
181
|
+
// Send to specific peer
|
|
182
|
+
const targetPeer = room.get(to);
|
|
183
|
+
if (targetPeer) {
|
|
184
|
+
this.send(targetPeer.ws, msg);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
// Broadcast to all peers in room
|
|
188
|
+
this.broadcast(roomId, msg, peerId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Relay ICE candidate to target peer(s)
|
|
194
|
+
*/
|
|
195
|
+
handleICE(ws: WebSocketConnection, to: string | undefined, candidate: ICECandidate): void {
|
|
196
|
+
const peerInfo = this.wsToPeer.get(ws);
|
|
197
|
+
if (!peerInfo) {
|
|
198
|
+
const error = new Error('Not in a room');
|
|
199
|
+
this.callbacks.onError?.(error);
|
|
200
|
+
this.send(ws, { t: 'error', message: error.message });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { peerId, roomId } = peerInfo;
|
|
205
|
+
const room = this.rooms.get(roomId);
|
|
206
|
+
if (!room) return;
|
|
207
|
+
|
|
208
|
+
// Fire callback
|
|
209
|
+
this.callbacks.onMessage?.('ice', peerId, to, roomId);
|
|
210
|
+
|
|
211
|
+
// Server injects 'from' to prevent spoofing
|
|
212
|
+
const msg: SignalMessage = { t: 'ice', from: peerId, candidate };
|
|
213
|
+
|
|
214
|
+
if (to) {
|
|
215
|
+
// Send to specific peer
|
|
216
|
+
const targetPeer = room.get(to);
|
|
217
|
+
if (targetPeer) {
|
|
218
|
+
this.send(targetPeer.ws, msg);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
// Broadcast to all peers in room
|
|
222
|
+
this.broadcast(roomId, msg, peerId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle incoming signaling message
|
|
228
|
+
*/
|
|
229
|
+
handleMessage(ws: WebSocketConnection, data: string): void {
|
|
230
|
+
let msg: SignalMessage;
|
|
231
|
+
try {
|
|
232
|
+
msg = JSON.parse(data);
|
|
233
|
+
} catch {
|
|
234
|
+
const error = new Error('Invalid JSON');
|
|
235
|
+
this.callbacks.onError?.(error);
|
|
236
|
+
this.send(ws, { t: 'error', message: error.message });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Validate message format
|
|
241
|
+
if (!msg || typeof msg.t !== 'string') {
|
|
242
|
+
const error = new Error('Invalid message format');
|
|
243
|
+
this.callbacks.onError?.(error);
|
|
244
|
+
this.send(ws, { t: 'error', message: error.message });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
switch (msg.t) {
|
|
249
|
+
case 'join':
|
|
250
|
+
if (!msg.roomId || typeof msg.roomId !== 'string') {
|
|
251
|
+
this.send(ws, { t: 'error', message: 'Missing or invalid roomId' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.handleJoin(ws, msg.roomId);
|
|
255
|
+
break;
|
|
256
|
+
case 'sdp':
|
|
257
|
+
if (!msg.description || typeof msg.description !== 'object') {
|
|
258
|
+
this.send(ws, { t: 'error', message: 'Missing or invalid description' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.handleSDP(ws, msg.to, msg.description);
|
|
262
|
+
break;
|
|
263
|
+
case 'ice':
|
|
264
|
+
if (!msg.candidate || typeof msg.candidate !== 'object') {
|
|
265
|
+
this.send(ws, { t: 'error', message: 'Missing or invalid candidate' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.handleICE(ws, msg.to, msg.candidate);
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
this.send(ws, {
|
|
272
|
+
t: 'error',
|
|
273
|
+
message: `Unknown message type: ${(msg as { t: string }).t}`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get room stats for debugging
|
|
280
|
+
*/
|
|
281
|
+
getRoomStats(): { roomCount: number; totalPeers: number } {
|
|
282
|
+
let totalPeers = 0;
|
|
283
|
+
for (const room of this.rooms.values()) {
|
|
284
|
+
totalPeers += room.size;
|
|
285
|
+
}
|
|
286
|
+
return { roomCount: this.rooms.size, totalPeers };
|
|
287
|
+
}
|
|
288
|
+
}
|