@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.
@@ -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>(handler: SSEHandler<E>): Handler<E> {
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
+ }