spiceflow 1.12.1 → 1.13.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 (98) hide show
  1. package/README.md +107 -4
  2. package/dist/_node-server-unsupported.js +1 -0
  3. package/dist/_node-server-unsupported.js.map +1 -0
  4. package/dist/_node-server.d.ts.map +1 -1
  5. package/dist/_node-server.js +2 -2
  6. package/dist/_node-server.js.map +1 -0
  7. package/dist/client/errors.js +1 -0
  8. package/dist/client/errors.js.map +1 -0
  9. package/dist/client/index.d.ts +2 -2
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +7 -4
  12. package/dist/client/index.js.map +1 -0
  13. package/dist/client/types.js +1 -0
  14. package/dist/client/types.js.map +1 -0
  15. package/dist/client/utils.js +1 -0
  16. package/dist/client/utils.js.map +1 -0
  17. package/dist/client.test.js +1 -0
  18. package/dist/client.test.js.map +1 -0
  19. package/dist/context.d.ts +6 -3
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +1 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/cors.js +1 -0
  24. package/dist/cors.js.map +1 -0
  25. package/dist/cors.test.js +1 -0
  26. package/dist/cors.test.js.map +1 -0
  27. package/dist/error.js +1 -0
  28. package/dist/error.js.map +1 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mcp-client-transport.d.ts +35 -0
  34. package/dist/mcp-client-transport.d.ts.map +1 -0
  35. package/dist/mcp-client-transport.js +147 -0
  36. package/dist/mcp-client-transport.js.map +1 -0
  37. package/dist/mcp-transport.js +1 -0
  38. package/dist/mcp-transport.js.map +1 -0
  39. package/dist/mcp.d.ts +18 -1
  40. package/dist/mcp.d.ts.map +1 -1
  41. package/dist/mcp.js +43 -224
  42. package/dist/mcp.js.map +1 -0
  43. package/dist/middleware.test.js +1 -0
  44. package/dist/middleware.test.js.map +1 -0
  45. package/dist/openapi-to-mcp.d.ts +38 -0
  46. package/dist/openapi-to-mcp.d.ts.map +1 -0
  47. package/dist/openapi-to-mcp.js +367 -0
  48. package/dist/openapi-to-mcp.js.map +1 -0
  49. package/dist/openapi.d.ts.map +1 -1
  50. package/dist/openapi.js +7 -2
  51. package/dist/openapi.js.map +1 -0
  52. package/dist/openapi.test.js +32 -31
  53. package/dist/openapi.test.js.map +1 -0
  54. package/dist/simple.benchmark.js +1 -0
  55. package/dist/simple.benchmark.js.map +1 -0
  56. package/dist/spiceflow.d.ts +5 -2
  57. package/dist/spiceflow.d.ts.map +1 -1
  58. package/dist/spiceflow.js +26 -6
  59. package/dist/spiceflow.js.map +1 -0
  60. package/dist/spiceflow.test.js +15 -3
  61. package/dist/spiceflow.test.js.map +1 -0
  62. package/dist/static-node.js +1 -0
  63. package/dist/static-node.js.map +1 -0
  64. package/dist/static.benchmark.js +1 -0
  65. package/dist/static.benchmark.js.map +1 -0
  66. package/dist/static.js +1 -0
  67. package/dist/static.js.map +1 -0
  68. package/dist/stream.test.js +1 -0
  69. package/dist/stream.test.js.map +1 -0
  70. package/dist/types.js +1 -0
  71. package/dist/types.js.map +1 -0
  72. package/dist/types.test.js +1 -0
  73. package/dist/types.test.js.map +1 -0
  74. package/dist/utils.js +1 -0
  75. package/dist/utils.js.map +1 -0
  76. package/dist/waitUntil.test.d.ts +2 -0
  77. package/dist/waitUntil.test.d.ts.map +1 -0
  78. package/dist/waitUntil.test.js +142 -0
  79. package/dist/waitUntil.test.js.map +1 -0
  80. package/dist/zod.test.js +1 -0
  81. package/dist/zod.test.js.map +1 -0
  82. package/package.json +4 -3
  83. package/src/_node-server.ts +1 -2
  84. package/src/client/index.ts +9 -7
  85. package/src/context.ts +6 -3
  86. package/src/index.ts +1 -1
  87. package/src/mcp-client-transport.ts +184 -0
  88. package/src/mcp.ts +49 -307
  89. package/src/openapi-to-mcp.ts +510 -0
  90. package/src/openapi.test.ts +31 -31
  91. package/src/openapi.ts +9 -3
  92. package/src/spiceflow.test.ts +18 -4
  93. package/src/spiceflow.ts +42 -15
  94. package/src/waitUntil.test.ts +168 -0
  95. package/dist/serialize.d.ts +0 -2
  96. package/dist/serialize.d.ts.map +0 -1
  97. package/dist/serialize.js +0 -9
  98. package/src/serialize.ts +0 -10
