@robosystems/client 1.12.1 → 1.13.0

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.
@@ -5,8 +5,9 @@
5
5
  * Provides intelligent operator execution with automatic strategy selection
6
6
  */
7
7
 
8
- import { autoSelectOperator, executeSpecificOperator } from '../sdk.gen'
8
+ import { autoSelectOperator, executeSpecificOperator, getOperationStatus } from '../sdk.gen'
9
9
  import type { AutoSelectOperatorData, ExecuteSpecificOperatorData } from '../types.gen'
10
+ import type { TokenProvider } from './graphql/client'
10
11
  import { EventType, SSEClient } from './SSEClient'
11
12
 
12
13
  export interface OperatorQueryRequest {
@@ -22,6 +23,12 @@ export interface OperatorOptions {
22
23
  mode?: 'auto' | 'sync' | 'async'
23
24
  maxWait?: number
24
25
  onProgress?: (message: string, percentage?: number) => void
26
+ /**
27
+ * Interval between `/v1/operations/{id}/status` polls while the client
28
+ * follows a queued run it could not open an SSE stream for. Defaults to
29
+ * 2000 ms; only the fallback path uses it.
30
+ */
31
+ pollIntervalMs?: number
25
32
  }
26
33
 
27
34
  export interface OperatorResult {
@@ -37,6 +44,12 @@ export interface OperatorResult {
37
44
  confidence_score?: number
38
45
  execution_time?: number
39
46
  timestamp?: string
47
+ /**
48
+ * Present when the API reports a failed run inside an otherwise successful
49
+ * response (credit pre-flight, operator timeouts, cancelled runs). `content`
50
+ * then carries the explanation rather than an answer.
51
+ */
52
+ error_details?: Record<string, any>
40
53
  }
41
54
 
42
55
  export interface QueuedOperatorResponse {
@@ -46,21 +59,74 @@ export interface QueuedOperatorResponse {
46
59
  sse_endpoint?: string
47
60
  }
48
61
 
62
+ export interface OperatorClientConfig {
63
+ baseUrl: string
64
+ credentials?: 'include' | 'same-origin' | 'omit'
65
+ headers?: Record<string, string>
66
+ /**
67
+ * Static JWT captured at construction. Prefer `tokenProvider` in the
68
+ * browser — see `SSEConfig` for why a captured token goes stale.
69
+ */
70
+ token?: string
71
+ /**
72
+ * Consulted on every SSE connect so a rotated JWT keeps the progress
73
+ * stream authenticated. Wins over `token` when both are set.
74
+ */
75
+ tokenProvider?: TokenProvider
76
+ }
77
+
78
+ const DEFAULT_POLL_INTERVAL_MS = 2000
79
+
80
+ // Consecutive `/status` failures tolerated before the polling fallback gives
81
+ // up: one transient network error must not lose a run that is still going.
82
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3
83
+
84
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
85
+
86
+ const describeError = (err: unknown): string => (err instanceof Error ? err.message : String(err))
87
+
88
+ /**
89
+ * Text for the `error` slot of a generated-client envelope: a thrown `Error`
90
+ * (network failure — the client returns those rather than throwing), an API
91
+ * body with `detail`, or whatever else came back.
92
+ */
93
+ const describeEnvelopeError = (err: unknown): string => {
94
+ if (err instanceof Error) return err.message
95
+ if (err && typeof err === 'object' && 'detail' in err) return String((err as any).detail)
96
+ if (err === undefined || err === null) return 'empty response'
97
+ return typeof err === 'string' ? err : JSON.stringify(err)
98
+ }
99
+
100
+ /**
101
+ * Shape an operator payload — the sync 200 body, the `operation_completed`
102
+ * event, or the `/status` `result` — into the public result type. All three
103
+ * carry the same fields, so one mapper keeps them consistent.
104
+ */
105
+ function toOperatorResult(data: Record<string, any>): OperatorResult {
106
+ const result: OperatorResult = {
107
+ content: data.content ?? '',
108
+ operator_used: data.operator_used || 'unknown',
109
+ mode_used: data.mode_used || 'standard',
110
+ metadata: data.metadata,
111
+ tokens_used: data.tokens_used,
112
+ confidence_score: data.confidence_score,
113
+ execution_time: data.execution_time,
114
+ timestamp: data.timestamp || new Date().toISOString(),
115
+ }
116
+ if (data.error_details) {
117
+ result.error_details = data.error_details
118
+ }
119
+ return result
120
+ }
121
+
122
+ /** Internal: a `/status` verdict that must not be retried. */
123
+ class PollAbort extends Error {}
124
+
49
125
  export class OperatorClient {
50
126
  private sseClient?: SSEClient
51
- private config: {
52
- baseUrl: string
53
- credentials?: 'include' | 'same-origin' | 'omit'
54
- headers?: Record<string, string>
55
- token?: string
56
- }
127
+ private config: OperatorClientConfig
57
128
 
58
- constructor(config: {
59
- baseUrl: string
60
- credentials?: 'include' | 'same-origin' | 'omit'
61
- headers?: Record<string, string>
62
- token?: string
63
- }) {
129
+ constructor(config: OperatorClientConfig) {
64
130
  this.config = config
65
131
  }
66
132
 
@@ -86,37 +152,7 @@ export class OperatorClient {
86
152
  }
87
153
 
88
154
  const response = await autoSelectOperator(data)
89
- const responseData = response.data as any
90
-
91
- // Check if this is an immediate response (sync execution)
92
- if (responseData?.content !== undefined && responseData?.operator_used) {
93
- return {
94
- content: responseData.content,
95
- operator_used: responseData.operator_used,
96
- mode_used: responseData.mode_used,
97
- metadata: responseData.metadata,
98
- tokens_used: responseData.tokens_used,
99
- confidence_score: responseData.confidence_score,
100
- execution_time: responseData.execution_time,
101
- timestamp: new Date().toISOString(),
102
- }
103
- }
104
-
105
- // Check if this is a queued response (async background task execution)
106
- if (responseData?.operation_id) {
107
- const queuedResponse = responseData as QueuedOperatorResponse
108
-
109
- // If user doesn't want to wait, throw with queue info
110
- if (options.maxWait === 0) {
111
- throw new QueuedOperatorError(queuedResponse)
112
- }
113
-
114
- // Use SSE to monitor the operation
115
- return this.waitForOperatorCompletion(queuedResponse.operation_id, options)
116
- }
117
-
118
- // Unexpected response format
119
- throw new Error('Unexpected response format from operator endpoint')
155
+ return this.settle(response.data, options)
120
156
  }
121
157
 
122
158
  /**
@@ -142,23 +178,20 @@ export class OperatorClient {
142
178
  }
143
179
 
144
180
  const response = await executeSpecificOperator(data)
145
- const responseData = response.data as any
181
+ return this.settle(response.data, options)
182
+ }
146
183
 
147
- // Check if this is an immediate response (sync execution)
184
+ /**
185
+ * Resolve an operator endpoint response: a sync 200 body is the result, a
186
+ * 202 with an `operation_id` is followed to completion.
187
+ */
188
+ private async settle(responseData: any, options: OperatorOptions): Promise<OperatorResult> {
189
+ // Immediate response (sync execution)
148
190
  if (responseData?.content !== undefined && responseData?.operator_used) {
149
- return {
150
- content: responseData.content,
151
- operator_used: responseData.operator_used,
152
- mode_used: responseData.mode_used,
153
- metadata: responseData.metadata,
154
- tokens_used: responseData.tokens_used,
155
- confidence_score: responseData.confidence_score,
156
- execution_time: responseData.execution_time,
157
- timestamp: new Date().toISOString(),
158
- }
191
+ return toOperatorResult(responseData)
159
192
  }
160
193
 
161
- // Check if this is a queued response (async background task execution)
194
+ // Queued response (async background task execution)
162
195
  if (responseData?.operation_id) {
163
196
  const queuedResponse = responseData as QueuedOperatorResponse
164
197
 
@@ -167,7 +200,6 @@ export class OperatorClient {
167
200
  throw new QueuedOperatorError(queuedResponse)
168
201
  }
169
202
 
170
- // Use SSE to monitor the operation
171
203
  return this.waitForOperatorCompletion(queuedResponse.operation_id, options)
172
204
  }
173
205
 
@@ -179,128 +211,160 @@ export class OperatorClient {
179
211
  operationId: string,
180
212
  options: OperatorOptions
181
213
  ): Promise<OperatorResult> {
214
+ const sseClient = new SSEClient(this.config)
215
+ this.sseClient = sseClient
216
+
217
+ try {
218
+ await sseClient.connect(operationId)
219
+ } catch (streamError) {
220
+ // The run is already queued and finishes whether or not anyone is
221
+ // watching, so a stream that cannot open — a revoked token, a proxy
222
+ // that will not hold the connection, the per-user connection cap —
223
+ // must not lose the result. Follow the operation over `/status`,
224
+ // which rides the regular REST auth path, instead.
225
+ this.sseClient = undefined
226
+ return this.pollForCompletion(operationId, options, streamError)
227
+ }
228
+
182
229
  return new Promise((resolve, reject) => {
183
- const sseClient = new SSEClient(this.config)
184
-
185
- sseClient
186
- .connect(operationId)
187
- .then(() => {
188
- let result: OperatorResult | null = null
189
-
190
- // Listen for progress events
191
- sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
192
- options.onProgress?.(data.message, data.progress_percentage)
193
- })
194
-
195
- // Listen for agent-specific events
196
- sseClient.on('operator_started' as EventType, (data) => {
197
- options.onProgress?.(`Operator ${data.operator_type} started`, 0)
198
- })
199
-
200
- sseClient.on('operator_initialized' as EventType, (data) => {
201
- options.onProgress?.(`${data.agent_name} initialized`, 10)
202
- })
203
-
204
- sseClient.on('progress' as EventType, (data) => {
205
- options.onProgress?.(data.message, data.percentage)
206
- })
207
-
208
- sseClient.on('operator_completed' as EventType, (data) => {
209
- result = {
210
- content: data.content,
211
- operator_used: data.operator_used,
212
- mode_used: data.mode_used,
213
- metadata: data.metadata,
214
- tokens_used: data.tokens_used,
215
- confidence_score: data.confidence_score,
216
- execution_time: data.execution_time,
217
- timestamp: data.timestamp || new Date().toISOString(),
218
- }
219
- sseClient.close()
220
- resolve(result)
221
- })
222
-
223
- // Fallback to generic completion event
224
- sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
225
- if (!result) {
226
- const operatorResult = data.result || data
227
- result = {
228
- content: operatorResult.content || '',
229
- operator_used: operatorResult.operator_used || 'unknown',
230
- mode_used: operatorResult.mode_used || 'standard',
231
- metadata: operatorResult.metadata,
232
- tokens_used: operatorResult.tokens_used,
233
- confidence_score: operatorResult.confidence_score,
234
- execution_time: operatorResult.execution_time,
235
- timestamp: operatorResult.timestamp || new Date().toISOString(),
236
- }
237
- sseClient.close()
238
- resolve(result)
239
- }
240
- })
241
-
242
- sseClient.on(EventType.OPERATION_ERROR, (error) => {
243
- sseClient.close()
244
- reject(new Error(error.message || error.error))
245
- })
246
-
247
- sseClient.on(EventType.OPERATION_CANCELLED, () => {
248
- sseClient.close()
249
- reject(new Error('Agent execution cancelled'))
250
- })
251
-
252
- // Handle generic error event
253
- sseClient.on('error' as EventType, (error) => {
254
- sseClient.close()
255
- reject(new Error(error.error || error.message || 'Agent execution failed'))
256
- })
257
- })
258
- .catch(reject)
259
- })
260
- }
230
+ let result: OperatorResult | null = null
261
231
 
262
- /**
263
- * Convenience method for simple agent queries with auto-selection
264
- */
265
- async query(
266
- graphId: string,
267
- message: string,
268
- context?: Record<string, any>
269
- ): Promise<OperatorResult> {
270
- return this.executeQuery(graphId, { message, context }, { mode: 'auto' })
271
- }
232
+ const finish = () => {
233
+ sseClient.close()
234
+ if (this.sseClient === sseClient) {
235
+ this.sseClient = undefined
236
+ }
237
+ }
272
238
 
273
- /**
274
- * Execute financial agent for financial analysis
275
- */
276
- async analyzeFinancials(
277
- graphId: string,
278
- message: string,
279
- options: OperatorOptions = {}
280
- ): Promise<OperatorResult> {
281
- return this.executeOperator(graphId, 'financial', { message }, options)
239
+ // Listen for progress events
240
+ sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
241
+ options.onProgress?.(data.message, data.progress_percentage)
242
+ })
243
+
244
+ // Listen for agent-specific events
245
+ sseClient.on('operator_started' as EventType, (data) => {
246
+ options.onProgress?.(`Operator ${data.operator_type} started`, 0)
247
+ })
248
+
249
+ sseClient.on('operator_initialized' as EventType, (data) => {
250
+ options.onProgress?.(`${data.agent_name} initialized`, 10)
251
+ })
252
+
253
+ sseClient.on('progress' as EventType, (data) => {
254
+ options.onProgress?.(data.message, data.percentage)
255
+ })
256
+
257
+ sseClient.on('operator_completed' as EventType, (data) => {
258
+ result = toOperatorResult(data)
259
+ finish()
260
+ resolve(result)
261
+ })
262
+
263
+ // Fallback to generic completion event
264
+ sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
265
+ if (!result) {
266
+ result = toOperatorResult(data.result || data)
267
+ finish()
268
+ resolve(result)
269
+ }
270
+ })
271
+
272
+ sseClient.on(EventType.OPERATION_ERROR, (error) => {
273
+ finish()
274
+ reject(new Error(error.message || error.error))
275
+ })
276
+
277
+ sseClient.on(EventType.OPERATION_CANCELLED, () => {
278
+ finish()
279
+ reject(new Error('Agent execution cancelled'))
280
+ })
281
+
282
+ // Handle generic error event
283
+ sseClient.on('error' as EventType, (error) => {
284
+ finish()
285
+ reject(new Error(error.error || error.message || 'Agent execution failed'))
286
+ })
287
+ })
282
288
  }
