@agentuity/server 1.0.14 → 1.0.16
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/api/api.d.ts +6 -0
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +27 -5
- package/dist/api/api.js.map +1 -1
- package/dist/api/queue/index.d.ts +2 -1
- package/dist/api/queue/index.d.ts.map +1 -1
- package/dist/api/queue/index.js +5 -1
- package/dist/api/queue/index.js.map +1 -1
- package/dist/api/queue/types.d.ts +61 -0
- package/dist/api/queue/types.d.ts.map +1 -1
- package/dist/api/queue/types.js +41 -0
- package/dist/api/queue/types.js.map +1 -1
- package/dist/api/queue/websocket.d.ts +144 -0
- package/dist/api/queue/websocket.d.ts.map +1 -0
- package/dist/api/queue/websocket.js +376 -0
- package/dist/api/queue/websocket.js.map +1 -0
- package/dist/api/region/create.d.ts.map +1 -1
- package/dist/api/region/create.js +7 -0
- package/dist/api/region/create.js.map +1 -1
- package/dist/api/sandbox/create.d.ts +1 -0
- package/dist/api/sandbox/create.d.ts.map +1 -1
- package/dist/api/sandbox/create.js +10 -0
- package/dist/api/sandbox/create.js.map +1 -1
- package/dist/api/sandbox/get.d.ts +2 -0
- package/dist/api/sandbox/get.d.ts.map +1 -1
- package/dist/api/sandbox/get.js +5 -0
- package/dist/api/sandbox/get.js.map +1 -1
- package/dist/api/sandbox/index.d.ts +1 -1
- package/dist/api/sandbox/index.d.ts.map +1 -1
- package/dist/api/sandbox/index.js +1 -1
- package/dist/api/sandbox/index.js.map +1 -1
- package/dist/api/sandbox/snapshot-build.d.ts +14 -1
- package/dist/api/sandbox/snapshot-build.d.ts.map +1 -1
- package/dist/api/sandbox/snapshot-build.js +21 -3
- package/dist/api/sandbox/snapshot-build.js.map +1 -1
- package/dist/api/sandbox/snapshot.d.ts +1 -0
- package/dist/api/sandbox/snapshot.d.ts.map +1 -1
- package/dist/api/sandbox/snapshot.js +10 -1
- package/dist/api/sandbox/snapshot.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mime.d.ts +10 -0
- package/dist/util/mime.d.ts.map +1 -0
- package/dist/util/mime.js +102 -0
- package/dist/util/mime.js.map +1 -0
- package/package.json +4 -4
- package/src/api/api.ts +43 -7
- package/src/api/queue/index.ts +19 -0
- package/src/api/queue/types.ts +51 -0
- package/src/api/queue/websocket.ts +488 -0
- package/src/api/region/create.ts +7 -0
- package/src/api/sandbox/create.ts +15 -0
- package/src/api/sandbox/get.ts +5 -0
- package/src/api/sandbox/index.ts +1 -1
- package/src/api/sandbox/snapshot-build.ts +29 -3
- package/src/api/sandbox/snapshot.ts +15 -1
- package/src/index.ts +3 -0
- package/src/util/mime.ts +109 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentuity/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.16",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Agentuity employees and contributors",
|
|
6
6
|
"type": "module",
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"prepublishOnly": "bun run clean && bun run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@agentuity/core": "1.0.
|
|
29
|
-
"@agentuity/schema": "1.0.
|
|
28
|
+
"@agentuity/core": "1.0.16",
|
|
29
|
+
"@agentuity/schema": "1.0.16",
|
|
30
30
|
"zod": "^4.3.5"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@agentuity/test-utils": "1.0.
|
|
33
|
+
"@agentuity/test-utils": "1.0.16",
|
|
34
34
|
"@types/bun": "latest",
|
|
35
35
|
"@types/node": "^22.0.0",
|
|
36
36
|
"bun-types": "latest",
|
package/src/api/api.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface APIClientConfig {
|
|
|
33
33
|
maxRetries?: number;
|
|
34
34
|
retryDelayMs?: number;
|
|
35
35
|
headers?: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Maximum time in milliseconds to keep retrying 502/503 responses.
|
|
38
|
+
* These indicate the service is restarting (hot-swap) and typically
|
|
39
|
+
* resolve within seconds. Defaults to 30000 (30 seconds).
|
|
40
|
+
*/
|
|
41
|
+
serviceUnavailableTimeoutMs?: number;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export const ZodIssuesSchema = z.array(
|
|
@@ -362,6 +368,11 @@ export class APIClient {
|
|
|
362
368
|
|
|
363
369
|
const maxRetries = this.#config?.maxRetries ?? 3;
|
|
364
370
|
const baseDelayMs = this.#config?.retryDelayMs ?? 100;
|
|
371
|
+
const serviceUnavailableTimeoutMs =
|
|
372
|
+
this.#config?.serviceUnavailableTimeoutMs ?? 30_000;
|
|
373
|
+
|
|
374
|
+
// Track when we first see a 502/503 so we can retry for up to the timeout
|
|
375
|
+
let serviceUnavailableStart: number | null = null;
|
|
365
376
|
|
|
366
377
|
const url = `${this.#baseUrl}${endpoint}`;
|
|
367
378
|
const headers: Record<string, string> = {
|
|
@@ -404,7 +415,8 @@ export class APIClient {
|
|
|
404
415
|
|
|
405
416
|
const canRetry = !(body instanceof ReadableStream); // we cannot safely retry a ReadableStream as body
|
|
406
417
|
|
|
407
|
-
|
|
418
|
+
let attempt = 0;
|
|
419
|
+
while (true) {
|
|
408
420
|
try {
|
|
409
421
|
let response: Response;
|
|
410
422
|
|
|
@@ -520,8 +532,34 @@ export class APIClient {
|
|
|
520
532
|
}
|
|
521
533
|
}
|
|
522
534
|
|
|
523
|
-
//
|
|
524
|
-
|
|
535
|
+
// 502/503 indicate the service is restarting (hot-swap) — retry
|
|
536
|
+
// for up to serviceUnavailableTimeoutMs (default 30s) with a
|
|
537
|
+
// slower backoff (1s base) so we survive typical restart windows.
|
|
538
|
+
const isServiceUnavailable =
|
|
539
|
+
response.status === 502 || response.status === 503;
|
|
540
|
+
|
|
541
|
+
if (isServiceUnavailable && canRetry) {
|
|
542
|
+
if (serviceUnavailableStart === null) {
|
|
543
|
+
serviceUnavailableStart = Date.now();
|
|
544
|
+
}
|
|
545
|
+
const elapsed = Date.now() - serviceUnavailableStart;
|
|
546
|
+
if (elapsed < serviceUnavailableTimeoutMs) {
|
|
547
|
+
// Use 1s base delay with exponential backoff, capped at 5s
|
|
548
|
+
const delayMs = Math.min(
|
|
549
|
+
this.#getRetryDelay(attempt, 1000),
|
|
550
|
+
5000
|
|
551
|
+
);
|
|
552
|
+
this.#logger.debug(
|
|
553
|
+
`Got ${response.status} sending to ${url}, service unavailable for ${Math.round(elapsed / 1000)}s, retrying (will delay ${delayMs}ms), sessionId: ${sessionId ?? null}`
|
|
554
|
+
);
|
|
555
|
+
await this.#sleep(delayMs);
|
|
556
|
+
attempt++;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Check if we should retry on specific status codes (409, 501)
|
|
562
|
+
const retryableStatuses = [409, 501];
|
|
525
563
|
if (canRetry && retryableStatuses.includes(response.status) && attempt < maxRetries) {
|
|
526
564
|
let delayMs = this.#getRetryDelay(attempt, baseDelayMs);
|
|
527
565
|
|
|
@@ -548,6 +586,7 @@ export class APIClient {
|
|
|
548
586
|
|
|
549
587
|
this.#logger.debug(`after sleep for ${url}, sessionId: ${sessionId ?? null}`);
|
|
550
588
|
|
|
589
|
+
attempt++;
|
|
551
590
|
continue;
|
|
552
591
|
}
|
|
553
592
|
|
|
@@ -693,16 +732,13 @@ export class APIClient {
|
|
|
693
732
|
error
|
|
694
733
|
);
|
|
695
734
|
await this.#sleep(this.#getRetryDelay(attempt, baseDelayMs));
|
|
735
|
+
attempt++;
|
|
696
736
|
continue;
|
|
697
737
|
}
|
|
698
738
|
|
|
699
739
|
throw error;
|
|
700
740
|
}
|
|
701
741
|
}
|
|
702
|
-
|
|
703
|
-
this.#logger.debug('max retries trying: %s', url);
|
|
704
|
-
|
|
705
|
-
throw new MaxRetriesError();
|
|
706
742
|
}
|
|
707
743
|
|
|
708
744
|
#isRetryableError(error: unknown): boolean {
|
package/src/api/queue/index.ts
CHANGED
|
@@ -130,6 +130,12 @@ export {
|
|
|
130
130
|
UpdateQueueRequestSchema,
|
|
131
131
|
type UpdateSourceRequest,
|
|
132
132
|
UpdateSourceRequestSchema,
|
|
133
|
+
type WebSocketAuthRequest,
|
|
134
|
+
WebSocketAuthRequestSchema,
|
|
135
|
+
type WebSocketAuthResponse,
|
|
136
|
+
WebSocketAuthResponseSchema,
|
|
137
|
+
type WebSocketMessage,
|
|
138
|
+
WebSocketMessageSchema,
|
|
133
139
|
} from './types';
|
|
134
140
|
|
|
135
141
|
// ============================================================================
|
|
@@ -249,6 +255,19 @@ export {
|
|
|
249
255
|
TimeSeriesResponseSchema,
|
|
250
256
|
} from './analytics';
|
|
251
257
|
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// WebSocket Operations
|
|
260
|
+
// ============================================================================
|
|
261
|
+
|
|
262
|
+
export {
|
|
263
|
+
createQueueWebSocket,
|
|
264
|
+
subscribeToQueue,
|
|
265
|
+
type QueueWebSocketOptions,
|
|
266
|
+
type QueueWebSocketConnection,
|
|
267
|
+
type QueueWebSocketState,
|
|
268
|
+
type SubscribeToQueueOptions,
|
|
269
|
+
} from './websocket';
|
|
270
|
+
|
|
252
271
|
// ============================================================================
|
|
253
272
|
// Validation Utilities
|
|
254
273
|
// ============================================================================
|
package/src/api/queue/types.ts
CHANGED
|
@@ -1272,3 +1272,54 @@ export const UpdateSourceRequestSchema = z.object({
|
|
|
1272
1272
|
* Update source request type.
|
|
1273
1273
|
*/
|
|
1274
1274
|
export type UpdateSourceRequest = z.infer<typeof UpdateSourceRequestSchema>;
|
|
1275
|
+
|
|
1276
|
+
// ============================================================================
|
|
1277
|
+
// WebSocket Types
|
|
1278
|
+
// ============================================================================
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* WebSocket authentication request.
|
|
1282
|
+
* This must be the first message sent after the WebSocket connection is established.
|
|
1283
|
+
*/
|
|
1284
|
+
export const WebSocketAuthRequestSchema = z.object({
|
|
1285
|
+
/** The API key for authentication (raw key, not "Bearer ..."). */
|
|
1286
|
+
authorization: z.string(),
|
|
1287
|
+
/** Optional client ID from a previous connection for reconnection. */
|
|
1288
|
+
client_id: z.string().optional(),
|
|
1289
|
+
/** Offset of the last message successfully processed. Server replays from here. */
|
|
1290
|
+
last_offset: z.number().optional(),
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
export type WebSocketAuthRequest = z.infer<typeof WebSocketAuthRequestSchema>;
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* WebSocket authentication response from the server.
|
|
1297
|
+
*/
|
|
1298
|
+
export const WebSocketAuthResponseSchema = z.object({
|
|
1299
|
+
/** Whether authentication was successful. */
|
|
1300
|
+
success: z.boolean(),
|
|
1301
|
+
/** Error message if authentication failed. */
|
|
1302
|
+
error: z.string().optional(),
|
|
1303
|
+
/** The client/subscription ID assigned to this connection. Store and reuse on reconnect. */
|
|
1304
|
+
client_id: z.string().optional(),
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
export type WebSocketAuthResponse = z.infer<typeof WebSocketAuthResponseSchema>;
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* WebSocket message pushed by the server.
|
|
1311
|
+
*
|
|
1312
|
+
* Messages are always delivered as an array. A single live push contains one
|
|
1313
|
+
* element (`type: "message"`), while a replay batch may contain many
|
|
1314
|
+
* (`type: "replay"`).
|
|
1315
|
+
*/
|
|
1316
|
+
export const WebSocketMessageSchema = z.object({
|
|
1317
|
+
/** Message type — "message" for live pushes, "replay" for reconnect replay batches. */
|
|
1318
|
+
type: z.enum(['message', 'replay']),
|
|
1319
|
+
/** Queue ID the messages belong to. */
|
|
1320
|
+
queue_id: z.string(),
|
|
1321
|
+
/** The queue messages. Always an array — single live pushes contain one element. */
|
|
1322
|
+
messages: z.array(MessageSchema),
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
export type WebSocketMessage = z.infer<typeof WebSocketMessageSchema>;
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module websocket
|
|
3
|
+
*
|
|
4
|
+
* WebSocket client for real-time queue message subscriptions.
|
|
5
|
+
*
|
|
6
|
+
* Provides both a callback-based API ({@link createQueueWebSocket}) and an
|
|
7
|
+
* async iterator API ({@link subscribeToQueue}) for receiving messages from
|
|
8
|
+
* a queue in real time via WebSocket.
|
|
9
|
+
*
|
|
10
|
+
* @example Callback-based API
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { createQueueWebSocket } from '@agentuity/server';
|
|
13
|
+
*
|
|
14
|
+
* const connection = createQueueWebSocket({
|
|
15
|
+
* queueName: 'order-processing',
|
|
16
|
+
* baseUrl: 'https://catalyst.agentuity.cloud',
|
|
17
|
+
* onMessage: (message) => {
|
|
18
|
+
* console.log('Received:', message.id, message.payload);
|
|
19
|
+
* },
|
|
20
|
+
* onOpen: () => console.log('Connected'),
|
|
21
|
+
* onClose: (code, reason) => console.log('Closed:', code, reason),
|
|
22
|
+
* onError: (error) => console.error('Error:', error),
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Later: close the connection
|
|
26
|
+
* connection.close();
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example Async iterator API
|
|
30
|
+
* ```typescript
|
|
31
|
+
* import { subscribeToQueue } from '@agentuity/server';
|
|
32
|
+
*
|
|
33
|
+
* const controller = new AbortController();
|
|
34
|
+
* for await (const message of subscribeToQueue({
|
|
35
|
+
* queueName: 'order-processing',
|
|
36
|
+
* baseUrl: 'https://catalyst.agentuity.cloud',
|
|
37
|
+
* signal: controller.signal,
|
|
38
|
+
* })) {
|
|
39
|
+
* console.log('Received:', message.id, message.payload);
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { Message } from './types';
|
|
45
|
+
import { WebSocketAuthResponseSchema, WebSocketMessageSchema } from './types';
|
|
46
|
+
import { QueueError } from './util';
|
|
47
|
+
import { validateQueueName } from './validation';
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Types
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/** Connection state for a queue WebSocket connection. */
|
|
54
|
+
export type QueueWebSocketState =
|
|
55
|
+
| 'connecting'
|
|
56
|
+
| 'authenticating'
|
|
57
|
+
| 'connected'
|
|
58
|
+
| 'reconnecting'
|
|
59
|
+
| 'closed';
|
|
60
|
+
|
|
61
|
+
/** Options for creating a queue WebSocket subscription. */
|
|
62
|
+
export interface QueueWebSocketOptions {
|
|
63
|
+
/** Queue name to subscribe to. */
|
|
64
|
+
queueName: string;
|
|
65
|
+
/** API key for authentication (if not provided, uses AGENTUITY_SDK_KEY env var). */
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
/** Base URL of the catalyst service (e.g., https://catalyst.agentuity.cloud). */
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
/** Called when a message is received. */
|
|
70
|
+
onMessage: (message: Message) => void;
|
|
71
|
+
/** Called when the connection is established and authenticated. */
|
|
72
|
+
onOpen?: () => void;
|
|
73
|
+
/** Called when the connection is closed. */
|
|
74
|
+
onClose?: (code: number, reason: string) => void;
|
|
75
|
+
/** Called when an error occurs. */
|
|
76
|
+
onError?: (error: Error) => void;
|
|
77
|
+
/** Whether to automatically reconnect on disconnection (default: true). */
|
|
78
|
+
autoReconnect?: boolean;
|
|
79
|
+
/** Maximum number of reconnection attempts (default: Infinity). */
|
|
80
|
+
maxReconnectAttempts?: number;
|
|
81
|
+
/** Initial reconnection delay in ms (default: 1000). Uses exponential backoff. */
|
|
82
|
+
reconnectDelayMs?: number;
|
|
83
|
+
/** Maximum reconnection delay in ms (default: 30000). */
|
|
84
|
+
maxReconnectDelayMs?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Return type from {@link createQueueWebSocket}. */
|
|
88
|
+
export interface QueueWebSocketConnection {
|
|
89
|
+
/** Close the WebSocket connection. Disables auto-reconnect. */
|
|
90
|
+
close(): void;
|
|
91
|
+
/** The current connection state. */
|
|
92
|
+
readonly state: QueueWebSocketState;
|
|
93
|
+
/** The client/subscription ID assigned by the server. Stable across reconnections. */
|
|
94
|
+
readonly clientId: string | undefined;
|
|
95
|
+
/** The offset of the last message processed. */
|
|
96
|
+
readonly lastOffset: number | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Options for the async iterator queue subscription. */
|
|
100
|
+
export interface SubscribeToQueueOptions {
|
|
101
|
+
/** Queue name to subscribe to. */
|
|
102
|
+
queueName: string;
|
|
103
|
+
/** API key for authentication (if not provided, uses AGENTUITY_SDK_KEY env var). */
|
|
104
|
+
apiKey?: string;
|
|
105
|
+
/** Base URL of the catalyst service. */
|
|
106
|
+
baseUrl: string;
|
|
107
|
+
/** AbortSignal to stop the subscription. */
|
|
108
|
+
signal?: AbortSignal;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Internal Helpers
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the API key from the options or the AGENTUITY_SDK_KEY environment variable.
|
|
117
|
+
* Throws a {@link QueueError} if no API key is available.
|
|
118
|
+
*/
|
|
119
|
+
function resolveApiKey(apiKey?: string): string {
|
|
120
|
+
const key = apiKey ?? process.env.AGENTUITY_SDK_KEY;
|
|
121
|
+
if (!key) {
|
|
122
|
+
throw new QueueError({
|
|
123
|
+
message:
|
|
124
|
+
'No API key provided. Pass apiKey in options or set the AGENTUITY_SDK_KEY environment variable.',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return key;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Convert an HTTP(S) base URL to a WebSocket URL and append the queue path.
|
|
132
|
+
*
|
|
133
|
+
* The WebSocket route is registered at `/queue/ws/{name}` (not versioned).
|
|
134
|
+
*/
|
|
135
|
+
function buildWebSocketUrl(baseUrl: string, queueName: string): string {
|
|
136
|
+
const wsUrl = baseUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
|
|
137
|
+
// Remove trailing slash if present
|
|
138
|
+
const base = wsUrl.replace(/\/$/, '');
|
|
139
|
+
return `${base}/queue/ws/${encodeURIComponent(queueName)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Callback-based API
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a WebSocket connection to receive real-time messages from a queue.
|
|
148
|
+
*
|
|
149
|
+
* The connection handles authentication, automatic reconnection with exponential
|
|
150
|
+
* backoff, and ping/pong keep-alive (handled automatically by the WebSocket
|
|
151
|
+
* implementation).
|
|
152
|
+
*
|
|
153
|
+
* @param options - Configuration for the WebSocket connection
|
|
154
|
+
* @returns A {@link QueueWebSocketConnection} handle for managing the connection
|
|
155
|
+
* @throws {QueueError} If no API key is available
|
|
156
|
+
* @throws {QueueValidationError} If the queue name is invalid
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```typescript
|
|
160
|
+
* const connection = createQueueWebSocket({
|
|
161
|
+
* queueName: 'order-processing',
|
|
162
|
+
* baseUrl: 'https://catalyst.agentuity.cloud',
|
|
163
|
+
* onMessage: (message) => {
|
|
164
|
+
* console.log('Received:', message.id, message.payload);
|
|
165
|
+
* },
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* // Later: close the connection
|
|
169
|
+
* connection.close();
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export function createQueueWebSocket(options: QueueWebSocketOptions): QueueWebSocketConnection {
|
|
173
|
+
// Validate inputs eagerly so callers get immediate feedback.
|
|
174
|
+
validateQueueName(options.queueName);
|
|
175
|
+
const apiKey = resolveApiKey(options.apiKey);
|
|
176
|
+
|
|
177
|
+
const {
|
|
178
|
+
queueName,
|
|
179
|
+
baseUrl,
|
|
180
|
+
onMessage,
|
|
181
|
+
onOpen,
|
|
182
|
+
onClose,
|
|
183
|
+
onError,
|
|
184
|
+
autoReconnect = true,
|
|
185
|
+
maxReconnectAttempts = Infinity,
|
|
186
|
+
reconnectDelayMs = 1000,
|
|
187
|
+
maxReconnectDelayMs = 30000,
|
|
188
|
+
} = options;
|
|
189
|
+
|
|
190
|
+
let state: QueueWebSocketState = 'connecting';
|
|
191
|
+
let ws: WebSocket | null = null;
|
|
192
|
+
let intentionallyClosed = false;
|
|
193
|
+
let reconnectAttempts = 0;
|
|
194
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
195
|
+
let clientId: string | undefined;
|
|
196
|
+
let lastProcessedOffset: number | undefined;
|
|
197
|
+
|
|
198
|
+
function connect() {
|
|
199
|
+
if (intentionallyClosed) return;
|
|
200
|
+
|
|
201
|
+
const url = buildWebSocketUrl(baseUrl, queueName);
|
|
202
|
+
state = reconnectAttempts > 0 ? 'reconnecting' : 'connecting';
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
ws = new WebSocket(url);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
state = 'closed';
|
|
208
|
+
onError?.(
|
|
209
|
+
new QueueError({
|
|
210
|
+
message: `Failed to create WebSocket connection: ${err instanceof Error ? err.message : String(err)}`,
|
|
211
|
+
queueName,
|
|
212
|
+
cause: err instanceof Error ? err : undefined,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
scheduleReconnect();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ws.onopen = () => {
|
|
220
|
+
state = 'authenticating';
|
|
221
|
+
// Send auth message — raw API key, no "Bearer " prefix.
|
|
222
|
+
// Include client_id and last_offset on reconnect for resumption.
|
|
223
|
+
const authPayload: Record<string, unknown> = { authorization: apiKey };
|
|
224
|
+
if (clientId) {
|
|
225
|
+
authPayload.client_id = clientId;
|
|
226
|
+
}
|
|
227
|
+
if (lastProcessedOffset !== undefined) {
|
|
228
|
+
authPayload.last_offset = lastProcessedOffset;
|
|
229
|
+
}
|
|
230
|
+
ws!.send(JSON.stringify(authPayload));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Whether the auth handshake has completed successfully. */
|
|
234
|
+
let authenticated = false;
|
|
235
|
+
|
|
236
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
237
|
+
const raw = typeof event.data === 'string' ? event.data : String(event.data);
|
|
238
|
+
|
|
239
|
+
if (!authenticated) {
|
|
240
|
+
// First message after open must be the auth response.
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(raw);
|
|
243
|
+
const authResult = WebSocketAuthResponseSchema.safeParse(parsed);
|
|
244
|
+
if (!authResult.success) {
|
|
245
|
+
const err = new QueueError({
|
|
246
|
+
message: `Unexpected auth response from server: ${raw}`,
|
|
247
|
+
queueName,
|
|
248
|
+
});
|
|
249
|
+
onError?.(err);
|
|
250
|
+
ws?.close(4000, 'Invalid auth response');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!authResult.data.success) {
|
|
255
|
+
const err = new QueueError({
|
|
256
|
+
message: `Authentication failed: ${authResult.data.error ?? 'Unknown error'}`,
|
|
257
|
+
queueName,
|
|
258
|
+
});
|
|
259
|
+
onError?.(err);
|
|
260
|
+
// Auth rejection is terminal — do not reconnect with the same bad credentials.
|
|
261
|
+
intentionallyClosed = true;
|
|
262
|
+
ws?.close(4001, 'Auth failed');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
authenticated = true;
|
|
267
|
+
reconnectAttempts = 0; // Reset on successful auth.
|
|
268
|
+
state = 'connected';
|
|
269
|
+
if (authResult.data.client_id) {
|
|
270
|
+
clientId = authResult.data.client_id;
|
|
271
|
+
}
|
|
272
|
+
onOpen?.();
|
|
273
|
+
} catch {
|
|
274
|
+
const err = new QueueError({
|
|
275
|
+
message: `Failed to parse auth response: ${raw}`,
|
|
276
|
+
queueName,
|
|
277
|
+
});
|
|
278
|
+
onError?.(err);
|
|
279
|
+
ws?.close(4000, 'Invalid auth response');
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Normal message after authentication.
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(raw);
|
|
287
|
+
const msgResult = WebSocketMessageSchema.safeParse(parsed);
|
|
288
|
+
if (msgResult.success && msgResult.data.messages.length > 0) {
|
|
289
|
+
for (const msg of msgResult.data.messages) {
|
|
290
|
+
onMessage(msg);
|
|
291
|
+
if (msg.offset !== undefined) {
|
|
292
|
+
lastProcessedOffset = msg.offset;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Non-JSON frames are silently ignored; the server may send
|
|
298
|
+
// ping text frames that are not JSON.
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
ws.onclose = (event: CloseEvent) => {
|
|
303
|
+
state = 'closed';
|
|
304
|
+
ws = null;
|
|
305
|
+
|
|
306
|
+
onClose?.(event.code, event.reason);
|
|
307
|
+
|
|
308
|
+
// Reconnect on any unintentional close — whether we were fully
|
|
309
|
+
// connected, mid-auth, or never authenticated (transient network issue).
|
|
310
|
+
if (!intentionallyClosed) {
|
|
311
|
+
scheduleReconnect();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
ws.onerror = () => {
|
|
316
|
+
// The browser/Node WebSocket fires `error` then `close`.
|
|
317
|
+
// We report the error but let `onclose` handle reconnection.
|
|
318
|
+
onError?.(
|
|
319
|
+
new QueueError({
|
|
320
|
+
message: 'WebSocket connection error',
|
|
321
|
+
queueName,
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function scheduleReconnect() {
|
|
328
|
+
if (intentionallyClosed || !autoReconnect) return;
|
|
329
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
330
|
+
onError?.(
|
|
331
|
+
new QueueError({
|
|
332
|
+
message: `Exceeded maximum reconnection attempts (${maxReconnectAttempts})`,
|
|
333
|
+
queueName,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Exponential backoff with jitter, capped at maxReconnectDelayMs.
|
|
340
|
+
const baseDelay = reconnectDelayMs * Math.pow(2, reconnectAttempts);
|
|
341
|
+
const jitter = 0.5 + Math.random() * 0.5;
|
|
342
|
+
const delay = Math.min(Math.floor(baseDelay * jitter), maxReconnectDelayMs);
|
|
343
|
+
|
|
344
|
+
reconnectAttempts++;
|
|
345
|
+
state = 'reconnecting';
|
|
346
|
+
reconnectTimer = setTimeout(() => {
|
|
347
|
+
reconnectTimer = null;
|
|
348
|
+
connect();
|
|
349
|
+
}, delay);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Kick off the initial connection.
|
|
353
|
+
connect();
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
close() {
|
|
357
|
+
intentionallyClosed = true;
|
|
358
|
+
if (reconnectTimer !== null) {
|
|
359
|
+
clearTimeout(reconnectTimer);
|
|
360
|
+
reconnectTimer = null;
|
|
361
|
+
}
|
|
362
|
+
if (ws) {
|
|
363
|
+
ws.close(1000, 'Client closed');
|
|
364
|
+
ws = null;
|
|
365
|
+
}
|
|
366
|
+
state = 'closed';
|
|
367
|
+
},
|
|
368
|
+
get state() {
|
|
369
|
+
return state;
|
|
370
|
+
},
|
|
371
|
+
get clientId() {
|
|
372
|
+
return clientId;
|
|
373
|
+
},
|
|
374
|
+
get lastOffset() {
|
|
375
|
+
return lastProcessedOffset;
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Async Iterator API
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Subscribe to real-time messages from a queue via WebSocket.
|
|
386
|
+
*
|
|
387
|
+
* Returns an async iterator that yields messages as they arrive.
|
|
388
|
+
* The connection is automatically managed (auth, reconnection, cleanup).
|
|
389
|
+
*
|
|
390
|
+
* @param options - Configuration for the subscription
|
|
391
|
+
* @returns An async generator that yields {@link Message} objects
|
|
392
|
+
* @throws {QueueError} If no API key is available
|
|
393
|
+
* @throws {QueueValidationError} If the queue name is invalid
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```typescript
|
|
397
|
+
* const controller = new AbortController();
|
|
398
|
+
* for await (const message of subscribeToQueue({
|
|
399
|
+
* queueName: 'order-processing',
|
|
400
|
+
* baseUrl: 'https://catalyst.agentuity.cloud',
|
|
401
|
+
* signal: controller.signal,
|
|
402
|
+
* })) {
|
|
403
|
+
* console.log('Received:', message.id, message.payload);
|
|
404
|
+
* }
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
export async function* subscribeToQueue(
|
|
408
|
+
options: SubscribeToQueueOptions,
|
|
409
|
+
): AsyncGenerator<Message, void, unknown> {
|
|
410
|
+
const { signal } = options;
|
|
411
|
+
|
|
412
|
+
// Check if already aborted.
|
|
413
|
+
if (signal?.aborted) return;
|
|
414
|
+
|
|
415
|
+
// A queue for buffering messages between the WebSocket callbacks and the
|
|
416
|
+
// async iterator consumer.
|
|
417
|
+
const buffer: Message[] = [];
|
|
418
|
+
let resolve: (() => void) | null = null;
|
|
419
|
+
let done = false;
|
|
420
|
+
let lastError: Error | null = null;
|
|
421
|
+
|
|
422
|
+
function push(message: Message) {
|
|
423
|
+
buffer.push(message);
|
|
424
|
+
if (resolve) {
|
|
425
|
+
resolve();
|
|
426
|
+
resolve = null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function finish(error?: Error) {
|
|
431
|
+
done = true;
|
|
432
|
+
if (error) lastError = error;
|
|
433
|
+
if (resolve) {
|
|
434
|
+
resolve();
|
|
435
|
+
resolve = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const connection = createQueueWebSocket({
|
|
440
|
+
queueName: options.queueName,
|
|
441
|
+
apiKey: options.apiKey,
|
|
442
|
+
baseUrl: options.baseUrl,
|
|
443
|
+
onMessage: push,
|
|
444
|
+
onError: (err) => finish(err),
|
|
445
|
+
onClose: () => {
|
|
446
|
+
// Only finish if the connection is intentionally closed (signal aborted).
|
|
447
|
+
// Otherwise, the callback-based API handles reconnection.
|
|
448
|
+
},
|
|
449
|
+
autoReconnect: true,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Wire up the abort signal to close the connection.
|
|
453
|
+
const onAbort = () => {
|
|
454
|
+
connection.close();
|
|
455
|
+
finish();
|
|
456
|
+
};
|
|
457
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
while (!done) {
|
|
461
|
+
// Drain buffered messages.
|
|
462
|
+
while (buffer.length > 0) {
|
|
463
|
+
yield buffer.shift()!;
|
|
464
|
+
// Re-check after yield in case signal was aborted.
|
|
465
|
+
if (done || signal?.aborted) return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (done || signal?.aborted) return;
|
|
469
|
+
|
|
470
|
+
// Wait for the next message or completion.
|
|
471
|
+
await new Promise<void>((r) => {
|
|
472
|
+
resolve = r;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Drain any remaining messages.
|
|
477
|
+
while (buffer.length > 0) {
|
|
478
|
+
yield buffer.shift()!;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (lastError) {
|
|
482
|
+
throw lastError;
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
signal?.removeEventListener('abort', onAbort);
|
|
486
|
+
connection.close();
|
|
487
|
+
}
|
|
488
|
+
}
|