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,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'