283
289
 
284
290
  /**
285
- * Execute research agent for deep research
291
+ * Follow a queued run over `/v1/operations/{id}/status` until it settles.
292
+ * Used when the SSE stream could not be opened; `streamError` is folded
293
+ * into the failure message if polling cannot reach a verdict either.
286
294
  */
287
- async research(
288
- graphId: string,
289
- message: string,
290
- options: OperatorOptions = {}
295
+ private async pollForCompletion(
296
+ operationId: string,
297
+ options: OperatorOptions,
298
+ streamError: unknown
291
299
  ): Promise<OperatorResult> {
292
- return this.executeOperator(graphId, 'research', { message }, options)
300
+ const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
301
+ const streamDetail = describeError(streamError)
302
+ let consecutiveFailures = 0
303
+
304
+ options.onProgress?.('Live progress unavailable — waiting for the result')
305
+
306
+ for (;;) {
307
+ let status: any
308
+ try {
309
+ const response = await getOperationStatus({ path: { operation_id: operationId } })
310
+ if (response.error || !response.data) {
311
+ const httpStatus = response.response?.status
312
+ const detail = describeEnvelopeError(response.error)
313
+ // A definitive 4xx (expired, not ours, unauthenticated) ends the
314
+ // wait; anything else is treated as transient and retried below.
315
+ if (
316
+ httpStatus !== undefined &&
317
+ httpStatus >= 400 &&
318
+ httpStatus < 500 &&
319
+ httpStatus !== 429
320
+ ) {
321
+ throw new PollAbort(
322
+ `Operator stream failed (${streamDetail}); status check failed (${httpStatus}: ${detail})`
323
+ )
324
+ }
325
+ throw new Error(detail)
326
+ }
327
+ status = response.data
328
+ consecutiveFailures = 0
329
+ } catch (pollError) {
330
+ if (pollError instanceof PollAbort) {
331
+ throw new Error(pollError.message)
332
+ }
333
+ consecutiveFailures += 1
334
+ if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
335
+ throw new Error(
336
+ `Operator stream failed (${streamDetail}); status polling failed (${describeError(pollError)})`
337
+ )
338
+ }
339
+ await sleep(interval)
340
+ continue
341
+ }
342
+
343
+ switch (status.status) {
344
+ case 'completed':
345
+ return toOperatorResult(status.result || {})
346
+ case 'failed':
347
+ throw new Error(status.error || status.message || 'Operator run failed')
348
+ case 'cancelled':
349
+ throw new Error('Agent execution cancelled')
350
+ default:
351
+ if (status.message) {
352
+ options.onProgress?.(status.message)
353
+ }
354
+ await sleep(interval)
355
+ }
356
+ }
293
357
  }
