spiceflow 1.4.1 → 1.5.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.
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable no-case-declarations */
3
3
  /* eslint-disable prefer-const */
4
4
  import type { Spiceflow } from '../spiceflow.js'
5
+ import superjson from 'superjson'
5
6
  import { EventSourceParserStream } from 'eventsource-parser/stream'
6
7
 
7
8
  import type { SpiceflowClient } from './types.js'
@@ -161,17 +162,17 @@ export async function* streamSSEResponse(
161
162
  const { done, value: event } = await reader.read()
162
163
  if (done) break
163
164
  if (event?.event === 'error') {
164
- throw new SpiceflowFetchError(500, event.data)
165
+ throw new SpiceflowFetchError(500, superjsonDeserialize(event.data))
165
166
  }
166
167
  if (event) {
167
- yield tryParsingJson(event.data)
168
+ yield tryParsingSSEJson(event.data)
168
169
  }
169
170
  }
170
171
  }
171
172
 
172
- function tryParsingJson(data: string): any {
173
+ function tryParsingSSEJson(data: string): any {
173
174
  try {
174
- return JSON.parse(data)
175
+ return superjsonDeserialize(JSON.parse(data))
175
176
  } catch (error) {
176
177
  return null
177
178
  }
@@ -407,6 +408,7 @@ const createProxy = (
407
408
 
408
409
  case 'application/json':
409
410
  data = await response.json()
411
+ data = superjsonDeserialize(data)
410
412
  break
411
413
  case 'application/octet-stream':
412
414
  data = await response.arrayBuffer()
@@ -432,7 +434,7 @@ const createProxy = (
432
434
  response.status,
433
435
  data || 'Unknown error',
434
436
  )
435
- console.trace({ error, data })
437
+ // console.trace({ error, data })
436
438
  data = null
437
439
  }
438
440
 
@@ -477,3 +479,14 @@ export const createSpiceflowClient = <
477
479
 
478
480
  return createProxy('http://e.ly', config, [], domain)
479
481
  }
482
+
483
+ function superjsonDeserialize(data: any) {
484
+ if (data?.__superjsonMeta) {
485
+ const { __superjsonMeta, ...rest } = data
486
+ return superjson.deserialize({
487
+ json: rest,
488
+ meta: __superjsonMeta,
489
+ })
490
+ }
491
+ return data
492
+ }
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod'
2
2
  import { createSpiceflowClient } from './client/index.js'
3
- import { Spiceflow, t } from './spiceflow.js'
3
+ import { Spiceflow } from './spiceflow.js'
4
+ import { Type as t } from '@sinclair/typebox'
4
5
 
5
6
  import { describe, expect, it } from 'vitest'
6
-
7
7
  const app = new Spiceflow()
8
8
  .get('/', () => 'a')
9
9
  .post('/', () => 'a')
@@ -11,11 +11,11 @@ const app = new Spiceflow()
11
11
  .get('/true', () => true)
12
12
  .get('/false', () => false)
13
13
  .post('/array', async ({ request }) => await request.json(), {
14
- body: t.Array(t.String()),
14
+ body: z.array(z.string()),
15
15
  })
16
16
  .post('/mirror', async ({ request }) => await request.json())
17
17
  .post('/body', async ({ request }) => await request.text(), {
18
- body: t.String(),
18
+ body: z.string(),
19
19
  })
20
20
  .post('/zodAny', async ({ request }) => await request.json(), {
21
21
  body: z.object({ body: z.array(z.any()) }),
@@ -25,9 +25,9 @@ const app = new Spiceflow()
25
25
  return { body: body || null }
26
26
  })
27
27
  .post('/deep/nested/mirror', async ({ request }) => await request.json(), {
28
- body: t.Object({
29
- username: t.String(),
30
- password: t.String(),
28
+ body: z.object({
29
+ username: z.string(),
30
+ password: z.string(),
31
31
  }),
32
32
  })
33
33
  .get('/throws', () => {
@@ -62,8 +62,8 @@ const app = new Spiceflow()
62
62
  },
63
63
  {
64
64
  response: {
65
- 200: t.Object({
66
- x: t.String(),
65
+ 200: z.object({
66
+ x: z.string(),
67
67
  }),
68
68
  },
69
69
  },
@@ -78,8 +78,8 @@ const app = new Spiceflow()
78
78
  .get('/dateObject', () => ({ date: new Date() }))
79
79
  .get('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'))
80
80
  .post('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'), {
81
- body: t.Object({
82
- username: t.String(),
81
+ body: z.object({
82
+ username: z.string(),
83
83
  }),
84
84
  })
85
85
  // .get('/formdata', () => ({
package/src/context.ts CHANGED
@@ -82,7 +82,7 @@ export type Context<
82
82
 
83
83
  request: SpiceflowRequest<Route['body']>
84
84
  state: Singleton['state']
85
- response?: Route['response']
85
+ // response?: Route['response']
86
86
  }>
87
87
 
88
88
  // Use to mimic request before mapping route
package/src/cors.ts CHANGED
@@ -26,6 +26,7 @@ export const cors = (options?: CORSOptions): MiddlewareHandler => {
26
26
  allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'],
27
27
  allowHeaders: [],
28
28
  exposeHeaders: [],
29
+ credentials: true,
29
30
  cacheAge: 21600 // 6 hours default
30
31
  }
31
32
  const opts = {
package/src/mcp.test.ts CHANGED
@@ -195,12 +195,12 @@ describe('MCP Plugin', () => {
195
195
  {
196
196
  "mimeType": "application/json",
197
197
  "name": "GET /goSomething",
198
- "uri": "http://localhost:3000/goSomething",
198
+ "uri": "http://localhost:4000/goSomething",
199
199
  },
200
200
  {
201
201
  "mimeType": "application/json",
202
202
  "name": "GET /users",
203
- "uri": "http://localhost:3000/users",
203
+ "uri": "http://localhost:4000/users",
204
204
  },
205
205
  ]
206
206
  `)
@@ -220,22 +220,15 @@ describe('MCP Plugin', () => {
220
220
  [
221
221
  {
222
222
  "mimeType": "application/json",
223
- "text": "{
224
- "users": [
225
- {
226
- "id": 1,
227
- "name": "John"
228
- }
229
- ]
230
- }",
231
- "uri": "http://localhost:3000/users",
223
+ "text": "{"users":[{"id":1,"name":"John"}]}",
224
+ "uri": "http://localhost:4000/users",
232
225
  },
233
226
  ]
234
227
  `)
235
228
  })
236
229
  })
237
230
 
238
- async function getAvailablePort(startPort = 3000, maxRetries = 10) {
231
+ async function getAvailablePort(startPort = 4000, maxRetries = 10) {
239
232
  const net = await import('net')
240
233
 
241
234
  return await new Promise<number>((resolve, reject) => {
package/src/mcp.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { zodToJsonSchema } from 'zod-to-json-schema'
10
10
  import { SSEServerTransportSpiceflow } from './mcp-transport.js'
11
11
  import { isZodSchema, Spiceflow } from './spiceflow.js'
12
+ import { OpenAPIV3 } from 'openapi-types'
12
13
 
13
14
  function getJsonSchema(schema: any) {
14
15
  if (!schema) return undefined
@@ -159,6 +159,73 @@ test('openapi response', async () => {
159
159
  .then((x) => x.json())
160
160
  expect(openapiSchema).toMatchInlineSnapshot(`
161
161
  {
162
+ "__superjsonMeta": {
163
+ "values": {
164
+ "paths./addBody.patch.responses.200.content.application/json.schema.items": [
165
+ "undefined",
166
+ ],
167
+ "paths./addBody.patch.responses.200.content.application/json.schema.patternProperties": [
168
+ "undefined",
169
+ ],
170
+ "paths./addBody.patch.responses.200.content.application/json.schema.required": [
171
+ "undefined",
172
+ ],
173
+ "paths./formWithSchemaForm.post.responses.200.content.multipart/form-data.schema.items": [
174
+ "undefined",
175
+ ],
176
+ "paths./formWithSchemaForm.post.responses.200.content.multipart/form-data.schema.patternProperties": [
177
+ "undefined",
178
+ ],
179
+ "paths./one/ids/{id}.get.parameters.0.description": [
180
+ "undefined",
181
+ ],
182
+ "paths./one/ids/{id}.get.parameters.0.examples": [
183
+ "undefined",
184
+ ],
185
+ "paths./one/ids/{id}.get.responses.404.content.application/json.schema.items": [
186
+ "undefined",
187
+ ],
188
+ "paths./one/ids/{id}.get.responses.404.content.application/json.schema.patternProperties": [
189
+ "undefined",
190
+ ],
191
+ "paths./queryParams.get.parameters.0.description": [
192
+ "undefined",
193
+ ],
194
+ "paths./queryParams.get.parameters.0.examples": [
195
+ "undefined",
196
+ ],
197
+ "paths./queryParams.get.responses.200.content.application/json.schema.items": [
198
+ "undefined",
199
+ ],
200
+ "paths./queryParams.get.responses.200.content.application/json.schema.patternProperties": [
201
+ "undefined",
202
+ ],
203
+ "paths./queryParams.get.responses.200.content.application/json.schema.required": [
204
+ "undefined",
205
+ ],
206
+ "paths./queryParams.post.responses.200.content.application/json.schema.items": [
207
+ "undefined",
208
+ ],
209
+ "paths./queryParams.post.responses.200.content.application/json.schema.patternProperties": [
210
+ "undefined",
211
+ ],
212
+ "paths./queryParams.post.responses.200.content.application/json.schema.required": [
213
+ "undefined",
214
+ ],
215
+ "paths./streamWithSchema.get.responses.200.content.application/json.schema.items": [
216
+ "undefined",
217
+ ],
218
+ "paths./streamWithSchema.get.responses.200.content.application/json.schema.patternProperties": [
219
+ "undefined",
220
+ ],
221
+ "paths./two/ids/{id}.get.parameters.0.description": [
222
+ "undefined",
223
+ ],
224
+ "paths./two/ids/{id}.get.parameters.0.examples": [
225
+ "undefined",
226
+ ],
227
+ },
228
+ },
162
229
  "components": {
163
230
  "schemas": {},
164
231
  },
@@ -196,11 +263,14 @@ test('openapi response', async () => {
196
263
  "content": {
197
264
  "application/json": {
198
265
  "schema": {
266
+ "items": null,
267
+ "patternProperties": null,
199
268
  "properties": {
200
269
  "name": {
201
270
  "type": "string",
202
271
  },
203
272
  },
273
+ "required": null,
204
274
  "type": "object",
205
275
  },
206
276
  },
@@ -226,6 +296,8 @@ test('openapi response', async () => {
226
296
  "content": {
227
297
  "multipart/form-data": {
228
298
  "schema": {
299
+ "items": null,
300
+ "patternProperties": null,
229
301
  "properties": {
230
302
  "age": {
231
303
  "type": "string",
@@ -259,6 +331,8 @@ test('openapi response', async () => {
259
331
  "get": {
260
332
  "parameters": [
261
333
  {
334
+ "description": null,
335
+ "examples": null,
262
336
  "in": "path",
263
337
  "name": "id",
264
338
  "required": true,
@@ -282,6 +356,8 @@ test('openapi response', async () => {
282
356
  "content": {
283
357
  "application/json": {
284
358
  "schema": {
359
+ "items": null,
360
+ "patternProperties": null,
285
361
  "properties": {
286
362
  "message": {
287
363
  "type": "string",
@@ -333,6 +409,8 @@ test('openapi response', async () => {
333
409
  "get": {
334
410
  "parameters": [
335
411
  {
412
+ "description": null,
413
+ "examples": null,
336
414
  "in": "query",
337
415
  "name": "name",
338
416
  "required": true,
@@ -346,11 +424,14 @@ test('openapi response', async () => {
346
424
  "content": {
347
425
  "application/json": {
348
426
  "schema": {
427
+ "items": null,
428
+ "patternProperties": null,
349
429
  "properties": {
350
430
  "name": {
351
431
  "type": "string",
352
432
  },
353
433
  },
434
+ "required": null,
354
435
  "type": "object",
355
436
  },
356
437
  },
@@ -395,11 +476,14 @@ test('openapi response', async () => {
395
476
  "content": {
396
477
  "application/json": {
397
478
  "schema": {
479
+ "items": null,
480
+ "patternProperties": null,
398
481
  "properties": {
399
482
  "name": {
400
483
  "type": "string",
401
484
  },
402
485
  },
486
+ "required": null,
403
487
  "type": "object",
404
488
  },
405
489
  },
@@ -451,6 +535,8 @@ test('openapi response', async () => {
451
535
  "content": {
452
536
  "application/json": {
453
537
  "schema": {
538
+ "items": null,
539
+ "patternProperties": null,
454
540
  "properties": {
455
541
  "count": {
456
542
  "type": "number",
@@ -483,6 +569,8 @@ test('openapi response', async () => {
483
569
  "get": {
484
570
  "parameters": [
485
571
  {
572
+ "description": null,
573
+ "examples": null,
486
574
  "in": "path",
487
575
  "name": "id",
488
576
  "required": true,
@@ -11,6 +11,122 @@ test('works', async () => {
11
11
  expect(res.status).toBe(200)
12
12
  expect(await res.json()).toEqual('hi')
13
13
  })
14
+ test('can encode superjson types', async () => {
15
+ const app = new Spiceflow().post('/superjson', () => {
16
+ const item = {
17
+ date: new Date('2025-01-20T18:01:57.852Z'),
18
+ map: new Map([['a', 1]]),
19
+ set: new Set([1, 2, 3]),
20
+ bigint: BigInt(123),
21
+ }
22
+ return { items: Array(2).fill(item) }
23
+ })
24
+ const res = await app.handle(
25
+ new Request('http://localhost/superjson', { method: 'POST' }),
26
+ )
27
+ expect(res.status).toBe(200)
28
+ const client = createSpiceflowClient(app)
29
+ expect(await client.superjson.post().then((x) => x.data))
30
+ .toMatchInlineSnapshot(`
31
+ {
32
+ "items": [
33
+ {
34
+ "bigint": 123n,
35
+ "date": 2025-01-20T18:01:57.852Z,
36
+ "map": Map {
37
+ "a" => 1,
38
+ },
39
+ "set": Set {
40
+ 1,
41
+ 2,
42
+ 3,
43
+ },
44
+ },
45
+ {
46
+ "bigint": 123n,
47
+ "date": 2025-01-20T18:01:57.852Z,
48
+ "map": Map {
49
+ "a" => 1,
50
+ },
51
+ "set": Set {
52
+ 1,
53
+ 2,
54
+ 3,
55
+ },
56
+ },
57
+ ],
58
+ }
59
+ `)
60
+ expect(await res.json()).toMatchInlineSnapshot(`
61
+ {
62
+ "__superjsonMeta": {
63
+ "referentialEqualities": {
64
+ "items.0": [
65
+ "items.1",
66
+ ],
67
+ },
68
+ "values": {
69
+ "items.0.bigint": [
70
+ "bigint",
71
+ ],
72
+ "items.0.date": [
73
+ "Date",
74
+ ],
75
+ "items.0.map": [
76
+ "map",
77
+ ],
78
+ "items.0.set": [
79
+ "set",
80
+ ],
81
+ "items.1.bigint": [
82
+ "bigint",
83
+ ],
84
+ "items.1.date": [
85
+ "Date",
86
+ ],
87
+ "items.1.map": [
88
+ "map",
89
+ ],
90
+ "items.1.set": [
91
+ "set",
92
+ ],
93
+ },
94
+ },
95
+ "items": [
96
+ {
97
+ "bigint": "123",
98
+ "date": "2025-01-20T18:01:57.852Z",
99
+ "map": [
100
+ [
101
+ "a",
102
+ 1,
103
+ ],
104
+ ],
105
+ "set": [
106
+ 1,
107
+ 2,
108
+ 3,
109
+ ],
110
+ },
111
+ {
112
+ "bigint": "123",
113
+ "date": "2025-01-20T18:01:57.852Z",
114
+ "map": [
115
+ [
116
+ "a",
117
+ 1,
118
+ ],
119
+ ],
120
+ "set": [
121
+ 1,
122
+ 2,
123
+ 3,
124
+ ],
125
+ },
126
+ ],
127
+ }
128
+ `)
129
+ })
14
130
  test('dynamic route', async () => {
15
131
  const res = await new Spiceflow()
16
132
  .post('/ids/:id', () => 'hi')
@@ -311,7 +427,7 @@ test('validate body works, request fails', async () => {
311
427
  )
312
428
  expect(res.status).toBe(422)
313
429
  expect(await res.text()).toMatchInlineSnapshot(
314
- `"{"message":"data must have required property 'requiredField'"}"`,
430
+ `"{"code":"VALIDATION","status":422,"message":"data must have required property 'requiredField'"}"`,
315
431
  )
316
432
  })
317
433
 
@@ -578,7 +694,9 @@ test('errors inside basPath works', async () => {
578
694
  expect(onErrorTriggered).toEqual(['root', 'two', 'nested'])
579
695
  expect(onReqTriggered).toEqual(['root', 'two', 'nested'])
580
696
  expect(res.status).toBe(500)
581
- expect(await res.text()).toMatchInlineSnapshot(`"{"message":"error message"}"`)
697
+ expect(await res.text()).toMatchInlineSnapshot(
698
+ `"{"message":"error message"}"`,
699
+ )
582
700
  // expect(await res.json()).toEqual('nested'))
583
701
  }
584
702
  })
package/src/spiceflow.ts CHANGED
@@ -1,8 +1,5 @@
1
- import { Type } from '@sinclair/typebox'
2
-
3
- export { Type as t }
4
-
5
1
  import addFormats from 'ajv-formats'
2
+ import superjson from 'superjson'
6
3
  import {
7
4
  ComposeSpiceflowResponse,
8
5
  ContentType,
@@ -35,6 +32,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema'
35
32
  import { Context, MiddlewareContext } from './context.js'
36
33
  import { isProduction, ValidationError } from './error.js'
37
34
  import { isAsyncIterable, isResponse, redirect } from './utils.js'
35
+ import { json } from 'stream/consumers'
38
36
 
39
37
  const ajv = (addFormats.default || addFormats)(
40
38
  new (Ajv.default || Ajv)({ useDefaults: true }),
@@ -777,7 +775,10 @@ export class Spiceflow<
777
775
 
778
776
  let status = err?.status ?? 500
779
777
  res ||= new Response(
780
- JSON.stringify({ message: err?.message || 'Internal Server Error' }),
778
+ superjsonSerialize({
779
+ ...err,
780
+ message: err?.message || 'Internal Server Error',
781
+ }),
781
782
  {
782
783
  status,
783
784
  headers: {
@@ -922,7 +923,7 @@ export class Spiceflow<
922
923
  error(error) {
923
924
  console.error(error)
924
925
  return new Response(
925
- JSON.stringify({ message: 'Internal Server Error' }),
926
+ superjsonSerialize({ message: 'Internal Server Error' }),
926
927
  {
927
928
  status: 500,
928
929
  },
@@ -1002,7 +1003,7 @@ export class Spiceflow<
1002
1003
  } catch (error) {
1003
1004
  console.error('Error handling request:', error)
1004
1005
  res.statusCode = 500
1005
- res.end(JSON.stringify({ message: 'Internal Server Error' }))
1006
+ res.end(superjsonSerialize({ message: 'Internal Server Error' }))
1006
1007
  }
1007
1008
  })
1008
1009
 
@@ -1054,6 +1055,7 @@ export class Spiceflow<
1054
1055
  // 1. return() allows for cleanup in finally blocks
1055
1056
  // 2. throw() would trigger error handling which isn't needed for normal aborts
1056
1057
  // 3. return() is the more graceful way to stop iteration
1058
+
1057
1059
  if ('return' in generator) {
1058
1060
  try {
1059
1061
  await generator.return(undefined)
@@ -1072,7 +1074,9 @@ export class Spiceflow<
1072
1074
  if (init?.value !== undefined && init?.value !== null)
1073
1075
  controller.enqueue(
1074
1076
  Buffer.from(
1075
- 'event: message\ndata: ' + JSON.stringify(init.value) + '\n\n',
1077
+ 'event: message\ndata: ' +
1078
+ superjsonSerialize(init.value, false) +
1079
+ '\n\n',
1076
1080
  ),
1077
1081
  )
1078
1082
 
@@ -1083,7 +1087,9 @@ export class Spiceflow<
1083
1087
 
1084
1088
  controller.enqueue(
1085
1089
  Buffer.from(
1086
- 'event: message\ndata: ' + JSON.stringify(chunk) + '\n\n',
1090
+ 'event: message\ndata: ' +
1091
+ superjsonSerialize(chunk, false) +
1092
+ '\n\n',
1087
1093
  ),
1088
1094
  )
1089
1095
  }
@@ -1096,7 +1102,13 @@ export class Spiceflow<
1096
1102
  controller.enqueue(
1097
1103
  Buffer.from(
1098
1104
  'event: error\ndata: ' +
1099
- JSON.stringify(error.message || error.name || 'Error') +
1105
+ superjsonSerialize(
1106
+ {
1107
+ ...error,
1108
+ message: error.message || error.name || 'Error',
1109
+ },
1110
+ false,
1111
+ ) +
1100
1112
  '\n\n',
1101
1113
  ),
1102
1114
  )
@@ -1292,13 +1304,23 @@ export async function turnHandlerResultIntoResponse(
1292
1304
  })
1293
1305
  }
1294
1306
  }
1295
- return new Response(JSON.stringify(result ?? null, null, 2), {
1307
+
1308
+ return new Response(superjsonSerialize(result), {
1296
1309
  headers: {
1297
1310
  'content-type': 'application/json',
1298
1311
  },
1299
1312
  })
1300
1313
  }
1301
1314
 
1315
+ function superjsonSerialize(value: any, indent = false) {
1316
+ // return JSON.stringify(value)
1317
+ const { json, meta } = superjson.serialize(value)
1318
+ if (json && meta) {
1319
+ json['__superjsonMeta'] = meta
1320
+ }
1321
+ return JSON.stringify(json ?? null, null, indent ? 2 : undefined)
1322
+ }
1323
+
1302
1324
  export type AnySpiceflow = Spiceflow<any, any, any, any, any, any>
1303
1325
 
1304
1326
  export function isZodSchema(value: unknown): value is ZodType {
@@ -69,8 +69,16 @@ describe('Stream', () => {
69
69
 
70
70
  const response = await app.handle(req('/')).then((x) => x.text())
71
71
 
72
- expect(response).toBe(
73
- 'event: message\ndata: "a"\n\nevent: error\ndata: "an error"\n\n',
72
+ expect(response).toMatchInlineSnapshot(
73
+ `
74
+ "event: message
75
+ data: "a"
76
+
77
+ event: error
78
+ data: {"message":"an error"}
79
+
80
+ "
81
+ `,
74
82
  )
75
83
  })
76
84
 
package/src/zod.test.ts CHANGED
@@ -1,8 +1,7 @@
1
- import { test, describe, expect } from 'vitest'
2
- import { Type } from '@sinclair/typebox'
1
+ import { expect, test } from 'vitest'
2
+ import { z } from 'zod'
3
3
  import { Spiceflow } from './spiceflow.js'
4
4
  import { req } from './utils.js'
5
- import { z } from 'zod'
6
5
 
7
6
  test('body is parsed as json', async () => {
8
7
  let name = ''