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.
Files changed (47) hide show
  1. package/README.md +167 -2
  2. package/dist/client/errors.d.ts +2 -1
  3. package/dist/client/errors.d.ts.map +1 -1
  4. package/dist/client/errors.js +3 -1
  5. package/dist/client/errors.js.map +1 -1
  6. package/dist/client/fetch.d.ts +86 -0
  7. package/dist/client/fetch.d.ts.map +1 -0
  8. package/dist/client/fetch.js +143 -0
  9. package/dist/client/fetch.js.map +1 -0
  10. package/dist/client/index.d.ts +4 -14
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +3 -176
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/shared.d.ts +47 -0
  15. package/dist/client/shared.d.ts.map +1 -0
  16. package/dist/client/shared.js +314 -0
  17. package/dist/client/shared.js.map +1 -0
  18. package/dist/client/types.d.ts +2 -1
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/fetch-client.test.d.ts +2 -0
  21. package/dist/fetch-client.test.d.ts.map +1 -0
  22. package/dist/fetch-client.test.js +362 -0
  23. package/dist/fetch-client.test.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp.d.ts +1 -1
  29. package/dist/mcp.d.ts.map +1 -1
  30. package/dist/openapi.d.ts +1 -1
  31. package/dist/openapi.d.ts.map +1 -1
  32. package/dist/spiceflow.d.ts +36 -14
  33. package/dist/spiceflow.d.ts.map +1 -1
  34. package/dist/spiceflow.js +49 -16
  35. package/dist/spiceflow.js.map +1 -1
  36. package/dist/spiceflow.test.js +205 -1
  37. package/dist/spiceflow.test.js.map +1 -1
  38. package/package.json +3 -3
  39. package/src/client/errors.ts +3 -0
  40. package/src/client/fetch.ts +447 -0
  41. package/src/client/index.ts +19 -229
  42. package/src/client/shared.ts +406 -0
  43. package/src/client/types.ts +2 -1
  44. package/src/fetch-client.test.ts +411 -0
  45. package/src/index.ts +1 -1
  46. package/src/spiceflow.test.ts +315 -1
  47. 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
+ }
@@ -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)