294
358
 
295
359
  /**
296
- * Execute RAG agent for fast retrieval
360
+ * Convenience method for simple agent queries with auto-selection
297
361
  */
298
- async rag(
362
+ async query(
299
363
  graphId: string,
300
364
  message: string,
301
- options: OperatorOptions = {}
365
+ context?: Record<string, any>
302
366
  ): Promise<OperatorResult> {
303
- return this.executeOperator(graphId, 'rag', { message }, options)
367
+ return this.executeQuery(graphId, { message, context }, { mode: 'auto' })
304
368
  }
305
369
 
306
370
  /**
@@ -1,3 +1,4 @@
1
+ import type { TokenProvider } from './graphql/client';
1
2
  export interface QueryRequest {
2
3
  query: string;
3
4
  parameters?: Record<string, any>;
@@ -34,6 +35,7 @@ export declare class QueryClient {
34
35
  credentials?: 'include' | 'same-origin' | 'omit';
35
36
  headers?: Record<string, string>;
36
37
  token?: string;
38
+ tokenProvider?: TokenProvider;
37
39
  });
38
40
  executeQuery(graphId: string, request: QueryRequest, options?: QueryOptions): Promise<QueryResult | AsyncIterableIterator<any>>;
39
41
  private parseNDJSONResponse;
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { executeCypher } from '../sdk.gen'
9
9
  import type { ExecuteCypherData } from '../types.gen'
10
+ import type { TokenProvider } from './graphql/client'
10
11
  import { EventType, SSEClient } from './SSEClient'
11
12
 
12
13
  export interface QueryRequest {
@@ -48,6 +49,7 @@ export class QueryClient {
48
49
  credentials?: 'include' | 'same-origin' | 'omit'
49
50
  headers?: Record<string, string>
50
51
  token?: string // JWT token for authentication
52
+ tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
51
53
  }
52
54
 
53
55
  constructor(config: {
@@ -55,6 +57,7 @@ export class QueryClient {
55
57
  credentials?: 'include' | 'same-origin' | 'omit'
56
58
  headers?: Record<string, string>
57
59
  token?: string // JWT token for authentication
60
+ tokenProvider?: TokenProvider // consulted on every SSE connect; wins over `token`
58
61
  }) {
59
62
  this.config = config
60
63
  }
@@ -14,12 +14,30 @@
14
14
  * - Implementing a WebSocket-based alternative
15
15
  * - Using short-lived tokens that expire quickly
16
16
  * - Ensuring all connections use HTTPS
17
+ *
18
+ * CREDENTIAL ROTATION: the stream endpoint authenticates the JWT it finds in
19
+ * the URL, and the backend revokes the previous JWT on every session refresh.
20
+ * A `token` captured once therefore goes dead the moment the session rotates
21
+ * (about every 25 minutes in the browser apps); pass a `tokenProvider` and it
22
+ * is consulted on every connect — including automatic reconnects — instead.
17
23
  */
24
+ import type { TokenProvider } from './graphql/client';
18
25
  export interface SSEConfig {
19
26
  baseUrl: string;
20
27
  credentials?: 'include' | 'same-origin' | 'omit';
21
28
  headers?: Record<string, string>;
29
+ /**
30
+ * Static JWT captured at construction. Fine for long-lived API keys and
31
+ * server-side flows; browser sessions should use `tokenProvider`.
32
+ */
22
33
  token?: string;
34
+ /**
35
+ * Dynamic credential callback, consulted on every `connect()` and
36
+ * preferred over `token` when both are set. Return `null` to connect
37
+ * without a token. See the class documentation for why a static token
38
+ * is not enough in the browser.
39
+ */
40
+ tokenProvider?: TokenProvider;
23
41
  maxRetries?: number;
24
42
  retryDelay?: number;
25
43
  heartbeatInterval?: number;
@@ -51,6 +69,14 @@ export declare class SSEClient {
51
69
  private listeners;
52
70
  constructor(config: SSEConfig);
53
71
  connect(operationId: string, fromSequence?: number): Promise<void>;
72
+ /**
73
+ * Credential for one connect attempt: the `tokenProvider` result when one
74
+ * is configured (`null`/`undefined` connects unauthenticated), otherwise
75
+ * the static `token`. A throwing provider fails the connect — matching the
76
+ * GraphQL client — because the caller intended to authenticate, and a
77
+ * silent unauthenticated attempt would surface as an unrelated-looking 401.
78
+ */
79
+ private resolveToken;
54
80
  private handleMessage;
55
81
  private handleTypedEvent;
56
82
  private handleError;
@@ -27,13 +27,16 @@ class SSEClient {
27
27
  };
28
28
  }
29
29
  async connect(operationId, fromSequence = 0) {
30
+ // Resolved per attempt so a rotated JWT is picked up by every connect,
31
+ // not just the first one after construction.
32
+ const token = await this.resolveToken();
30
33
  return new Promise((resolve, reject) => {
31
34
  let url = `${this.config.baseUrl}/v1/operations/${operationId}/stream?from_sequence=${fromSequence}`;
32
35
  // Add JWT token as query parameter if provided
33
36
  // WARNING: EventSource API doesn't support custom headers, so tokens are passed via query param
34
37
  // This has security implications - see class documentation
35
- if (this.config.token) {
36
- url += `&token=${encodeURIComponent(this.config.token)}`;
38
+ if (token) {
39
+ url += `&token=${encodeURIComponent(token)}`;
37
40
  }
38
41
  this.eventSource = new EventSource(url, {
39
42
  withCredentials: this.config.credentials === 'include',
@@ -83,6 +86,29 @@ class SSEClient {
83
86
  });
84
87
  });
85
88
  }
89
+ /**
90
+ * Credential for one connect attempt: the `tokenProvider` result when one
91
+ * is configured (`null`/`undefined` connects unauthenticated), otherwise
92
+ * the static `token`. A throwing provider fails the connect — matching the
93
+ * GraphQL client — because the caller intended to authenticate, and a
94
+ * silent unauthenticated attempt would surface as an unrelated-looking 401.
95
+ */
96
+ async resolveToken() {
97
+ if (!this.config.tokenProvider) {
98
+ return this.config.token;
99
+ }
100
+ let token;
101
+ try {
102
+ token = await this.config.tokenProvider();
103
+ }
104
+ catch (err) {
105
+ const detail = err instanceof Error ? err.message : String(err);
106
+ throw new Error(`RoboSystems SDK: tokenProvider threw while resolving the SSE credential (${detail}). ` +
107
+ 'Fix the tokenProvider passed in the client config (or via setSDKClientConfig) so it ' +
108
+ 'returns the current token, or null to connect without one.');
109
+ }
110
+ return token || undefined;
111
+ }
86
112
  handleMessage(event) {
87
113
  try {
88
114
  const data = JSON.parse(event.data);
@@ -16,13 +16,32 @@
16
16
  * - Implementing a WebSocket-based alternative
17
17
  * - Using short-lived tokens that expire quickly
18
18
  * - Ensuring all connections use HTTPS
19
+ *
20
+ * CREDENTIAL ROTATION: the stream endpoint authenticates the JWT it finds in
21
+ * the URL, and the backend revokes the previous JWT on every session refresh.
22
+ * A `token` captured once therefore goes dead the moment the session rotates
23
+ * (about every 25 minutes in the browser apps); pass a `tokenProvider` and it
24
+ * is consulted on every connect — including automatic reconnects — instead.
19
25
  */
20
26
 
27
+ import type { TokenProvider } from './graphql/client'
28
+
21
29
  export interface SSEConfig {
22
30
  baseUrl: string
23
31
  credentials?: 'include' | 'same-origin' | 'omit'
24
32
  headers?: Record<string, string>
25
- token?: string // JWT token for authentication
33
+ /**
34
+ * Static JWT captured at construction. Fine for long-lived API keys and
35
+ * server-side flows; browser sessions should use `tokenProvider`.
36
+ */
37
+ token?: string
38
+ /**
39
+ * Dynamic credential callback, consulted on every `connect()` and
40
+ * preferred over `token` when both are set. Return `null` to connect
41
+ * without a token. See the class documentation for why a static token
42
+ * is not enough in the browser.
43
+ */
44
+ tokenProvider?: TokenProvider
26
45
  maxRetries?: number
27
46
  retryDelay?: number
28
47
  heartbeatInterval?: number
@@ -66,14 +85,18 @@ export class SSEClient {
66
85
  }
67
86
 
68
87
  async connect(operationId: string, fromSequence: number = 0): Promise<void> {
88
+ // Resolved per attempt so a rotated JWT is picked up by every connect,
89
+ // not just the first one after construction.
90
+ const token = await this.resolveToken()
91
+
69
92
  return new Promise((resolve, reject) => {
70
93
  let url = `${this.config.baseUrl}/v1/operations/${operationId}/stream?from_sequence=${fromSequence}`
71
94
 
72
95
  // Add JWT token as query parameter if provided
73
96
  // WARNING: EventSource API doesn't support custom headers, so tokens are passed via query param
74
97
  // This has security implications - see class documentation
75
- if (this.config.token) {
76
- url += `&token=${encodeURIComponent(this.config.token)}`
98
+ if (token) {
99
+ url += `&token=${encodeURIComponent(token)}`
77
100
  }
78
101
 
79
102
  this.eventSource = new EventSource(url, {
@@ -135,6 +158,31 @@ export class SSEClient {
135
158
  })
136
159
  }
137
160
 
161
+ /**
162
+ * Credential for one connect attempt: the `tokenProvider` result when one
163
+ * is configured (`null`/`undefined` connects unauthenticated), otherwise
164
+ * the static `token`. A throwing provider fails the connect — matching the
165
+ * GraphQL client — because the caller intended to authenticate, and a
166
+ * silent unauthenticated attempt would surface as an unrelated-looking 401.
167
+ */
168
+ private async resolveToken(): Promise<string | undefined> {
169
+ if (!this.config.tokenProvider) {
170
+ return this.config.token
171
+ }
172
+ let token: string | null | undefined
173
+ try {
174
+ token = await this.config.tokenProvider()
175
+ } catch (err) {
176
+ const detail = err instanceof Error ? err.message : String(err)
177
+ throw new Error(
178
+ `RoboSystems SDK: tokenProvider threw while resolving the SSE credential (${detail}). ` +
179
+ 'Fix the tokenProvider passed in the client config (or via setSDKClientConfig) so it ' +
180
+ 'returns the current token, or null to connect without one.'
181
+ )
182
+ }
183
+ return token || undefined
184
+ }
185
+
138
186
  private handleMessage(event: MessageEvent): void {
139
187
  try {
140
188
  const data = JSON.parse(event.data)