spiceflow 1.17.11 → 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 (57) hide show
  1. package/README.md +168 -3
  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 -9
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +39 -151
  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 +3 -1
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/client.test.js +43 -0
  21. package/dist/client.test.js.map +1 -1
  22. package/dist/fetch-client.test.d.ts +2 -0
  23. package/dist/fetch-client.test.d.ts.map +1 -0
  24. package/dist/fetch-client.test.js +362 -0
  25. package/dist/fetch-client.test.js.map +1 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-client-transport.d.ts.map +1 -1
  31. package/dist/mcp-client-transport.js +5 -2
  32. package/dist/mcp-client-transport.js.map +1 -1
  33. package/dist/mcp.d.ts +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.d.ts.map +1 -1
  37. package/dist/spiceflow.d.ts +36 -14
  38. package/dist/spiceflow.d.ts.map +1 -1
  39. package/dist/spiceflow.js +49 -16
  40. package/dist/spiceflow.js.map +1 -1
  41. package/dist/spiceflow.test.js +205 -1
  42. package/dist/spiceflow.test.js.map +1 -1
  43. package/dist/stream.test.js +1 -1
  44. package/dist/stream.test.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/client/errors.ts +3 -0
  47. package/src/client/fetch.ts +447 -0
  48. package/src/client/index.ts +73 -192
  49. package/src/client/shared.ts +406 -0
  50. package/src/client/types.ts +3 -1
  51. package/src/client.test.ts +52 -0
  52. package/src/fetch-client.test.ts +411 -0
  53. package/src/index.ts +1 -1
  54. package/src/mcp-client-transport.ts +5 -2
  55. package/src/spiceflow.test.ts +315 -1
  56. package/src/spiceflow.ts +106 -32
  57. package/src/stream.test.ts +1 -1
@@ -3,9 +3,11 @@ export class SpiceflowFetchError<
3
3
  Value extends any = any,
4
4
  > extends Error {
5
5
  value: Value
6
+ response?: Response
6
7
  constructor(
7
8
  public status: Status,
8
9
  public passedValue: Value,
10
+ response?: Response,
9
11
  ) {
10
12
  let message = String((passedValue as any)?.message || '')
11
13
  if (!message) {
@@ -17,5 +19,6 @@ export class SpiceflowFetchError<
17
19
  }
18
20
  super(message)
19
21
  this.value = passedValue
22
+ this.response = response
20
23
  }
21
24
  }
