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,411 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { createSpiceflowFetch } from './client/fetch.ts'
|
|
3
|
+
import { Spiceflow } from './spiceflow.ts'
|
|
4
|
+
import { SpiceflowFetchError } from './client/errors.ts'
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const app = new Spiceflow()
|
|
9
|
+
.state('someState', 1 as number | undefined)
|
|
10
|
+
.get('/', () => 'a')
|
|
11
|
+
.post('/', () => 'a')
|
|
12
|
+
.get('/number', () => 1)
|
|
13
|
+
.get('/someState', ({ state }) => state.someState)
|
|
14
|
+
.get('/true', () => true)
|
|
15
|
+
.get('/false', () => false)
|
|
16
|
+
.post('/array', async ({ request }) => await request.json(), {
|
|
17
|
+
body: z.array(z.string()),
|
|
18
|
+
})
|
|
19
|
+
.route({
|
|
20
|
+
method: 'POST',
|
|
21
|
+
path: '/mirror',
|
|
22
|
+
handler: async ({ request }) => await request.json(),
|
|
23
|
+
})
|
|
24
|
+
.route({
|
|
25
|
+
method: 'POST',
|
|
26
|
+
path: '/body',
|
|
27
|
+
handler: async ({ request }) => await request.text(),
|
|
28
|
+
body: z.string(),
|
|
29
|
+
})
|
|
30
|
+
.route({
|
|
31
|
+
method: 'DELETE',
|
|
32
|
+
path: '/empty',
|
|
33
|
+
handler: async ({ request }) => {
|
|
34
|
+
const body = await request.text()
|
|
35
|
+
return { body: body || null }
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.route({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/deep/nested/mirror',
|
|
41
|
+
handler: async ({ request }) => await request.json(),
|
|
42
|
+
body: z.object({
|
|
43
|
+
username: z.string(),
|
|
44
|
+
password: z.string(),
|
|
45
|
+
}),
|
|
46
|
+
})
|
|
47
|
+
.get('/throws', () => {
|
|
48
|
+
throw new Response('Custom error', { status: 400 })
|
|
49
|
+
})
|
|
50
|
+
.get('/throws-307', () => {
|
|
51
|
+
throw new Response('Redirect', {
|
|
52
|
+
status: 307,
|
|
53
|
+
headers: { location: 'http://example.com' },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
.get('/throws-200', () => {
|
|
57
|
+
throw new Response('this string will not be parsed as json', {
|
|
58
|
+
status: 200,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
.get('/throws-402-json', () => {
|
|
62
|
+
throw new Response(
|
|
63
|
+
JSON.stringify({ reason: 'Payment required', code: 4021 }),
|
|
64
|
+
{
|
|
65
|
+
status: 402,
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
.use(
|
|
71
|
+
new Spiceflow({ basePath: '/nested' }).get('/data', ({ params }) => 'hi'),
|
|
72
|
+
)
|
|
73
|
+
.get(
|
|
74
|
+
'/validationError',
|
|
75
|
+
// @ts-expect-error
|
|
76
|
+
() => {
|
|
77
|
+
return 'this errors because validation is wrong'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
response: {
|
|
81
|
+
200: z.object({
|
|
82
|
+
x: z.string(),
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
.get('/dateObject', () => ({ date: new Date() }))
|
|
88
|
+
.get('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'))
|
|
89
|
+
.post('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'), {
|
|
90
|
+
body: z.object({
|
|
91
|
+
username: z.string(),
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
.get('/stream', function* stream() {
|
|
95
|
+
yield 'a'
|
|
96
|
+
yield 'b'
|
|
97
|
+
yield 'c'
|
|
98
|
+
})
|
|
99
|
+
.get('/stream-async', async function* stream() {
|
|
100
|
+
yield 'a'
|
|
101
|
+
yield 'b'
|
|
102
|
+
yield 'c'
|
|
103
|
+
})
|
|
104
|
+
.get('/stream-return', function* stream() {
|
|
105
|
+
return 'a'
|
|
106
|
+
})
|
|
107
|
+
.get('/stream-return-async', function* stream() {
|
|
108
|
+
return 'a'
|
|
109
|
+
})
|
|
110
|
+
.get('/id/:id', ({ params: { id } }) => id)
|
|
111
|
+
.get('/items/:id/:id2', ({ params }) => params)
|
|
112
|
+
.get(
|
|
113
|
+
'/search',
|
|
114
|
+
({ query }) => query,
|
|
115
|
+
{ query: z.object({ q: z.string(), page: z.coerce.number().optional() }) },
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const f = createSpiceflowFetch(app)
|
|
119
|
+
|
|
120
|
+
describe('fetch client', () => {
|
|
121
|
+
it('get index', async () => {
|
|
122
|
+
const result = await f('/')
|
|
123
|
+
if (result instanceof Error) throw result
|
|
124
|
+
|
|
125
|
+
expect(result).toBe('a')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('post index', async () => {
|
|
129
|
+
const result = await f('/', { method: 'POST' })
|
|
130
|
+
if (result instanceof Error) throw result
|
|
131
|
+
|
|
132
|
+
expect(result).toBe('a')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('parse number', async () => {
|
|
136
|
+
const result = await f('/number')
|
|
137
|
+
if (result instanceof Error) throw result
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('parse true', async () => {
|
|
143
|
+
const result = await f('/true')
|
|
144
|
+
if (result instanceof Error) throw result
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('parse false', async () => {
|
|
150
|
+
const result = await f('/false')
|
|
151
|
+
if (result instanceof Error) throw result
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('post array', async () => {
|
|
157
|
+
const result = await f('/array', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: ['a', 'b'],
|
|
160
|
+
})
|
|
161
|
+
if (result instanceof Error) throw result
|
|
162
|
+
|
|
163
|
+
expect(result).toEqual(['a', 'b'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('post body', async () => {
|
|
167
|
+
const result = await f('/body', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: 'a',
|
|
170
|
+
})
|
|
171
|
+
if (result instanceof Error) throw result
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual('a')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('post mirror', async () => {
|
|
177
|
+
const body = { username: 'A', password: 'B' }
|
|
178
|
+
|
|
179
|
+
const result = await f('/mirror', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body,
|
|
182
|
+
})
|
|
183
|
+
if (result instanceof Error) throw result
|
|
184
|
+
|
|
185
|
+
expect(result).toEqual(body)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('delete empty', async () => {
|
|
189
|
+
const result = await f('/empty', { method: 'DELETE' })
|
|
190
|
+
if (result instanceof Error) throw result
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({ body: null })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('post deep nested mirror', async () => {
|
|
196
|
+
const body = { username: 'A', password: 'B' }
|
|
197
|
+
|
|
198
|
+
const result = await f('/deep/nested/mirror', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body,
|
|
201
|
+
})
|
|
202
|
+
if (result instanceof Error) throw result
|
|
203
|
+
|
|
204
|
+
expect(result).toEqual(body)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('get nested data', async () => {
|
|
208
|
+
const result = await f('/nested/data')
|
|
209
|
+
if (result instanceof Error) throw result
|
|
210
|
+
|
|
211
|
+
expect(result).toEqual('hi')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('handles thrown response', async () => {
|
|
215
|
+
const result = await f('/throws')
|
|
216
|
+
|
|
217
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
218
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
219
|
+
expect(result.status).toBe(400)
|
|
220
|
+
expect(result.message).toBe('Custom error')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('handles thrown response with 307', async () => {
|
|
224
|
+
const result = await f('/throws-307')
|
|
225
|
+
|
|
226
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
227
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
228
|
+
expect(result.status).toBe(307)
|
|
229
|
+
expect(result.message).toBe('Redirect')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('handles thrown response with 200', async () => {
|
|
233
|
+
const result = await f('/throws-200')
|
|
234
|
+
if (result instanceof Error) throw result
|
|
235
|
+
|
|
236
|
+
expect(result).toMatchInlineSnapshot(
|
|
237
|
+
`"this string will not be parsed as json"`,
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('surfaces json payload in error value for 402 responses', async () => {
|
|
242
|
+
const result = await f('/throws-402-json')
|
|
243
|
+
|
|
244
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
245
|
+
if (!(result instanceof SpiceflowFetchError)) throw new Error('Expected SpiceflowFetchError')
|
|
246
|
+
expect(result.status).toBe(402)
|
|
247
|
+
expect(result.value).toEqual({ reason: 'Payment required', code: 4021 })
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('stream', async () => {
|
|
251
|
+
const result = await f('/stream')
|
|
252
|
+
if (result instanceof Error) throw result
|
|
253
|
+
|
|
254
|
+
let all = ''
|
|
255
|
+
for await (const chunk of result) {
|
|
256
|
+
all += chunk + '-'
|
|
257
|
+
}
|
|
258
|
+
expect(all).toEqual('a-b-c-')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('stream async', async () => {
|
|
262
|
+
const result = await f('/stream-async')
|
|
263
|
+
if (result instanceof Error) throw result
|
|
264
|
+
|
|
265
|
+
let all = ''
|
|
266
|
+
for await (const chunk of result) {
|
|
267
|
+
all += chunk + '-'
|
|
268
|
+
}
|
|
269
|
+
expect(all).toEqual('a-b-c-')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('stream return', async () => {
|
|
273
|
+
const result = await f('/stream-return')
|
|
274
|
+
if (result instanceof Error) throw result
|
|
275
|
+
|
|
276
|
+
let all = ''
|
|
277
|
+
for await (const chunk of result) {
|
|
278
|
+
all += chunk
|
|
279
|
+
}
|
|
280
|
+
expect(all).toEqual('a')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('stream return async', async () => {
|
|
284
|
+
const result = await f('/stream-return-async')
|
|
285
|
+
if (result instanceof Error) throw result
|
|
286
|
+
|
|
287
|
+
let all = ''
|
|
288
|
+
for await (const chunk of result) {
|
|
289
|
+
all += chunk
|
|
290
|
+
}
|
|
291
|
+
expect(all).toEqual('a')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('path params', async () => {
|
|
295
|
+
const result = await f('/id/:id', {
|
|
296
|
+
params: { id: '123' },
|
|
297
|
+
})
|
|
298
|
+
if (result instanceof Error) throw result
|
|
299
|
+
|
|
300
|
+
expect(result).toEqual('123')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('query params', async () => {
|
|
304
|
+
const result = await f('/search', {
|
|
305
|
+
query: { q: 'hello', page: 1 },
|
|
306
|
+
})
|
|
307
|
+
if (result instanceof Error) throw result
|
|
308
|
+
|
|
309
|
+
expect(result).toEqual({ q: 'hello', page: 1 })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('overlapping param names', async () => {
|
|
313
|
+
const result = await f('/items/:id/:id2', {
|
|
314
|
+
params: { id: 'A', id2: 'B' },
|
|
315
|
+
})
|
|
316
|
+
if (result instanceof Error) throw result
|
|
317
|
+
|
|
318
|
+
expect(result).toEqual({ id: 'A', id2: 'B' })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('untyped URL falls back gracefully', async () => {
|
|
322
|
+
const untypedFetch = createSpiceflowFetch(app)
|
|
323
|
+
const result = await (untypedFetch as any)('/number')
|
|
324
|
+
if (result instanceof Error) throw result
|
|
325
|
+
expect(result).toEqual(1)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('fetch client type safety', () => {
|
|
330
|
+
it('requires params for parameterized paths', () => {
|
|
331
|
+
// @ts-expect-error - missing required params for /id/:id
|
|
332
|
+
f('/id/:id')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('requires query when route schema demands it', () => {
|
|
336
|
+
// @ts-expect-error - missing required query for /search
|
|
337
|
+
f('/search')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('requires body for POST with typed body schema', () => {
|
|
341
|
+
// @ts-expect-error - missing required body for /deep/nested/mirror POST
|
|
342
|
+
f('/deep/nested/mirror', { method: 'POST' })
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('allows GET without options on routes with no required fields', async () => {
|
|
346
|
+
const result = await f('/number')
|
|
347
|
+
if (result instanceof Error) throw result
|
|
348
|
+
expect(result).toEqual(1)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('fetch client with state', () => {
|
|
353
|
+
it('should return state value', async () => {
|
|
354
|
+
const f = createSpiceflowFetch(app, { state: { someState: 3 } })
|
|
355
|
+
const result = await f('/someState')
|
|
356
|
+
if (result instanceof Error) throw result
|
|
357
|
+
expect(result).toBe(3)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('fetch client retries', () => {
|
|
362
|
+
it('should retry on 500 errors and succeed on third attempt', async () => {
|
|
363
|
+
let attemptCount = 0
|
|
364
|
+
const retryApp = new Spiceflow().get('/retry-success', () => {
|
|
365
|
+
attemptCount++
|
|
366
|
+
if (attemptCount < 3) {
|
|
367
|
+
throw new Response('Server error', { status: 500 })
|
|
368
|
+
}
|
|
369
|
+
return { success: true, attempts: attemptCount }
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
373
|
+
const result = await retryFetch('/retry-success')
|
|
374
|
+
if (result instanceof Error) throw result
|
|
375
|
+
|
|
376
|
+
expect(result).toEqual({ success: true, attempts: 3 })
|
|
377
|
+
expect(attemptCount).toBe(3)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('should fail after all retries are exhausted', async () => {
|
|
381
|
+
let attemptCount = 0
|
|
382
|
+
const retryApp = new Spiceflow().get('/retry-fail', () => {
|
|
383
|
+
attemptCount++
|
|
384
|
+
throw new Response('Server error', { status: 500 })
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
388
|
+
const result = await retryFetch('/retry-fail')
|
|
389
|
+
|
|
390
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
391
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
392
|
+
expect(result.status).toBe(500)
|
|
393
|
+
expect(attemptCount).toBe(3)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('should not retry on non-500 errors', async () => {
|
|
397
|
+
let attemptCount = 0
|
|
398
|
+
const retryApp = new Spiceflow().get('/retry-400', () => {
|
|
399
|
+
attemptCount++
|
|
400
|
+
throw new Response('Bad request', { status: 400 })
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
404
|
+
const result = await retryFetch('/retry-400')
|
|
405
|
+
|
|
406
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
407
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
408
|
+
expect(result.status).toBe(400)
|
|
409
|
+
expect(attemptCount).toBe(1)
|
|
410
|
+
})
|
|
411
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SpiceflowRequest, WaitUntil, Method } from './spiceflow.ts'
|
|
2
2
|
|
|
3
|
-
export { Spiceflow } from './spiceflow.ts'
|
|
3
|
+
export { Spiceflow, createSafePath } from './spiceflow.ts'
|
|
4
4
|
export type { AnySpiceflow, WaitUntil } from './spiceflow.ts'
|
|
5
5
|
export { ValidationError } from './error.ts'
|
|
6
6
|
export { preventProcessExitIfBusy } from './prevent-process-exit-if-busy.ts'
|