@robosystems/client 0.1.10 → 0.1.11
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/extensions/OperationClient.js +297 -0
- package/extensions/OperationClient.ts +322 -0
- package/extensions/QueryClient.js +245 -0
- package/extensions/QueryClient.ts +283 -0
- package/extensions/SSEClient.js +166 -0
- package/extensions/SSEClient.ts +189 -0
- package/extensions/config.js +66 -0
- package/extensions/config.ts +91 -0
- package/extensions/hooks.js +435 -0
- package/extensions/hooks.ts +438 -0
- package/extensions/index.js +118 -0
- package/extensions/index.ts +123 -0
- package/package.json +2 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enhanced Query Client with SSE support
|
|
5
|
+
* Provides intelligent query execution with automatic strategy selection
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { executeCypherQuery } from '../sdk.gen'
|
|
9
|
+
import { ExecuteCypherQueryData } from '../types.gen'
|
|
10
|
+
import { EventType, SSEClient } from './SSEClient'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export class QueryClient {
|
|
21
|
+
private sseClient?
|
|
22
|
+
private config
|
|
23
|
+
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.config = config
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async executeQuery(
|
|
29
|
+
graphId,
|
|
30
|
+
request,
|
|
31
|
+
options = {}
|
|
32
|
+
)> {
|
|
33
|
+
const data = {
|
|
34
|
+
url: '/v1/{graph_id}/query',
|
|
35
|
+
path,
|
|
36
|
+
body,
|
|
37
|
+
query,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Execute the query
|
|
41
|
+
const response = await executeCypherQuery(data)
|
|
42
|
+
const responseData = response.data
|
|
43
|
+
|
|
44
|
+
// Check if this is an immediate response
|
|
45
|
+
if (responseData?.data !== undefined && responseData?.columns) {
|
|
46
|
+
return {
|
|
47
|
+
data.data,
|
|
48
|
+
columns.columns,
|
|
49
|
+
row_count.row_count || responseData.data.length,
|
|
50
|
+
execution_time_ms.execution_time_ms || 0,
|
|
51
|
+
graph_id,
|
|
52
|
+
timestamp.timestamp || new Date().toISOString(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if this is a queued response
|
|
57
|
+
if (responseData?.status === 'queued' && responseData?.operation_id) {
|
|
58
|
+
const queuedResponse = responseData
|
|
59
|
+
|
|
60
|
+
// Notify about queue status
|
|
61
|
+
options.onQueueUpdate?.(queuedResponse.queue_position, queuedResponse.estimated_wait_seconds)
|
|
62
|
+
|
|
63
|
+
// If user doesn't want to wait, throw with queue info
|
|
64
|
+
if (options.maxWait === 0) {
|
|
65
|
+
throw new QueuedQueryError(queuedResponse)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Use SSE to monitor the operation
|
|
69
|
+
if (options.mode === 'stream') {
|
|
70
|
+
return this.streamQueryResults(queuedResponse.operation_id, options)
|
|
71
|
+
} else {
|
|
72
|
+
return this.waitForQueryCompletion(queuedResponse.operation_id, options)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Unexpected response format
|
|
77
|
+
throw new Error('Unexpected response format from query endpoint')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async *streamQueryResults(
|
|
81
|
+
operationId,
|
|
82
|
+
options
|
|
83
|
+
) {
|
|
84
|
+
const buffer = []
|
|
85
|
+
let completed = false
|
|
86
|
+
let error | null = null
|
|
87
|
+
|
|
88
|
+
// Set up SSE connection
|
|
89
|
+
this.sseClient = new SSEClient(this.config)
|
|
90
|
+
await this.sseClient.connect(operationId)
|
|
91
|
+
|
|
92
|
+
// Listen for data chunks
|
|
93
|
+
this.sseClient.on(EventType.DATA_CHUNK, (data) => {
|
|
94
|
+
if (Array.isArray(data.rows)) {
|
|
95
|
+
buffer.push(...data.rows)
|
|
96
|
+
} else if (data.data) {
|
|
97
|
+
buffer.push(...data.data)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Listen for queue updates
|
|
102
|
+
this.sseClient.on(EventType.QUEUE_UPDATE, (data) => {
|
|
103
|
+
options.onQueueUpdate?.(data.position, data.estimated_wait_seconds)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Listen for progress
|
|
107
|
+
this.sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
|
|
108
|
+
options.onProgress?.(data.message)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Listen for completion
|
|
112
|
+
this.sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
|
|
113
|
+
if (data.result?.data) {
|
|
114
|
+
buffer.push(...data.result.data)
|
|
115
|
+
}
|
|
116
|
+
completed = true
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Listen for errors
|
|
120
|
+
this.sseClient.on(EventType.OPERATION_ERROR, (err) => {
|
|
121
|
+
error = new Error(err.message || err.error)
|
|
122
|
+
completed = true
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Yield buffered results
|
|
126
|
+
while (!completed || buffer.length > 0) {
|
|
127
|
+
if (error) throw error
|
|
128
|
+
|
|
129
|
+
if (buffer.length > 0) {
|
|
130
|
+
const chunk = buffer.splice(0, options.chunkSize || 100)
|
|
131
|
+
for (const item of chunk) {
|
|
132
|
+
yield item
|
|
133
|
+
}
|
|
134
|
+
} else if (!completed) {
|
|
135
|
+
// Wait for more data
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.sseClient.close()
|
|
141
|
+
this.sseClient = undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async waitForQueryCompletion(
|
|
145
|
+
operationId,
|
|
146
|
+
options
|
|
147
|
+
) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const sseClient = new SSEClient(this.config)
|
|
150
|
+
|
|
151
|
+
sseClient
|
|
152
|
+
.connect(operationId)
|
|
153
|
+
.then(() => {
|
|
154
|
+
let result | null = null
|
|
155
|
+
|
|
156
|
+
// Listen for queue updates
|
|
157
|
+
sseClient.on(EventType.QUEUE_UPDATE, (data) => {
|
|
158
|
+
options.onQueueUpdate?.(data.position, data.estimated_wait_seconds)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Listen for progress
|
|
162
|
+
sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
|
|
163
|
+
options.onProgress?.(data.message)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
|
|
167
|
+
const queryResult = data.result || data
|
|
168
|
+
result = {
|
|
169
|
+
data.data || [],
|
|
170
|
+
columns.columns || [],
|
|
171
|
+
row_count.row_count || 0,
|
|
172
|
+
execution_time_ms.execution_time_ms || 0,
|
|
173
|
+
graph_id.graph_id,
|
|
174
|
+
timestamp.timestamp || new Date().toISOString(),
|
|
175
|
+
}
|
|
176
|
+
sseClient.close()
|
|
177
|
+
resolve(result)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
sseClient.on(EventType.OPERATION_ERROR, (error) => {
|
|
181
|
+
sseClient.close()
|
|
182
|
+
reject(new Error(error.message || error.error))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
sseClient.on(EventType.OPERATION_CANCELLED, () => {
|
|
186
|
+
sseClient.close()
|
|
187
|
+
reject(new Error('Query cancelled'))
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
.catch(reject)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Convenience method for simple queries
|
|
195
|
+
async query(
|
|
196
|
+
graphId,
|
|
197
|
+
cypher,
|
|
198
|
+
parameters?
|
|
199
|
+
) {
|
|
200
|
+
return this.executeQuery(
|
|
201
|
+
graphId,
|
|
202
|
+
{ query, parameters },
|
|
203
|
+
{ mode }
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Streaming query for large results
|
|
208
|
+
async *streamQuery(
|
|
209
|
+
graphId,
|
|
210
|
+
cypher,
|
|
211
|
+
parameters?,
|
|
212
|
+
chunkSize = 1000
|
|
213
|
+
) {
|
|
214
|
+
const result = await this.executeQuery(
|
|
215
|
+
graphId,
|
|
216
|
+
{ query, parameters },
|
|
217
|
+
{ mode, chunkSize }
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (Symbol.asyncIterator in (result)) {
|
|
221
|
+
yield* result
|
|
222
|
+
} else {
|
|
223
|
+
// If not streaming, yield all results at once
|
|
224
|
+
yield* (result).data
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Cancel any active SSE connections
|
|
229
|
+
close() {
|
|
230
|
+
if (this.sseClient) {
|
|
231
|
+
this.sseClient.close()
|
|
232
|
+
this.sseClient = undefined
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Error thrown when query is queued and maxWait is 0
|
|
239
|
+
*/
|
|
240
|
+
export class QueuedQueryError extends Error {
|
|
241
|
+
constructor(public queueInfo) {
|
|
242
|
+
super('Query was queued')
|
|
243
|
+
this.name = 'QueuedQueryError'
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enhanced Query Client with SSE support
|
|
5
|
+
* Provides intelligent query execution with automatic strategy selection
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { executeCypherQuery } from '../sdk.gen'
|
|
9
|
+
import type { ExecuteCypherQueryData } from '../types.gen'
|
|
10
|
+
import { EventType, SSEClient } from './SSEClient'
|
|
11
|
+
|
|
12
|
+
export interface QueryRequest {
|
|
13
|
+
query: string
|
|
14
|
+
parameters?: Record<string, any>
|
|
15
|
+
timeout?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QueryOptions {
|
|
19
|
+
mode?: 'auto' | 'sync' | 'async' | 'stream'
|
|
20
|
+
chunkSize?: number
|
|
21
|
+
testMode?: boolean
|
|
22
|
+
maxWait?: number
|
|
23
|
+
onQueueUpdate?: (position: number, estimatedWait: number) => void
|
|
24
|
+
onProgress?: (message: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QueryResult {
|
|
28
|
+
data: any[]
|
|
29
|
+
columns: string[]
|
|
30
|
+
row_count: number
|
|
31
|
+
execution_time_ms: number
|
|
32
|
+
graph_id?: string
|
|
33
|
+
timestamp?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QueuedQueryResponse {
|
|
37
|
+
status: 'queued'
|
|
38
|
+
operation_id: string
|
|
39
|
+
queue_position: number
|
|
40
|
+
estimated_wait_seconds: number
|
|
41
|
+
message: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class QueryClient {
|
|
45
|
+
private sseClient?: SSEClient
|
|
46
|
+
private config: {
|
|
47
|
+
baseUrl: string
|
|
48
|
+
credentials?: 'include' | 'same-origin' | 'omit'
|
|
49
|
+
headers?: Record<string, string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
constructor(config: {
|
|
53
|
+
baseUrl: string
|
|
54
|
+
credentials?: 'include' | 'same-origin' | 'omit'
|
|
55
|
+
headers?: Record<string, string>
|
|
56
|
+
}) {
|
|
57
|
+
this.config = config
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async executeQuery(
|
|
61
|
+
graphId: string,
|
|
62
|
+
request: QueryRequest,
|
|
63
|
+
options: QueryOptions = {}
|
|
64
|
+
): Promise<QueryResult | AsyncIterableIterator<any>> {
|
|
65
|
+
const data: ExecuteCypherQueryData = {
|
|
66
|
+
url: '/v1/{graph_id}/query' as const,
|
|
67
|
+
path: { graph_id: graphId },
|
|
68
|
+
body: {
|
|
69
|
+
query: request.query,
|
|
70
|
+
parameters: request.parameters,
|
|
71
|
+
},
|
|
72
|
+
query: {
|
|
73
|
+
mode: options.mode,
|
|
74
|
+
test_mode: options.testMode,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Execute the query
|
|
79
|
+
const response = await executeCypherQuery(data)
|
|
80
|
+
const responseData = response.data as any
|
|
81
|
+
|
|
82
|
+
// Check if this is an immediate response
|
|
83
|
+
if (responseData?.data !== undefined && responseData?.columns) {
|
|
84
|
+
return {
|
|
85
|
+
data: responseData.data,
|
|
86
|
+
columns: responseData.columns,
|
|
87
|
+
row_count: responseData.row_count || responseData.data.length,
|
|
88
|
+
execution_time_ms: responseData.execution_time_ms || 0,
|
|
89
|
+
graph_id: graphId,
|
|
90
|
+
timestamp: responseData.timestamp || new Date().toISOString(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if this is a queued response
|
|
95
|
+
if (responseData?.status === 'queued' && responseData?.operation_id) {
|
|
96
|
+
const queuedResponse = responseData as QueuedQueryResponse
|
|
97
|
+
|
|
98
|
+
// Notify about queue status
|
|
99
|
+
options.onQueueUpdate?.(queuedResponse.queue_position, queuedResponse.estimated_wait_seconds)
|
|
100
|
+
|
|
101
|
+
// If user doesn't want to wait, throw with queue info
|
|
102
|
+
if (options.maxWait === 0) {
|
|
103
|
+
throw new QueuedQueryError(queuedResponse)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Use SSE to monitor the operation
|
|
107
|
+
if (options.mode === 'stream') {
|
|
108
|
+
return this.streamQueryResults(queuedResponse.operation_id, options)
|
|
109
|
+
} else {
|
|
110
|
+
return this.waitForQueryCompletion(queuedResponse.operation_id, options)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Unexpected response format
|
|
115
|
+
throw new Error('Unexpected response format from query endpoint')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async *streamQueryResults(
|
|
119
|
+
operationId: string,
|
|
120
|
+
options: QueryOptions
|
|
121
|
+
): AsyncIterableIterator<any> {
|
|
122
|
+
const buffer: any[] = []
|
|
123
|
+
let completed = false
|
|
124
|
+
let error: Error | null = null
|
|
125
|
+
|
|
126
|
+
// Set up SSE connection
|
|
127
|
+
this.sseClient = new SSEClient(this.config)
|
|
128
|
+
await this.sseClient.connect(operationId)
|
|
129
|
+
|
|
130
|
+
// Listen for data chunks
|
|
131
|
+
this.sseClient.on(EventType.DATA_CHUNK, (data) => {
|
|
132
|
+
if (Array.isArray(data.rows)) {
|
|
133
|
+
buffer.push(...data.rows)
|
|
134
|
+
} else if (data.data) {
|
|
135
|
+
buffer.push(...data.data)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Listen for queue updates
|
|
140
|
+
this.sseClient.on(EventType.QUEUE_UPDATE, (data) => {
|
|
141
|
+
options.onQueueUpdate?.(data.position, data.estimated_wait_seconds)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Listen for progress
|
|
145
|
+
this.sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
|
|
146
|
+
options.onProgress?.(data.message)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Listen for completion
|
|
150
|
+
this.sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
|
|
151
|
+
if (data.result?.data) {
|
|
152
|
+
buffer.push(...data.result.data)
|
|
153
|
+
}
|
|
154
|
+
completed = true
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Listen for errors
|
|
158
|
+
this.sseClient.on(EventType.OPERATION_ERROR, (err) => {
|
|
159
|
+
error = new Error(err.message || err.error)
|
|
160
|
+
completed = true
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Yield buffered results
|
|
164
|
+
while (!completed || buffer.length > 0) {
|
|
165
|
+
if (error) throw error
|
|
166
|
+
|
|
167
|
+
if (buffer.length > 0) {
|
|
168
|
+
const chunk = buffer.splice(0, options.chunkSize || 100)
|
|
169
|
+
for (const item of chunk) {
|
|
170
|
+
yield item
|
|
171
|
+
}
|
|
172
|
+
} else if (!completed) {
|
|
173
|
+
// Wait for more data
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.sseClient.close()
|
|
179
|
+
this.sseClient = undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async waitForQueryCompletion(
|
|
183
|
+
operationId: string,
|
|
184
|
+
options: QueryOptions
|
|
185
|
+
): Promise<QueryResult> {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const sseClient = new SSEClient(this.config)
|
|
188
|
+
|
|
189
|
+
sseClient
|
|
190
|
+
.connect(operationId)
|
|
191
|
+
.then(() => {
|
|
192
|
+
let result: QueryResult | null = null
|
|
193
|
+
|
|
194
|
+
// Listen for queue updates
|
|
195
|
+
sseClient.on(EventType.QUEUE_UPDATE, (data) => {
|
|
196
|
+
options.onQueueUpdate?.(data.position, data.estimated_wait_seconds)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Listen for progress
|
|
200
|
+
sseClient.on(EventType.OPERATION_PROGRESS, (data) => {
|
|
201
|
+
options.onProgress?.(data.message)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
sseClient.on(EventType.OPERATION_COMPLETED, (data) => {
|
|
205
|
+
const queryResult = data.result || data
|
|
206
|
+
result = {
|
|
207
|
+
data: queryResult.data || [],
|
|
208
|
+
columns: queryResult.columns || [],
|
|
209
|
+
row_count: queryResult.row_count || 0,
|
|
210
|
+
execution_time_ms: queryResult.execution_time_ms || 0,
|
|
211
|
+
graph_id: queryResult.graph_id,
|
|
212
|
+
timestamp: queryResult.timestamp || new Date().toISOString(),
|
|
213
|
+
}
|
|
214
|
+
sseClient.close()
|
|
215
|
+
resolve(result)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
sseClient.on(EventType.OPERATION_ERROR, (error) => {
|
|
219
|
+
sseClient.close()
|
|
220
|
+
reject(new Error(error.message || error.error))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
sseClient.on(EventType.OPERATION_CANCELLED, () => {
|
|
224
|
+
sseClient.close()
|
|
225
|
+
reject(new Error('Query cancelled'))
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
.catch(reject)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Convenience method for simple queries
|
|
233
|
+
async query(
|
|
234
|
+
graphId: string,
|
|
235
|
+
cypher: string,
|
|
236
|
+
parameters?: Record<string, any>
|
|
237
|
+
): Promise<QueryResult> {
|
|
238
|
+
return this.executeQuery(
|
|
239
|
+
graphId,
|
|
240
|
+
{ query: cypher, parameters },
|
|
241
|
+
{ mode: 'auto' }
|
|
242
|
+
) as Promise<QueryResult>
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Streaming query for large results
|
|
246
|
+
async *streamQuery(
|
|
247
|
+
graphId: string,
|
|
248
|
+
cypher: string,
|
|
249
|
+
parameters?: Record<string, any>,
|
|
250
|
+
chunkSize: number = 1000
|
|
251
|
+
): AsyncIterableIterator<any> {
|
|
252
|
+
const result = await this.executeQuery(
|
|
253
|
+
graphId,
|
|
254
|
+
{ query: cypher, parameters },
|
|
255
|
+
{ mode: 'stream', chunkSize }
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if (Symbol.asyncIterator in (result as any)) {
|
|
259
|
+
yield* result as AsyncIterableIterator<any>
|
|
260
|
+
} else {
|
|
261
|
+
// If not streaming, yield all results at once
|
|
262
|
+
yield* (result as QueryResult).data
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Cancel any active SSE connections
|
|
267
|
+
close(): void {
|
|
268
|
+
if (this.sseClient) {
|
|
269
|
+
this.sseClient.close()
|
|
270
|
+
this.sseClient = undefined
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Error thrown when query is queued and maxWait is 0
|
|
277
|
+
*/
|
|
278
|
+
export class QueuedQueryError extends Error {
|
|
279
|
+
constructor(public queueInfo: QueuedQueryResponse) {
|
|
280
|
+
super('Query was queued')
|
|
281
|
+
this.name = 'QueuedQueryError'
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Core SSE (Server-Sent Events) client for RoboSystems API
|
|
5
|
+
* Provides automatic reconnection, event replay, and type-safe event handling
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export enum EventType {
|
|
13
|
+
OPERATION_STARTED = 'operation_started',
|
|
14
|
+
OPERATION_PROGRESS = 'operation_progress',
|
|
15
|
+
OPERATION_COMPLETED = 'operation_completed',
|
|
16
|
+
OPERATION_ERROR = 'operation_error',
|
|
17
|
+
OPERATION_CANCELLED = 'operation_cancelled',
|
|
18
|
+
DATA_CHUNK = 'data_chunk',
|
|
19
|
+
METADATA = 'metadata',
|
|
20
|
+
HEARTBEAT = 'heartbeat',
|
|
21
|
+
QUEUE_UPDATE = 'queue_update',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SSEClient {
|
|
25
|
+
private config
|
|
26
|
+
private eventSource?
|
|
27
|
+
private reconnectAttempts = 0
|
|
28
|
+
private lastEventId?
|
|
29
|
+
private closed = false
|
|
30
|
+
private listeners void>> = new Map()
|
|
31
|
+
|
|
32
|
+
constructor(config) {
|
|
33
|
+
this.config = {
|
|
34
|
+
maxRetries,
|
|
35
|
+
retryDelay,
|
|
36
|
+
heartbeatInterval,
|
|
37
|
+
...config,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async connect(operationId, fromSequence = 0) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const url = `${this.config.baseUrl}/v1/operations/${operationId}/stream?from_sequence=${fromSequence}`
|
|
44
|
+
|
|
45
|
+
this.eventSource = new EventSource(url, {
|
|
46
|
+
withCredentials.config.credentials === 'include',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const connectionTimeout = setTimeout(() => {
|
|
50
|
+
reject(new Error('Connection timeout'))
|
|
51
|
+
this.close()
|
|
52
|
+
}, 10000)
|
|
53
|
+
|
|
54
|
+
this.eventSource.onopen = () => {
|
|
55
|
+
clearTimeout(connectionTimeout)
|
|
56
|
+
this.reconnectAttempts = 0
|
|
57
|
+
this.emit('connected', null)
|
|
58
|
+
resolve()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.eventSource.onerror = (error) => {
|
|
62
|
+
clearTimeout(connectionTimeout)
|
|
63
|
+
if (!this.closed) {
|
|
64
|
+
this.handleError(error, operationId, fromSequence)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.eventSource.onmessage = (event) => {
|
|
69
|
+
this.handleMessage(event)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Set up specific event listeners
|
|
73
|
+
Object.values(EventType).forEach((eventType) => {
|
|
74
|
+
this.eventSource!.addEventListener(eventType, (event) => {
|
|
75
|
+
this.handleTypedEvent(eventType, event)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private handleMessage(event) {
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(event.data)
|
|
84
|
+
const sseEvent = {
|
|
85
|
+
event.type || 'message',
|
|
86
|
+
data,
|
|
87
|
+
id.lastEventId,
|
|
88
|
+
timestamp Date(),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.lastEventId = event.lastEventId
|
|
92
|
+
this.emit('event', sseEvent)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.emit('parse_error', { error, rawData.data })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private handleTypedEvent(eventType, event) {
|
|
99
|
+
try {
|
|
100
|
+
const data = JSON.parse(event.data)
|
|
101
|
+
this.lastEventId = event.lastEventId
|
|
102
|
+
this.emit(eventType, data)
|
|
103
|
+
|
|
104
|
+
// Check for completion events
|
|
105
|
+
if (
|
|
106
|
+
eventType === EventType.OPERATION_COMPLETED ||
|
|
107
|
+
eventType === EventType.OPERATION_ERROR ||
|
|
108
|
+
eventType === EventType.OPERATION_CANCELLED
|
|
109
|
+
) {
|
|
110
|
+
this.close()
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.emit('parse_error', { error, rawData.data })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async handleError(error, operationId, fromSequence) {
|
|
118
|
+
if (this.closed) return
|
|
119
|
+
|
|
120
|
+
if (this.reconnectAttempts {
|
|
121
|
+
const resumeFrom = this.lastEventId ? parseInt(this.lastEventId) + 1
|
|
122
|
+
this.connect(operationId, resumeFrom).catch(() => {
|
|
123
|
+
// Error handled in connect
|
|
124
|
+
})
|
|
125
|
+
}, delay)
|
|
126
|
+
} else {
|
|
127
|
+
this.emit('max_retries_exceeded', error)
|
|
128
|
+
this.close()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
on(event, listener) {
|
|
133
|
+
if (!this.listeners.has(event)) {
|
|
134
|
+
this.listeners.set(event, new Set())
|
|
135
|
+
}
|
|
136
|
+
this.listeners.get(event)!.add(listener)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
off(event, listener) {
|
|
140
|
+
const listeners = this.listeners.get(event)
|
|
141
|
+
if (listeners) {
|
|
142
|
+
listeners.delete(listener)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private emit(event, data) {
|
|
147
|
+
const listeners = this.listeners.get(event)
|
|
148
|
+
if (listeners) {
|
|
149
|
+
listeners.forEach((listener) => listener(data))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
close() {
|
|
154
|
+
this.closed = true
|
|
155
|
+
if (this.eventSource) {
|
|
156
|
+
this.eventSource.close()
|
|
157
|
+
this.eventSource = undefined
|
|
158
|
+
}
|
|
159
|
+
this.emit('closed', null)
|
|
160
|
+
this.listeners.clear()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
isConnected() {
|
|
164
|
+
return this.eventSource !== undefined && this.eventSource.readyState === EventSource.OPEN
|
|
165
|
+
}
|
|
166
|
+
}
|