spiceflow 1.17.12 → 1.18.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.
- package/README.md +167 -2
- package/dist/client/errors.d.ts +2 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +3 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/fetch.d.ts +86 -0
- package/dist/client/fetch.d.ts.map +1 -0
- package/dist/client/fetch.js +143 -0
- package/dist/client/fetch.js.map +1 -0
- package/dist/client/index.d.ts +4 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -176
- package/dist/client/index.js.map +1 -1
- package/dist/client/shared.d.ts +47 -0
- package/dist/client/shared.d.ts.map +1 -0
- package/dist/client/shared.js +314 -0
- package/dist/client/shared.js.map +1 -0
- package/dist/client/types.d.ts +2 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/fetch-client.test.d.ts +2 -0
- package/dist/fetch-client.test.d.ts.map +1 -0
- package/dist/fetch-client.test.js +362 -0
- package/dist/fetch-client.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/spiceflow.d.ts +36 -14
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +49 -16
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +205 -1
- package/dist/spiceflow.test.js.map +1 -1
- package/package.json +3 -3
- package/src/client/errors.ts +3 -0
- package/src/client/fetch.ts +447 -0
- package/src/client/index.ts +19 -229
- package/src/client/shared.ts +406 -0
- package/src/client/types.ts +2 -1
- package/src/fetch-client.test.ts +411 -0
- package/src/index.ts +1 -1
- package/src/spiceflow.test.ts +315 -1
- package/src/spiceflow.ts +106 -32
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// Shared utilities used by both the proxy client and the fetch client
|
|
2
|
+
import superjson from 'superjson'
|
|
3
|
+
import { EventSourceParserStream } from 'eventsource-parser/stream'
|
|
4
|
+
|
|
5
|
+
import type { SpiceflowClient } from './types.ts'
|
|
6
|
+
import { SpiceflowFetchError } from './errors.ts'
|
|
7
|
+
import { parseStringifiedValue } from './utils.ts'
|
|
8
|
+
import type { AnySpiceflow } from '../spiceflow.ts'
|
|
9
|
+
|
|
10
|
+
export const isServer = typeof FileList === 'undefined'
|
|
11
|
+
|
|
12
|
+
export const isFile = (v: any) => {
|
|
13
|
+
if (isServer) return v instanceof Blob
|
|
14
|
+
return v instanceof FileList || v instanceof File
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const hasFile = (obj: Record<string, any>) => {
|
|
18
|
+
if (!obj) return false
|
|
19
|
+
for (const key in obj) {
|
|
20
|
+
if (isFile(obj[key])) return true
|
|
21
|
+
if (Array.isArray(obj[key]) && (obj[key] as unknown[]).find(isFile))
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const createNewFile = (v: File) =>
|
|
28
|
+
isServer
|
|
29
|
+
? v
|
|
30
|
+
: new Promise<File>((resolve) => {
|
|
31
|
+
const reader = new FileReader()
|
|
32
|
+
reader.onload = () => {
|
|
33
|
+
const file = new File([reader.result!], v.name, {
|
|
34
|
+
lastModified: v.lastModified,
|
|
35
|
+
type: v.type,
|
|
36
|
+
})
|
|
37
|
+
resolve(file)
|
|
38
|
+
}
|
|
39
|
+
reader.readAsArrayBuffer(v)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const processHeaders = (
|
|
43
|
+
h: SpiceflowClient.Config['headers'],
|
|
44
|
+
path: string,
|
|
45
|
+
options: RequestInit = {},
|
|
46
|
+
headers: Record<string, string> = {},
|
|
47
|
+
): Record<string, string> => {
|
|
48
|
+
if (Array.isArray(h)) {
|
|
49
|
+
for (const value of h)
|
|
50
|
+
if (!Array.isArray(value))
|
|
51
|
+
headers = processHeaders(value, path, options, headers)
|
|
52
|
+
else {
|
|
53
|
+
const key = value[0]
|
|
54
|
+
if (typeof key === 'string')
|
|
55
|
+
headers[key.toLowerCase()] = value[1] as string
|
|
56
|
+
else
|
|
57
|
+
for (const [k, value] of key)
|
|
58
|
+
headers[k.toLowerCase()] = value as string
|
|
59
|
+
}
|
|
60
|
+
return headers
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!h) return headers
|
|
64
|
+
|
|
65
|
+
switch (typeof h) {
|
|
66
|
+
case 'function':
|
|
67
|
+
if (typeof Headers !== 'undefined' && h instanceof Headers)
|
|
68
|
+
return processHeaders(h, path, options, headers)
|
|
69
|
+
const v = h(path, options)
|
|
70
|
+
if (v) return processHeaders(v, path, options, headers)
|
|
71
|
+
return headers
|
|
72
|
+
|
|
73
|
+
case 'object':
|
|
74
|
+
if (typeof Headers !== 'undefined' && h instanceof Headers) {
|
|
75
|
+
h.forEach((value, key) => {
|
|
76
|
+
headers[key.toLowerCase()] = value
|
|
77
|
+
})
|
|
78
|
+
return headers
|
|
79
|
+
}
|
|
80
|
+
for (const [key, value] of Object.entries(h))
|
|
81
|
+
headers[key.toLowerCase()] = value as string
|
|
82
|
+
return headers
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return headers
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SSEEvent {
|
|
90
|
+
event?: string
|
|
91
|
+
data: any
|
|
92
|
+
id?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class TextDecoderStream extends TransformStream<Uint8Array, string> {
|
|
96
|
+
constructor() {
|
|
97
|
+
const decoder = new TextDecoder('utf-8', {
|
|
98
|
+
fatal: true,
|
|
99
|
+
ignoreBOM: true,
|
|
100
|
+
})
|
|
101
|
+
super({
|
|
102
|
+
transform(
|
|
103
|
+
chunk: Uint8Array,
|
|
104
|
+
controller: TransformStreamDefaultController<string>,
|
|
105
|
+
) {
|
|
106
|
+
const decoded = decoder.decode(chunk, { stream: true })
|
|
107
|
+
if (decoded.length > 0) {
|
|
108
|
+
controller.enqueue(decoded)
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
flush(controller: TransformStreamDefaultController<string>) {
|
|
112
|
+
const output = decoder.decode()
|
|
113
|
+
if (output.length > 0) {
|
|
114
|
+
controller.enqueue(output)
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isAbortError(error: unknown): error is Error {
|
|
122
|
+
return (
|
|
123
|
+
(error instanceof Error || error instanceof DOMException) &&
|
|
124
|
+
(error.name === 'AbortError' ||
|
|
125
|
+
error.name === 'ResponseAborted' || // Next.js
|
|
126
|
+
error.name === 'TimeoutError')
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function* streamSSEResponse({
|
|
131
|
+
response,
|
|
132
|
+
map,
|
|
133
|
+
executeRequest,
|
|
134
|
+
maxRetries = 0,
|
|
135
|
+
}: {
|
|
136
|
+
response: Response
|
|
137
|
+
map: (x: SSEEvent) => any
|
|
138
|
+
executeRequest?: () => Promise<Response>
|
|
139
|
+
maxRetries?: number
|
|
140
|
+
}): AsyncGenerator<SSEEvent> {
|
|
141
|
+
let currentResponse = response
|
|
142
|
+
let retriesLeft = maxRetries
|
|
143
|
+
|
|
144
|
+
while (true) {
|
|
145
|
+
const body = currentResponse.body
|
|
146
|
+
if (!body) return
|
|
147
|
+
|
|
148
|
+
const eventStream = body
|
|
149
|
+
.pipeThrough(new TextDecoderStream())
|
|
150
|
+
.pipeThrough(new EventSourceParserStream())
|
|
151
|
+
|
|
152
|
+
let reader = eventStream.getReader()
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
while (true) {
|
|
156
|
+
const { done, value: event } = await reader.read()
|
|
157
|
+
if (done) return
|
|
158
|
+
|
|
159
|
+
if (event?.event === 'error') {
|
|
160
|
+
const error = superjsonDeserialize(event.data)
|
|
161
|
+
throw new SpiceflowFetchError(500, error)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (event) {
|
|
165
|
+
yield map({ ...event, data: event.data })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (isAbortError(error)) {
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
executeRequest &&
|
|
175
|
+
error instanceof SpiceflowFetchError &&
|
|
176
|
+
error.status >= 500 &&
|
|
177
|
+
retriesLeft > 0
|
|
178
|
+
) {
|
|
179
|
+
retriesLeft--
|
|
180
|
+
const backoffMs = Math.min(
|
|
181
|
+
1000 * 2 ** (maxRetries - retriesLeft - 1),
|
|
182
|
+
10000,
|
|
183
|
+
)
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs))
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
currentResponse = await executeRequest()
|
|
188
|
+
if (currentResponse.status >= 500) {
|
|
189
|
+
if (retriesLeft === 0) {
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
} catch (retryError) {
|
|
195
|
+
if (retriesLeft === 0) {
|
|
196
|
+
throw retryError
|
|
197
|
+
}
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
throw error
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function tryParsingSSEJson(data: string): any {
|
|
208
|
+
try {
|
|
209
|
+
return superjsonDeserialize(JSON.parse(data))
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return data
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function superjsonDeserialize(data: any) {
|
|
216
|
+
if (data?.__superjsonMeta) {
|
|
217
|
+
const { __superjsonMeta, ...rest } = data
|
|
218
|
+
return superjson.deserialize({
|
|
219
|
+
json: rest,
|
|
220
|
+
meta: __superjsonMeta,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
return data
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function buildQueryString(
|
|
227
|
+
query: Record<string, string | string[] | number | boolean | object | undefined | null> | undefined,
|
|
228
|
+
): string {
|
|
229
|
+
if (!query) return ''
|
|
230
|
+
let q = ''
|
|
231
|
+
const append = (key: string, value: string) => {
|
|
232
|
+
q +=
|
|
233
|
+
(q ? '&' : '?') +
|
|
234
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
|
235
|
+
}
|
|
236
|
+
for (const [key, value] of Object.entries(query)) {
|
|
237
|
+
if (value === undefined || value === null) continue
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
for (const v of value) append(key, v)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (typeof value === 'object') {
|
|
243
|
+
append(key, JSON.stringify(value))
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
append(key, `${value}`)
|
|
247
|
+
}
|
|
248
|
+
return q
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function serializeBody({
|
|
252
|
+
body,
|
|
253
|
+
fetchInit,
|
|
254
|
+
isGetOrHead,
|
|
255
|
+
}: {
|
|
256
|
+
body: any
|
|
257
|
+
fetchInit: RequestInit
|
|
258
|
+
isGetOrHead: boolean
|
|
259
|
+
}): Promise<void> {
|
|
260
|
+
if (isGetOrHead) {
|
|
261
|
+
delete fetchInit.body
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (hasFile(body)) {
|
|
266
|
+
const formData = new FormData()
|
|
267
|
+
for (const [key, field] of Object.entries(body)) {
|
|
268
|
+
if (isServer) {
|
|
269
|
+
formData.append(key, field as any)
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (field instanceof File) {
|
|
273
|
+
formData.append(key, await createNewFile(field as any))
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
if (field instanceof FileList) {
|
|
277
|
+
for (let i = 0; i < field.length; i++)
|
|
278
|
+
formData.append(key as any, await createNewFile((field as any)[i]))
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (Array.isArray(field)) {
|
|
282
|
+
for (let i = 0; i < field.length; i++) {
|
|
283
|
+
const value = (field as any)[i]
|
|
284
|
+
formData.append(
|
|
285
|
+
key as any,
|
|
286
|
+
value instanceof File ? await createNewFile(value) : value,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
formData.append(key, field as string)
|
|
292
|
+
}
|
|
293
|
+
fetchInit.body = formData
|
|
294
|
+
} else if (typeof body === 'object' && body !== null) {
|
|
295
|
+
;(fetchInit.headers as Record<string, string>)['content-type'] =
|
|
296
|
+
'application/json'
|
|
297
|
+
fetchInit.body = JSON.stringify(body)
|
|
298
|
+
} else if (body !== undefined && body !== null) {
|
|
299
|
+
;(fetchInit.headers as Record<string, string>)['content-type'] =
|
|
300
|
+
'text/plain'
|
|
301
|
+
fetchInit.body = body
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function parseResponseData({
|
|
306
|
+
response,
|
|
307
|
+
executeRequest,
|
|
308
|
+
retries,
|
|
309
|
+
}: {
|
|
310
|
+
response: Response
|
|
311
|
+
executeRequest: () => Promise<Response>
|
|
312
|
+
retries: number
|
|
313
|
+
}): Promise<{ data: any; error: any }> {
|
|
314
|
+
let data = null as any
|
|
315
|
+
let error = null as any
|
|
316
|
+
|
|
317
|
+
switch (response.headers.get('Content-Type')?.split(';')[0]) {
|
|
318
|
+
case 'text/event-stream':
|
|
319
|
+
data = streamSSEResponse({
|
|
320
|
+
response,
|
|
321
|
+
map: (x) => tryParsingSSEJson(x.data),
|
|
322
|
+
executeRequest,
|
|
323
|
+
maxRetries: retries,
|
|
324
|
+
})
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
case 'application/json':
|
|
328
|
+
data = await response.json()
|
|
329
|
+
data = superjsonDeserialize(data)
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
case 'application/octet-stream':
|
|
333
|
+
data = await response.arrayBuffer()
|
|
334
|
+
break
|
|
335
|
+
|
|
336
|
+
case 'multipart/form-data': {
|
|
337
|
+
const temp = await response.formData()
|
|
338
|
+
data = {}
|
|
339
|
+
temp.forEach((value, key) => {
|
|
340
|
+
data[key] = value
|
|
341
|
+
})
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
default:
|
|
346
|
+
data = await response.text().then(parseStringifiedValue)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (response.status >= 300 || response.status < 200) {
|
|
350
|
+
error = new SpiceflowFetchError(response.status, data || 'Unknown error')
|
|
351
|
+
data = null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { data, error }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function executeWithRetries({
|
|
358
|
+
url,
|
|
359
|
+
fetchInit,
|
|
360
|
+
fetcher,
|
|
361
|
+
instance,
|
|
362
|
+
state,
|
|
363
|
+
retries,
|
|
364
|
+
}: {
|
|
365
|
+
url: string
|
|
366
|
+
fetchInit: RequestInit
|
|
367
|
+
fetcher: typeof fetch
|
|
368
|
+
instance?: AnySpiceflow
|
|
369
|
+
state?: any
|
|
370
|
+
retries: number
|
|
371
|
+
}): Promise<Response> {
|
|
372
|
+
let attempt = 0
|
|
373
|
+
let response!: Response
|
|
374
|
+
let lastError: Error | null = null
|
|
375
|
+
|
|
376
|
+
while (attempt <= retries) {
|
|
377
|
+
try {
|
|
378
|
+
response = await (instance?.handle(new Request(url, fetchInit), {
|
|
379
|
+
state,
|
|
380
|
+
}) ?? fetcher(url, fetchInit))
|
|
381
|
+
|
|
382
|
+
if (response.status < 500 || attempt === retries) {
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
lastError = new Error(
|
|
387
|
+
`Server error: ${response.status} ${response.statusText}`,
|
|
388
|
+
)
|
|
389
|
+
} catch (err) {
|
|
390
|
+
lastError = err as Error
|
|
391
|
+
if (attempt === retries) {
|
|
392
|
+
throw err
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
attempt++
|
|
397
|
+
const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000)
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!response) {
|
|
402
|
+
throw lastError || new Error('Failed to fetch after retries')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return response
|
|
406
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -28,7 +28,7 @@ type And<A extends boolean, B extends boolean> = A extends true
|
|
|
28
28
|
: false
|
|
29
29
|
: false
|
|
30
30
|
|
|
31
|
-
type ReplaceGeneratorWithAsyncGenerator<
|
|
31
|
+
export type ReplaceGeneratorWithAsyncGenerator<
|
|
32
32
|
in out RecordType extends Record<string, unknown>,
|
|
33
33
|
> = {
|
|
34
34
|
[K in keyof RecordType]: RecordType[K] extends any
|
|
@@ -132,6 +132,7 @@ export namespace SpiceflowClient {
|
|
|
132
132
|
export interface Config {
|
|
133
133
|
// fetch?: Omit<RequestInit, 'headers' | 'method'>
|
|
134
134
|
fetch?: typeof fetch
|
|
135
|
+
state?: Record<string, any>
|
|
135
136
|
headers?: MaybeArray<
|
|
136
137
|
| RequestInit['headers']
|
|
137
138
|
| ((path: string, options: RequestInit) => RequestInit['headers'] | void)
|