@@ -0,0 +1,447 @@
1
+ // Type-safe fetch-like client for Spiceflow. Uses a familiar fetch(path, options)
2
+ // interface instead of the proxy-based chainable API.
3
+ import type { AnySpiceflow, Spiceflow } from '../spiceflow.ts'
4
+ import type { ExtractParamsFromPath } from '../types.ts'
5
+
6
+ import type { SpiceflowClient } from './types.ts'
7
+ import type { ReplaceGeneratorWithAsyncGenerator } from './types.ts'
8
+ import { SpiceflowFetchError } from './errors.ts'
9
+
10
+ import {
11
+ processHeaders,
12
+ buildQueryString,
13
+ serializeBody,
14
+ parseResponseData,
15
+ executeWithRetries,
16
+ } from './shared.ts'
17
+
18
+ // ─── Type utilities ──────────────────────────────────────────────────────────
19
+
20
+ type HttpMethodLower =
21
+ | 'get'
22
+ | 'post'
23
+ | 'put'
24
+ | 'delete'
25
+ | 'patch'
26
+ | 'options'
27
+ | 'head'
28
+ | 'connect'
29
+ | 'subscribe'
30
+
31
+ // Navigate the nested ClientRoutes tree given a path string.
32
+ // Reverses what CreateClient does: `/users/:id` → Routes['users'][':id']
33
+ type NavigateRoutes<Routes, Path extends string> =
34
+ Path extends `/${infer Rest}`
35
+ ? Rest extends ''
36
+ ? 'index' extends keyof Routes
37
+ ? Routes['index']
38
+ : never
39
+ : _NavigateRoutes<Routes, Rest>
40
+ : _NavigateRoutes<Routes, Path>
41
+
42
+ type _NavigateRoutes<Routes, Path extends string> =
43
+ Path extends `${infer Segment}/${infer Rest}`
44
+ ? Segment extends keyof Routes
45
+ ? _NavigateRoutes<Routes[Segment], Rest>
46
+ : never
47
+ : Path extends keyof Routes
48
+ ? Routes[Path]
49
+ : never
50
+
51
+ type RouteAtPath<
52
+ Routes extends Record<string, any>,
53
+ Path extends string,
54
+ > = NavigateRoutes<Routes, Path>
55
+
56
+ type MethodsAtPath<
57
+ Routes extends Record<string, any>,
58
+ Path extends string,
59
+ > = Extract<keyof RouteAtPath<Routes, Path>, HttpMethodLower>
60
+
61
+ type AllowedMethod<
62
+ Routes extends Record<string, any>,
63
+ Path extends string,
64
+ > = Uppercase<MethodsAtPath<Routes, Path>> | MethodsAtPath<Routes, Path>
65
+
66
+ type RouteInfoForMethod<
67
+ Routes extends Record<string, any>,
68
+ Path extends string,
69
+ Method extends string,
70
+ > = Lowercase<Method> extends keyof RouteAtPath<Routes, Path>
71
+ ? RouteAtPath<Routes, Path>[Lowercase<Method>]
72
+ : never
73
+
74
+ // ─── Options type ────────────────────────────────────────────────────────────
75
+
76
+ // Params option: required if path has :params, omitted otherwise
77
+ type ParamsOption<Path extends string> =
78
+ ExtractParamsFromPath<Path> extends undefined
79
+ ? { params?: Record<string, string> }
80
+ : { params: ExtractParamsFromPath<Path> }
81
+
82
+ // Query option: typed from route schema if available
83
+ type QueryOption<
84
+ Routes extends Record<string, any>,
85
+ Path extends string,
86
+ Method extends string,
87
+ > = RouteInfoForMethod<Routes, Path, Method> extends {
88
+ query: infer Q
89
+ }
90
+ ? undefined extends Q
91
+ ? { query?: Record<string, unknown> }
92
+ : { query: Q }
93
+ : { query?: Record<string, unknown> }
94
+
95
+ // Body option: typed from route schema, only for non-GET/HEAD/SUBSCRIBE methods
96
+ type BodyOption<
97
+ Routes extends Record<string, any>,
98
+ Path extends string,
99
+ Method extends string,
100
+ > = Lowercase<Method> extends 'get' | 'head' | 'subscribe'
101
+ ? {}
102
+ : RouteInfoForMethod<Routes, Path, Method> extends {
103
+ request: infer Body
104
+ }
105
+ ? undefined extends Body
106
+ ? { body?: unknown }
107
+ : { body: Body }
108
+ : { body?: unknown }
109
+
110
+ // Check if options has any required fields
111
+ type HasRequiredFields<
112
+ Routes extends Record<string, any>,
113
+ Path extends string,
114
+ Method extends string,
115
+ > =
116
+ // params required?
117
+ ExtractParamsFromPath<Path> extends undefined
118
+ ? // query required?
119
+ RouteInfoForMethod<Routes, Path, Method> extends { query: infer Q }
120
+ ? undefined extends Q
121
+ ? // body required?
122
+ Lowercase<Method> extends 'get' | 'head' | 'subscribe'
123
+ ? false
124
+ : RouteInfoForMethod<Routes, Path, Method> extends {
125
+ request: infer Body
126
+ }
127
+ ? undefined extends Body
128
+ ? false
129
+ : true
130
+ : false
131
+ : true
132
+ : // body required?
133
+ Lowercase<Method> extends 'get' | 'head' | 'subscribe'
134
+ ? false
135
+ : RouteInfoForMethod<Routes, Path, Method> extends {
136
+ request: infer Body
137
+ }
138
+ ? undefined extends Body
139
+ ? false
140
+ : true
141
+ : false
142
+ : true
143
+
144
+ type FetchOptionsTyped<
145
+ Routes extends Record<string, any>,
146
+ Path extends string,
147
+ Method extends string,
148
+ > = {
149
+ method?: Method
150
+ headers?: RequestInit['headers']
151
+ signal?: AbortSignal
152
+ } & ParamsOption<Path> &
153
+ QueryOption<Routes, Path, Method> &
154
+ BodyOption<Routes, Path, Method>
155
+
156
+ type FetchOptionsFallback = {
157
+ method?: string
158
+ body?: BodyInit | Record<string, unknown> | null
159
+ query?: Record<string, unknown>
160
+ params?: Record<string, string>
161
+ headers?: RequestInit['headers']
162
+ signal?: AbortSignal
163
+ [key: string]: unknown
164
+ }
165
+
166
+ type FetchOptions<
167
+ Routes extends Record<string, any>,
168
+ Path extends string,
169
+ Method extends string,
170
+ > = [RouteAtPath<Routes, Path>] extends [never]
171
+ ? FetchOptionsFallback
172
+ : FetchOptionsTyped<Routes, Path, Method>
173
+
174
+ // ─── Response type ───────────────────────────────────────────────────────────
175
+
176
+ type FetchResultData<
177
+ Routes extends Record<string, any>,
178
+ Path extends string,
179
+ Method extends string,
180
+ > = [RouteAtPath<Routes, Path>] extends [never]
181
+ ? any
182
+ : RouteInfoForMethod<Routes, Path, Method> extends {
183
+ response: infer Res extends Record<number, unknown>
184
+ }
185
+ ? ReplaceGeneratorWithAsyncGenerator<Res>[200]
186
+ : any
187
+
188
+ type FetchResultError<
189
+ Routes extends Record<string, any>,
190
+ Path extends string,
191
+ Method extends string,
192
+ > = [RouteAtPath<Routes, Path>] extends [never]
193
+ ? SpiceflowFetchError<number, any>
194
+ : RouteInfoForMethod<Routes, Path, Method> extends {
195
+ response: infer Res extends Record<number, unknown>
196
+ }
197
+ ? Exclude<keyof Res, 200> extends never
198
+ ? SpiceflowFetchError<number, any>
199
+ : {
200
+ [Status in keyof Res]: SpiceflowFetchError<Status, Res[Status]>
201
+ }[Exclude<keyof Res, 200>]
202
+ : SpiceflowFetchError<number, any>
203
+
204
+ type FetchResult<
205
+ Routes extends Record<string, any>,
206
+ Path extends string,
207
+ Method extends string,
208
+ > = FetchResultError<Routes, Path, Method> | FetchResultData<Routes, Path, Method>
209
+
210
+ // ─── Public type ─────────────────────────────────────────────────────────────
211
+
212
+ // Resolves options for a given App/Path/Method combination.
213
+ // Returns the appropriate options type, or FetchOptionsFallback for unknown paths.
214
+ type ResolveOptions<
215
+ App extends AnySpiceflow,
216
+ Path extends string,
217
+ Method extends string,
218
+ > = App extends {
219
+ _types: { ClientRoutes: infer Routes extends Record<string, any> }
220
+ }
221
+ ? FetchOptions<Routes, Path, Method>
222
+ : FetchOptionsFallback
223
+
224
+ // Resolves the result type for a given App/Path/Method combination.
225
+ type ResolveResult<
226
+ App extends AnySpiceflow,
227
+ Path extends string,
228
+ Method extends string,
229
+ > = App extends {
230
+ _types: { ClientRoutes: infer Routes extends Record<string, any> }
231
+ }
232
+ ? FetchResult<Routes, Path, Method>
233
+ : SpiceflowFetchError<number, any> | any
234
+
235
+ // Check if options are required for a given App/Path/Method
236
+ type IsOptionsRequired<
237
+ App extends AnySpiceflow,
238
+ Path extends string,
239
+ Method extends string,
240
+ > = App extends {
241
+ _types: { ClientRoutes: infer Routes extends Record<string, any> }
242
+ }
243
+ ? [RouteAtPath<Routes, Path>] extends [never]
244
+ ? false
245
+ : HasRequiredFields<Routes, Path, Method>
246
+ : false
247
+
248
+ export interface SpiceflowFetch<App extends AnySpiceflow> {
249
+ // Overload: options required when route demands params/query/body
250
+ <const Path extends string, const Method extends string = 'GET'>(
251
+ ...args: IsOptionsRequired<App, Path, Method> extends true
252
+ ? [path: Path, options: ResolveOptions<App, Path, Method>]
253
+ : [path: Path, options?: ResolveOptions<App, Path, Method>]
254
+ ): Promise<ResolveResult<App, Path, Method>>
255
+ }
256
+
257
+ // ─── Factory ─────────────────────────────────────────────────────────────────
258
+
259
+ export function createSpiceflowFetch<const App extends AnySpiceflow>(
260
+ domain: App | string,
261
+ config: SpiceflowClient.Config &
262
+ (App extends Spiceflow<any, any, infer Singleton, any, any, any, any>
263
+ ? { state?: Singleton['state'] }
264
+ : {}) = {} as any,
265
+ ): SpiceflowFetch<App> {
266
+ let baseUrl: string
267
+ let instance: AnySpiceflow | undefined
268
+
269
+ if (typeof domain === 'string') {
270
+ baseUrl = domain.endsWith('/') ? domain.slice(0, -1) : domain
271
+ } else {
272
+ baseUrl = 'http://e.ly'
273
+ instance = domain
274
+
275
+ if (typeof window !== 'undefined') {
276
+ console.warn(
277
+ 'Spiceflow instance server found on client side, this is not recommended for security reason. Use generic type instead.',
278
+ )
279
+ }
280
+ }
281
+
282
+ if ((config as any).state && !instance) {
283
+ throw new Error('State is only available when using a Spiceflow instance')
284
+ }
285
+
286
+ const spiceflowFetch = async (
287
+ path: string,
288
+ options: any = {},
289
+ ): Promise<any> => {
290
+ let {
291
+ fetch: fetcher = fetch,
292
+ headers: configHeaders,
293
+ onRequest,
294
+ onResponse,
295
+ retries = 0,
296
+ } = config as SpiceflowClient.Config
297
+
298
+ const {
299
+ method: rawMethod = 'GET',
300
+ body,
301
+ query,
302
+ params,
303
+ headers: optionHeaders,
304
+ signal,
305
+ ...restInit
306
+ } = options
307
+
308
+ const methodUpper = rawMethod.toUpperCase()
309
+ const isGetOrHead =
310
+ methodUpper === 'GET' ||
311
+ methodUpper === 'HEAD' ||
312
+ methodUpper === 'SUBSCRIBE'
313
+
314
+ // Resolve path params (replace :param with values)
315
+ // Sort by key length descending to avoid :id replacing inside :id2
316
+ let resolvedPath = path
317
+ if (params && typeof params === 'object') {
318
+ const entries = Object.entries(params).sort(
319
+ ([a], [b]) => b.length - a.length,
320
+ )
321
+ for (const [key, value] of entries) {
322
+ if (key === '*') {
323
+ resolvedPath = resolvedPath.split('*').join(String(value))
324
+ } else {
325
+ resolvedPath = resolvedPath.split(`:${key}`).join(String(value))
326
+ }
327
+ }
328
+ }
329
+
330
+ const queryString = buildQueryString(query)
331
+
332
+ // Support absolute URLs — skip baseUrl concatenation
333
+ const isAbsoluteUrl = /^https?:\/\//i.test(resolvedPath)
334
+ const url = isAbsoluteUrl
335
+ ? resolvedPath + queryString
336
+ : baseUrl + resolvedPath + queryString
337
+
338
+ let headers = processHeaders(configHeaders, resolvedPath, {
339
+ method: methodUpper,
340
+ signal,
341
+ })
342
+ headers = {
343
+ ...headers,
344
+ ...processHeaders(optionHeaders, resolvedPath, {
345
+ method: methodUpper,
346
+ signal,
347
+ }),
348
+ }
349
+
350
+ let fetchInit: RequestInit = {
351
+ method: methodUpper,
352
+ headers,
353
+ signal,
354
+ ...restInit,
355
+ }
356
+
357
+ // Apply onRequest hooks (first pass, before body serialization)
358
+ if (onRequest) {
359
+ const hooks = Array.isArray(onRequest) ? onRequest : [onRequest]
360
+ for (const hook of hooks) {
361
+ const temp = await hook(resolvedPath, fetchInit)
362
+ if (typeof temp === 'object') {
363
+ fetchInit = {
364
+ ...fetchInit,
365
+ ...temp,
366
+ headers: {
367
+ ...fetchInit.headers,
368
+ ...processHeaders(temp.headers, resolvedPath, fetchInit),
369
+ },
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Ensure GET/HEAD has no body before serialization
376
+ if (isGetOrHead) delete fetchInit.body
377
+
378
+ // Serialize body
379
+ if (!isGetOrHead && body !== undefined) {
380
+ fetchInit.body = body
381
+ await serializeBody({ body, fetchInit, isGetOrHead })
382
+ }
383
+
384
+ if (isGetOrHead) {
385
+ delete fetchInit.body
386
+ }
387
+
388
+ // Add x-spiceflow-agent header
389
+ ;(fetchInit.headers as Record<string, string>)['x-spiceflow-agent'] =
390
+ 'spiceflow-client'
391
+
392
+ // Apply onRequest hooks (second pass, after body serialization — matches proxy client behavior)
393
+ if (onRequest) {
394
+ const hooks = Array.isArray(onRequest) ? onRequest : [onRequest]
395
+ for (const hook of hooks) {
396
+ const temp = await hook(resolvedPath, fetchInit)
397
+ if (typeof temp === 'object') {
398
+ fetchInit = {
399
+ ...fetchInit,
400
+ ...temp,
401
+ headers: {
402
+ ...fetchInit.headers,
403
+ ...processHeaders(temp.headers, resolvedPath, fetchInit),
404
+ } as Record<string, string>,
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ // Execute request with retries
411
+ const executeRequest = () =>
412
+ executeWithRetries({
413
+ url,
414
+ fetchInit,
415
+ fetcher: fetcher || fetch,
416
+ instance,
417
+ state: (config as any).state,
418
+ retries,
419
+ })
420
+
421
+ const response = await executeRequest()
422
+
423
+ // Process onResponse hooks
424
+ if (onResponse) {
425
+ const hooks = Array.isArray(onResponse) ? onResponse : [onResponse]
426
+ for (const hook of hooks) {
427
+ await hook(response.clone())
428
+ }
429
+ }
430
+
431
+ // Parse response
432
+ const { data, error } = await parseResponseData({
433
+ response,
434
+ executeRequest,
435
+ retries,
436
+ })
437
+
438
+ if (error) {
439
+ error.response = response
440
+ return error
441
+ }
442
+
443
+ return data
444
+ }
445
+
446
+ return spiceflowFetch as any
447
+ }