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,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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { AnySpiceflow, Spiceflow } from '../spiceflow.ts'
|
|
2
|
-
import superjson from 'superjson'
|
|
3
|
-
import { EventSourceParserStream } from 'eventsource-parser/stream'
|
|
4
2
|
|
|
5
3
|
import type { SpiceflowClient } from './types.ts'
|
|
6
4
|
|
|
@@ -10,6 +8,25 @@ import { SpiceflowFetchError } from './errors.ts'
|
|
|
10
8
|
|
|
11
9
|
import { parseStringifiedValue } from './utils.ts'
|
|
12
10
|
|
|
11
|
+
import {
|
|
12
|
+
isServer,
|
|
13
|
+
isFile,
|
|
14
|
+
hasFile,
|
|
15
|
+
createNewFile,
|
|
16
|
+
processHeaders,
|
|
17
|
+
streamSSEResponse,
|
|
18
|
+
tryParsingSSEJson,
|
|
19
|
+
superjsonDeserialize,
|
|
20
|
+
TextDecoderStream,
|
|
21
|
+
isAbortError,
|
|
22
|
+
type SSEEvent,
|
|
23
|
+
} from './shared.ts'
|
|
24
|
+
|
|
25
|
+
export { streamSSEResponse, TextDecoderStream }
|
|
26
|
+
|
|
27
|
+
export { createSpiceflowFetch } from './fetch.ts'
|
|
28
|
+
export type { SpiceflowFetch } from './fetch.ts'
|
|
29
|
+
|
|
13
30
|
const method = [
|
|
14
31
|
'get',
|
|
15
32
|
'post',
|
|
@@ -22,222 +39,6 @@ const method = [
|
|
|
22
39
|
'subscribe',
|
|
23
40
|
] as const
|
|
24
41
|
|
|
25
|
-
const isServer = typeof FileList === 'undefined'
|
|
26
|
-
|
|
27
|
-
const isFile = (v: any) => {
|
|
28
|
-
if (isServer) return v instanceof Blob
|
|
29
|
-
|
|
30
|
-
return v instanceof FileList || v instanceof File
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// FormData is 1 level deep
|
|
34
|
-
const hasFile = (obj: Record<string, any>) => {
|
|
35
|
-
if (!obj) return false
|
|
36
|
-
|
|
37
|
-
for (const key in obj) {
|
|
38
|
-
if (isFile(obj[key])) return true
|
|
39
|
-
|
|
40
|
-
if (Array.isArray(obj[key]) && (obj[key] as unknown[]).find(isFile))
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return false
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const createNewFile = (v: File) =>
|
|
48
|
-
isServer
|
|
49
|
-
? v
|
|
50
|
-
: new Promise<File>((resolve) => {
|
|
51
|
-
const reader = new FileReader()
|
|
52
|
-
|
|
53
|
-
reader.onload = () => {
|
|
54
|
-
const file = new File([reader.result!], v.name, {
|
|
55
|
-
lastModified: v.lastModified,
|
|
56
|
-
type: v.type,
|
|
57
|
-
})
|
|
58
|
-
resolve(file)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
reader.readAsArrayBuffer(v)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
const processHeaders = (
|
|
65
|
-
h: SpiceflowClient.Config['headers'],
|
|
66
|
-
path: string,
|
|
67
|
-
options: RequestInit = {},
|
|
68
|
-
headers: Record<string, string> = {},
|
|
69
|
-
): Record<string, string> => {
|
|
70
|
-
if (Array.isArray(h)) {
|
|
71
|
-
for (const value of h)
|
|
72
|
-
if (!Array.isArray(value))
|
|
73
|
-
headers = processHeaders(value, path, options, headers)
|
|
74
|
-
else {
|
|
75
|
-
const key = value[0]
|
|
76
|
-
if (typeof key === 'string')
|
|
77
|
-
headers[key.toLowerCase()] = value[1] as string
|
|
78
|
-
else
|
|
79
|
-
for (const [k, value] of key)
|
|
80
|
-
headers[k.toLowerCase()] = value as string
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return headers
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!h) return headers
|
|
87
|
-
|
|
88
|
-
switch (typeof h) {
|
|
89
|
-
case 'function':
|
|
90
|
-
if (typeof Headers !== 'undefined' && h instanceof Headers)
|
|
91
|
-
return processHeaders(h, path, options, headers)
|
|
92
|
-
|
|
93
|
-
const v = h(path, options)
|
|
94
|
-
if (v) return processHeaders(v, path, options, headers)
|
|
95
|
-
return headers
|
|
96
|
-
|
|
97
|
-
case 'object':
|
|
98
|
-
if (typeof Headers !== 'undefined' && h instanceof Headers) {
|
|
99
|
-
h.forEach((value, key) => {
|
|
100
|
-
headers[key.toLowerCase()] = value
|
|
101
|
-
})
|
|
102
|
-
return headers
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for (const [key, value] of Object.entries(h))
|
|
106
|
-
headers[key.toLowerCase()] = value as string
|
|
107
|
-
|
|
108
|
-
return headers
|
|
109
|
-
|
|
110
|
-
default:
|
|
111
|
-
return headers
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface SSEEvent {
|
|
116
|
-
event?: string
|
|
117
|
-
data: any
|
|
118
|
-
id?: string
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export class TextDecoderStream extends TransformStream<Uint8Array, string> {
|
|
122
|
-
constructor() {
|
|
123
|
-
const decoder = new TextDecoder('utf-8', {
|
|
124
|
-
fatal: true,
|
|
125
|
-
ignoreBOM: true,
|
|
126
|
-
})
|
|
127
|
-
super({
|
|
128
|
-
transform(
|
|
129
|
-
chunk: Uint8Array,
|
|
130
|
-
controller: TransformStreamDefaultController<string>,
|
|
131
|
-
) {
|
|
132
|
-
const decoded = decoder.decode(chunk, { stream: true })
|
|
133
|
-
if (decoded.length > 0) {
|
|
134
|
-
controller.enqueue(decoded)
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
flush(controller: TransformStreamDefaultController<string>) {
|
|
138
|
-
const output = decoder.decode()
|
|
139
|
-
if (output.length > 0) {
|
|
140
|
-
controller.enqueue(output)
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function isAbortError(error: unknown): error is Error {
|
|
148
|
-
return (
|
|
149
|
-
(error instanceof Error || error instanceof DOMException) &&
|
|
150
|
-
(error.name === 'AbortError' ||
|
|
151
|
-
error.name === 'ResponseAborted' || // Next.js
|
|
152
|
-
error.name === 'TimeoutError')
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function* streamSSEResponse({
|
|
157
|
-
response,
|
|
158
|
-
map,
|
|
159
|
-
executeRequest,
|
|
160
|
-
maxRetries = 0,
|
|
161
|
-
}: {
|
|
162
|
-
response: Response
|
|
163
|
-
map: (x: SSEEvent) => any
|
|
164
|
-
executeRequest?: () => Promise<Response>
|
|
165
|
-
maxRetries?: number
|
|
166
|
-
}): AsyncGenerator<SSEEvent> {
|
|
167
|
-
let currentResponse = response
|
|
168
|
-
let retriesLeft = maxRetries
|
|
169
|
-
|
|
170
|
-
while (true) {
|
|
171
|
-
const body = currentResponse.body
|
|
172
|
-
if (!body) return
|
|
173
|
-
|
|
174
|
-
const eventStream = body
|
|
175
|
-
.pipeThrough(new TextDecoderStream())
|
|
176
|
-
.pipeThrough(new EventSourceParserStream())
|
|
177
|
-
|
|
178
|
-
let reader = eventStream.getReader()
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
while (true) {
|
|
182
|
-
const { done, value: event } = await reader.read()
|
|
183
|
-
if (done) return
|
|
184
|
-
|
|
185
|
-
if (event?.event === 'error') {
|
|
186
|
-
const error = superjsonDeserialize(event.data)
|
|
187
|
-
throw new SpiceflowFetchError(500, error)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (event) {
|
|
191
|
-
yield map({ ...event, data: event.data })
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} catch (error) {
|
|
195
|
-
if (isAbortError(error)) {
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
executeRequest &&
|
|
201
|
-
error instanceof SpiceflowFetchError &&
|
|
202
|
-
error.status >= 500 &&
|
|
203
|
-
retriesLeft > 0
|
|
204
|
-
) {
|
|
205
|
-
retriesLeft--
|
|
206
|
-
const backoffMs = Math.min(
|
|
207
|
-
1000 * 2 ** (maxRetries - retriesLeft - 1),
|
|
208
|
-
10000,
|
|
209
|
-
)
|
|
210
|
-
await new Promise((resolve) => setTimeout(resolve, backoffMs))
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
currentResponse = await executeRequest()
|
|
214
|
-
if (currentResponse.status >= 500) {
|
|
215
|
-
if (retriesLeft === 0) {
|
|
216
|
-
throw error
|
|
217
|
-
}
|
|
218
|
-
continue
|
|
219
|
-
}
|
|
220
|
-
} catch (retryError) {
|
|
221
|
-
if (retriesLeft === 0) {
|
|
222
|
-
throw retryError
|
|
223
|
-
}
|
|
224
|
-
continue
|
|
225
|
-
}
|
|
226
|
-
} else {
|
|
227
|
-
throw error
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function tryParsingSSEJson(data: string): any {
|
|
234
|
-
try {
|
|
235
|
-
return superjsonDeserialize(JSON.parse(data))
|
|
236
|
-
} catch (error) {
|
|
237
|
-
return data
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
42
|
const createProxy = (
|
|
242
43
|
domain: string,
|
|
243
44
|
config: SpiceflowClient.Config & { state?: any },
|
|
@@ -603,14 +404,3 @@ export const createSpiceflowClient = <const App extends AnySpiceflow>(
|
|
|
603
404
|
|
|
604
405
|
return createProxy('http://e.ly', config || {}, [], domain)
|
|
605
406
|
}
|
|
606
|
-
|
|
607
|
-
function superjsonDeserialize(data: any) {
|
|
608
|
-
if (data?.__superjsonMeta) {
|
|
609
|
-
const { __superjsonMeta, ...rest } = data
|
|
610
|
-
return superjson.deserialize({
|
|
611
|
-
json: rest,
|
|
612
|
-
meta: __superjsonMeta,
|
|
613
|
-
})
|
|
614
|
-
}
|
|
615
|
-
return data
|
|
616
|
-
}
|