package/src/openapi.ts CHANGED
@@ -121,8 +121,10 @@ const registerSchemaPath = ({
121
121
  schema,
122
122
  route,
123
123
  models,
124
+ basePath,
124
125
  }: {
125
126
  schema: Partial<OpenAPIV3.PathsObject>
127
+ basePath: string
126
128
  route: InternalRoute
127
129
  models: Record<string, TypeSchema>
128
130
  }) => {
@@ -377,6 +379,7 @@ export const openapi = <Path extends string = '/openapi'>({
377
379
  const relativePath = path.startsWith('/') ? path.slice(1) : path
378
380
 
379
381
  const app = new Spiceflow({ name: 'openapi' }).get(path, ({}) => {
382
+ const basePath = app.topLevelApp!.basePath // TODO this does not work
380
383
  let routes = app.getAllRoutes()
381
384
  if (routes.length !== totalRoutes) {
382
385
  const ALLOWED_METHODS = [
@@ -392,6 +395,7 @@ export const openapi = <Path extends string = '/openapi'>({
392
395
  totalRoutes = routes.length
393
396
 
394
397
  routes.forEach((route: InternalRoute) => {
398
+ if (route.path.startsWith('_mcp_')) return
395
399
  if (route.hooks?.detail?.hide === true) return
396
400
  // TODO: route.hooks?.detail?.hide !== false add ability to hide: false to prevent excluding
397
401
  if (excludeMethods.includes(route.method)) return
@@ -404,9 +408,10 @@ export const openapi = <Path extends string = '/openapi'>({
404
408
  if (route.method === 'ALL') {
405
409
  ALLOWED_METHODS.forEach((method) => {
406
410
  registerSchemaPath({
411
+ basePath,
407
412
  schema,
408
413
  route: { ...route, method },
409
- // @ts-ignore
414
+
410
415
  models: app.definitions?.type,
411
416
  })
412
417
  })
@@ -414,6 +419,7 @@ export const openapi = <Path extends string = '/openapi'>({
414
419
  }
415
420
 
416
421
  registerSchemaPath({
422
+ basePath,
417
423
  schema,
418
424
  route,
419
425
  // @ts-ignore
@@ -473,9 +479,9 @@ function getJsonSchema(schema: TypeSchema) {
473
479
  return rest as any
474
480
  }
475
481
  if (isZodSchema(schema)) {
476
- let jsonSchema = zodToJsonSchema(schema, {
482
+ let jsonSchema = zodToJsonSchema(schema as any, {
477
483
  removeAdditionalStrategy: 'strict',
478
- })
484
+ }) as any
479
485
  const { $schema, ...rest } = jsonSchema
480
486
  return rest as any
481
487
  }
@@ -36,6 +36,18 @@ test('* param is a path without front slash', async () => {
36
36
  }
37
37
  })
38
38
 
39
+ test('should error if passing .request option to .route with method GET', () => {
40
+ new Spiceflow().route({
41
+ method: 'GET',
42
+ path: '/abc',
43
+ handler: () => 'ok',
44
+ // @ts-expect-error .request is not allowed for GET routes
45
+ request: z.object({
46
+ abc: z.string(),
47
+ }),
48
+ })
49
+ })
50
+
39
51
  test('this works to reference app in handler', async () => {
40
52
  const res = await new Spiceflow()
41
53
  .route({
@@ -600,7 +612,7 @@ test('getRouteAndParents', async () => {
600
612
  let routers = bfs(app)
601
613
  let last = routers[routers.length - 1]
602
614
 
603
- expect(app['getAppAndParents'](last).map((x) => x.prefix))
615
+ expect(app['getAppAndParents'](last).map((x) => x.basePath))
604
616
  .toMatchInlineSnapshot(`
605
617
  [
606
618
  "/one",
@@ -626,7 +638,7 @@ test('getAppsInScope include all parent apps', async () => {
626
638
  let routers = bfs(app)
627
639
  let secondLast = routers[routers.length - 2]
628
640
 
629
- expect(app['getAppsInScope'](secondLast).map((x) => x.prefix))
641
+ expect(app['getAppsInScope'](secondLast).map((x) => x.basePath))
630
642
  .toMatchInlineSnapshot(`
631
643
  [
632
644
  "/one",
@@ -652,7 +664,7 @@ test('getAppsInScope include all parent apps and non scoped apps', async () => {
652
664
  let routers = bfs(app)
653
665
  let secondLast = routers[routers.length - 2]
654
666
 
655
- expect(app['getAppsInScope'](secondLast).map((x) => x.prefix))
667
+ expect(app['getAppsInScope'](secondLast).map((x) => x.basePath))
656
668
  .toMatchInlineSnapshot(`
657
669
  [
658
670
  "/one",
@@ -932,7 +944,9 @@ describe('safePath', () => {
932
944
  expect(app.safePath('/posts')).toBe('/posts')
933
945
  // @ts-expect-error
934
946
  app.safePath('/posts/*')
935
- expect(app.safePath('/posts/*', { '*': 'some/key' })).toBe('/posts/some/key')
947
+ expect(app.safePath('/posts/*', { '*': 'some/key' })).toBe(
948
+ '/posts/some/key',
949
+ )
936
950
  })
937
951
 
938
952
  test('handles paths with required parameters', () => {
package/src/spiceflow.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { copy } from 'copy-anything'
2
+ import superjson from 'superjson'
2
3
 
3
4
  import { SpiceflowFetchError } from './client/errors.ts'
4
5
  import { ValidationError } from './error.ts'
@@ -34,7 +35,7 @@ import { StandardSchemaV1 } from '@standard-schema/spec'
34
35
  import type { IncomingMessage, ServerResponse } from 'node:http'
35
36
  import { handleForNode, listenForNode } from 'spiceflow/_node-server'
36
37
  import { MiddlewareContext } from './context.ts'
37
- import { superjsonSerialize } from './serialize.ts'
38
+
38
39
  import { isAsyncIterable, isResponse, redirect } from './utils.ts'
39
40
 
40
41
  let globalIndex = 0
@@ -46,6 +47,8 @@ export type SpiceflowServerError =
46
47
  | SpiceflowFetchError<number, any>
47
48
  | Error
48
49
 
50
+ export type WaitUntil = (promise: Promise<any>) => void
51
+
49
52
  type OnError = (x: {
50
53
  error: SpiceflowServerError
51
54
  request: Request
@@ -104,7 +107,8 @@ export class Spiceflow<
104
107
  private onErrorHandlers: OnError[] = []
105
108
  private routes: InternalRoute[] = []
106
109
  private defaultState: Record<any, any> = {}
107
- topLevelApp?: AnySpiceflow
110
+ topLevelApp?: AnySpiceflow = this
111
+ private waitUntilFn: WaitUntil
108
112
 
109
113
  _types = {
110
114
  Prefix: '' as BasePath,
@@ -117,7 +121,7 @@ export class Spiceflow<
117
121
  }
118
122
 
119
123
  /** @internal */
120
- prefix?: string
124
+ basePath?: string = ''
121
125
 
122
126
  /** @internal */
123
127
  childrenApps: AnySpiceflow[] = []
@@ -128,7 +132,7 @@ export class Spiceflow<
128
132
  const allApps = bfs(root) || []
129
133
  const allRoutes = allApps.flatMap((x) => {
130
134
  const prefix = this.getAppAndParents(x)
131
- .map((x) => x.prefix)
135
+ .map((x) => x.basePath)
132
136
  .join('')
133
137
 
134
138
  return x.routes.map((x) => ({ ...x, path: prefix + x.path }))
@@ -181,7 +185,7 @@ export class Spiceflow<
181
185
  const result = bfsFind(this, (app) => {
182
186
  app.topLevelApp = root
183
187
  let prefix = this.getAppAndParents(app)
184
- .map((x) => x.prefix)
188
+ .map((x) => x.basePath)
185
189
  .join('')
186
190
  .replace(/\/$/, '')
187
191
  if (prefix && !path.startsWith(prefix)) {
@@ -283,13 +287,22 @@ export class Spiceflow<
283
287
  options: {
284
288
  name?: string
285
289
  scoped?: Scoped
286
-
290
+ waitUntil?: WaitUntil
287
291
  basePath?: BasePath
288
292
  } = {},
289
293
  ) {
290
294
  this.scoped = options.scoped
291
-
292
- this.prefix = options.basePath
295
+
296
+ // Set up waitUntil function - use provided one, global one, or noop
297
+ this.waitUntilFn = options.waitUntil ||
298
+ (typeof globalThis !== 'undefined' && 'waitUntil' in globalThis
299
+ ? (globalThis as any).waitUntil
300
+ : () => {})
301
+
302
+ this.basePath = options.basePath || ''
303
+ if (this.basePath === '/') {
304
+ this.basePath = ''
305
+ }
293
306
  }
294
307
 
295
308
  post<
@@ -454,13 +467,16 @@ export class Spiceflow<
454
467
  path: Path
455
468
  method: Method
456
469
  handler: Handle
457
- } & LocalHook<
458
- LocalSchema,
459
- Schema,
460
- Singleton,
461
- Definitions['error'],
462
- Metadata['macro'],
463
- JoinPath<BasePath, Path>
470
+ } & Omit<
471
+ LocalHook<
472
+ LocalSchema,
473
+ Schema,
474
+ Singleton,
475
+ Definitions['error'],
476
+ Metadata['macro'],
477
+ JoinPath<BasePath, Path>
478
+ >,
479
+ Method extends 'GET' | 'HEAD' ? 'request' : never
464
480
  >,
465
481
  ): Spiceflow<
466
482
  BasePath,
@@ -778,6 +794,7 @@ export class Spiceflow<
778
794
 
779
795
  use(appOrHandler) {
780
796
  if (appOrHandler instanceof Spiceflow) {
797
+ appOrHandler.topLevelApp = this
781
798
  this.childrenApps.push(appOrHandler)
782
799
  } else if (typeof appOrHandler === 'function') {
783
800
  this.middlewares ??= []
@@ -843,6 +860,7 @@ export class Spiceflow<
843
860
  query: parseQuery((u.search || '').slice(1)),
844
861
  params: _params,
845
862
  redirect,
863
+ waitUntil: this.waitUntilFn,
846
864
  } satisfies MiddlewareContext<any>
847
865
  let handlerResponse: Response | undefined
848
866
  async function getResForError(err: any) {
@@ -1422,3 +1440,12 @@ function parseQuery(queryString: string) {
1422
1440
  export function cloneDeep(x) {
1423
1441
  return copy(x)
1424
1442
  }
1443
+
1444
+ function superjsonSerialize(value: any, indent = false) {
1445
+ // return JSON.stringify(value)
1446
+ const { json, meta } = superjson.serialize(value)
1447
+ if (json && meta) {
1448
+ json['__superjsonMeta'] = meta
1449
+ }
1450
+ return JSON.stringify(json ?? null, null, indent ? 2 : undefined)
1451
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { Spiceflow } from './spiceflow.ts'
3
+
4
+ describe('waitUntil', () => {
5
+ test('waitUntil is available in handler context', async () => {
6
+ let waitUntilCalled = false
7
+ let waitUntilPromise: Promise<any> | null = null
8
+
9
+ const mockWaitUntil = vi.fn((promise: Promise<any>) => {
10
+ waitUntilCalled = true
11
+ waitUntilPromise = promise
12
+ })
13
+
14
+ const app = new Spiceflow({
15
+ waitUntil: mockWaitUntil
16
+ }).route({
17
+ method: 'GET',
18
+ path: '/test',
19
+ handler({ waitUntil }) {
20
+ expect(typeof waitUntil).toBe('function')
21
+
22
+ const backgroundTask = Promise.resolve('background work done')
23
+ waitUntil(backgroundTask)
24
+
25
+ return { success: true }
26
+ }
27
+ })
28
+
29
+ const response = await app.handle(new Request('http://localhost/test'))
30
+ const data = await response.json()
31
+
32
+ expect(data).toEqual({ success: true })
33
+ expect(waitUntilCalled).toBe(true)
34
+ expect(mockWaitUntil).toHaveBeenCalledTimes(1)
35
+ expect(waitUntilPromise).toBeInstanceOf(Promise)
36
+ })
37
+
38
+ test('waitUntil defaults to noop when not provided', async () => {
39
+ const app = new Spiceflow().route({
40
+ method: 'GET',
41
+ path: '/test',
42
+ handler({ waitUntil }) {
43
+ expect(typeof waitUntil).toBe('function')
44
+
45
+ // Should not throw when called
46
+ waitUntil(Promise.resolve('test'))
47
+
48
+ return { success: true }
49
+ }
50
+ })
51
+
52
+ const response = await app.handle(new Request('http://localhost/test'))
53
+ const data = await response.json()
54
+
55
+ expect(data).toEqual({ success: true })
56
+ })
57
+
58
+ test('waitUntil can be called multiple times', async () => {
59
+ const mockWaitUntil = vi.fn()
60
+
61
+ const app = new Spiceflow({
62
+ waitUntil: mockWaitUntil
63
+ }).route({
64
+ method: 'POST',
65
+ path: '/multi',
66
+ handler({ waitUntil }) {
67
+ waitUntil(Promise.resolve('task 1'))
68
+ waitUntil(Promise.resolve('task 2'))
69
+ waitUntil(Promise.resolve('task 3'))
70
+
71
+ return { taskCount: 3 }
72
+ }
73
+ })
74
+
75
+ const response = await app.handle(new Request('http://localhost/multi', {
76
+ method: 'POST'
77
+ }))
78
+ const data = await response.json()
79
+
80
+ expect(data).toEqual({ taskCount: 3 })
81
+ expect(mockWaitUntil).toHaveBeenCalledTimes(3)
82
+ })
83
+
84
+ test('waitUntil works with middleware context', async () => {
85
+ const mockWaitUntil = vi.fn()
86
+
87
+ const app = new Spiceflow({
88
+ waitUntil: mockWaitUntil
89
+ })
90
+ .use(({ waitUntil }, next) => {
91
+ expect(typeof waitUntil).toBe('function')
92
+ waitUntil(Promise.resolve('middleware task'))
93
+ return next()
94
+ })
95
+ .route({
96
+ method: 'GET',
97
+ path: '/middleware',
98
+ handler({ waitUntil }) {
99
+ waitUntil(Promise.resolve('handler task'))
100
+ return { success: true }
101
+ }
102
+ })
103
+
104
+ const response = await app.handle(new Request('http://localhost/middleware'))
105
+ const data = await response.json()
106
+
107
+ expect(data).toEqual({ success: true })
108
+ expect(mockWaitUntil).toHaveBeenCalledTimes(2)
109
+ })
110
+
111
+ test('waitUntil uses global waitUntil when available', async () => {
112
+ // Mock global waitUntil
113
+ const originalGlobal = globalThis as any
114
+ const mockGlobalWaitUntil = vi.fn()
115
+ originalGlobal.waitUntil = mockGlobalWaitUntil
116
+
117
+ try {
118
+ const app = new Spiceflow().route({
119
+ method: 'GET',
120
+ path: '/global',
121
+ handler({ waitUntil }) {
122
+ waitUntil(Promise.resolve('using global'))
123
+ return { usingGlobal: true }
124
+ }
125
+ })
126
+
127
+ const response = await app.handle(new Request('http://localhost/global'))
128
+ const data = await response.json()
129
+
130
+ expect(data).toEqual({ usingGlobal: true })
131
+ expect(mockGlobalWaitUntil).toHaveBeenCalledTimes(1)
132
+ } finally {
133
+ // Clean up
134
+ delete originalGlobal.waitUntil
135
+ }
136
+ })
137
+
138
+ test('custom waitUntil overrides global waitUntil', async () => {
139
+ // Mock global waitUntil
140
+ const originalGlobal = globalThis as any
141
+ const mockGlobalWaitUntil = vi.fn()
142
+ const mockCustomWaitUntil = vi.fn()
143
+ originalGlobal.waitUntil = mockGlobalWaitUntil
144
+
145
+ try {
146
+ const app = new Spiceflow({
147
+ waitUntil: mockCustomWaitUntil
148
+ }).route({
149
+ method: 'GET',
150
+ path: '/custom',
151
+ handler({ waitUntil }) {
152
+ waitUntil(Promise.resolve('using custom'))
153
+ return { usingCustom: true }
154
+ }
155
+ })
156
+
157
+ const response = await app.handle(new Request('http://localhost/custom'))
158
+ const data = await response.json()
159
+
160
+ expect(data).toEqual({ usingCustom: true })
161
+ expect(mockCustomWaitUntil).toHaveBeenCalledTimes(1)
162
+ expect(mockGlobalWaitUntil).not.toHaveBeenCalled()
163
+ } finally {
164
+ // Clean up
165
+ delete originalGlobal.waitUntil
166
+ }
167
+ })
168
+ })
@@ -1,2 +0,0 @@
1
- export declare function superjsonSerialize(value: any, indent?: boolean): string;
2
- //# sourceMappingURL=serialize.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"AAEA,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,UAAQ,UAO5D"}
package/dist/serialize.js DELETED
@@ -1,9 +0,0 @@
1
- import superjson from 'superjson';
2
- export function superjsonSerialize(value, indent = false) {
3
- // return JSON.stringify(value)
4
- const { json, meta } = superjson.serialize(value);
5
- if (json && meta) {
6
- json['__superjsonMeta'] = meta;
7
- }
8
- return JSON.stringify(json ?? null, null, indent ? 2 : undefined);
9
- }
package/src/serialize.ts DELETED
@@ -1,10 +0,0 @@
1
- import superjson from 'superjson'
2
-
3
- export function superjsonSerialize(value: any, indent = false) {
4
- // return JSON.stringify(value)
5
- const { json, meta } = superjson.serialize(value)
6
- if (json && meta) {
7
- json['__superjsonMeta'] = meta
8
- }
9
- return JSON.stringify(json ?? null, null, indent ? 2 : undefined)
10
- }