spiceflow 1.1.2 → 1.1.4

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 (52) hide show
  1. package/README.md +54 -23
  2. package/dist/benchmark.benchmark.d.ts +2 -0
  3. package/dist/benchmark.benchmark.d.ts.map +1 -0
  4. package/dist/benchmark.benchmark.js +13 -0
  5. package/dist/benchmark.benchmark.js.map +1 -0
  6. package/dist/context.d.ts +9 -26
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/cors.d.ts +22 -0
  9. package/dist/cors.d.ts.map +1 -0
  10. package/dist/cors.js +76 -0
  11. package/dist/cors.js.map +1 -0
  12. package/dist/cors.test.d.ts +2 -0
  13. package/dist/cors.test.d.ts.map +1 -0
  14. package/dist/cors.test.js +39 -0
  15. package/dist/cors.test.js.map +1 -0
  16. package/dist/error.d.ts +0 -4
  17. package/dist/error.d.ts.map +1 -1
  18. package/dist/error.js +0 -7
  19. package/dist/error.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/middleware.test.js +64 -0
  25. package/dist/middleware.test.js.map +1 -1
  26. package/dist/openapi.d.ts +1 -1
  27. package/dist/spiceflow.d.ts +5 -16
  28. package/dist/spiceflow.d.ts.map +1 -1
  29. package/dist/spiceflow.js +70 -55
  30. package/dist/spiceflow.js.map +1 -1
  31. package/dist/spiceflow.test.js +60 -4
  32. package/dist/spiceflow.test.js.map +1 -1
  33. package/dist/types.d.ts +23 -53
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/package.json +23 -1
  37. package/src/benchmark.benchmark.ts +16 -0
  38. package/src/context.ts +10 -28
  39. package/src/cors.test.ts +44 -0
  40. package/src/cors.ts +115 -0
  41. package/src/error.ts +0 -8
  42. package/src/index.ts +1 -0
  43. package/src/middleware.test.ts +68 -0
  44. package/src/spiceflow.test.ts +81 -4
  45. package/src/spiceflow.ts +83 -109
  46. package/src/types.test.ts +1 -0
  47. package/src/types.ts +30 -55
  48. package/dist/benchmark.test.d.ts +0 -2
  49. package/dist/benchmark.test.d.ts.map +0 -1
  50. package/dist/benchmark.test.js +0 -8
  51. package/dist/benchmark.test.js.map +0 -1
  52. package/src/benchmark.test.ts +0 -8
@@ -23,6 +23,22 @@ test('middleware with next changes the response', async () => {
23
23
  expect(await res.json()).toEqual('hi')
24
24
  expect(res.headers.get('x-test')).toBe('ok')
25
25
  })
26
+ test('middleware next returns a response even for 404, if there are no routes', async () => {
27
+ const res = await new Spiceflow()
28
+ .use(async ({ request }, next) => {
29
+ expect(request.method).toBe('GET')
30
+ const res = await next()
31
+ expect(res).toBeInstanceOf(Response)
32
+ if (res) {
33
+ res.headers.set('x-test', 'ok')
34
+ }
35
+ return res
36
+ })
37
+ .handle(new Request('http://localhost/non-existent', { method: 'GET' }))
38
+ expect(res.status).toBe(404)
39
+ expect(res.headers.get('x-test')).toBe('ok')
40
+ expect(await res.text()).toContain('Not Found')
41
+ })
26
42
 
27
43
  test('middleware without next runs the next middleware and handler', async () => {
28
44
  let middlewaresCalled = [] as string[]
@@ -82,6 +98,29 @@ test('middleware stops other middlewares', async () => {
82
98
  expect(await res.text()).toEqual('ok')
83
99
  })
84
100
 
101
+ test('calling next and then returning a new response works', async () => {
102
+ let middlewaresCalled = [] as string[]
103
+ const res = await new Spiceflow()
104
+ .use(async (ctx, next) => {
105
+ middlewaresCalled.push('first')
106
+ await next()
107
+ return new Response('middleware response')
108
+ })
109
+ .use(async (ctx, next) => {
110
+ middlewaresCalled.push('second')
111
+ return next()
112
+ })
113
+ .get('/ids/:id', () => {
114
+ middlewaresCalled.push('handler')
115
+ return 'handler response'
116
+ })
117
+
118
+ .handle(new Request('http://localhost/ids/xxx', { method: 'GET' }))
119
+ expect(res.status).toBe(200)
120
+ expect(middlewaresCalled).toEqual(['first', 'second', 'handler'])
121
+ expect(await res.text()).toEqual('middleware response')
122
+ })
123
+
85
124
  test('middleware changes handler response body', async () => {
86
125
  let middlewaresCalled = [] as string[]
87
126
  const res = await new Spiceflow()
@@ -105,3 +144,32 @@ test('middleware changes handler response body', async () => {
105
144
  expect(middlewaresCalled).toEqual(['first', 'second'])
106
145
  expect(await res.json()).toEqual('HELLO WORLD')
107
146
  })
147
+
148
+ test('mutating response returned by next without returning it works', async () => {
149
+ let handlerCalledTimes = 0
150
+ const res = await new Spiceflow()
151
+ .use(async (ctx, next) => {
152
+ const response = await next()
153
+ if (response) {
154
+ response.headers.set('X-Custom-Header', 'Modified')
155
+ }
156
+ // Not returning the response, letting it pass through
157
+ })
158
+ .use(async (ctx, next) => {
159
+ const response = await next()
160
+ if (response) {
161
+ response.headers.set('X-Another-Header', 'Added')
162
+ }
163
+ })
164
+ .get('/test', () => {
165
+ handlerCalledTimes++
166
+ return 'hello world'
167
+ })
168
+ .handle(new Request('http://localhost/test'))
169
+
170
+ expect(res.status).toBe(200)
171
+ expect(handlerCalledTimes).toBe(1)
172
+ expect(await res.json()).toBe('hello world')
173
+ expect(res.headers.get('X-Custom-Header')).toBe('Modified')
174
+ expect(res.headers.get('X-Another-Header')).toBe('Added')
175
+ })
@@ -28,6 +28,83 @@ test('GET dynamic route', async () => {
28
28
  expect(await res.json()).toEqual('hi')
29
29
  })
30
30
 
31
+ test('onError does not fire on 404', async () => {
32
+ let errorFired = false
33
+ const app = new Spiceflow()
34
+ .get('/test', () => 'Hello')
35
+ .onError(() => {
36
+ errorFired = true
37
+ return new Response('Error', { status: 500 })
38
+ })
39
+
40
+ const res = await app.handle(
41
+ new Request('http://localhost/non-existent', { method: 'GET' }),
42
+ )
43
+
44
+ expect(res.status).toBe(404)
45
+ expect(errorFired).toBe(false)
46
+ expect(await res.text()).toBe('Not Found')
47
+ })
48
+
49
+ test('onError fires on validation errors', async () => {
50
+ let errorMessage = ''
51
+ const app = new Spiceflow()
52
+ .post(
53
+ '/test',
54
+ async ({ request }) => {
55
+ await request.json()
56
+ return 'Success'
57
+ },
58
+ {
59
+ body: z.object({
60
+ name: z.string(),
61
+ }),
62
+ },
63
+ )
64
+ .onError(({ error }) => {
65
+ errorMessage = error.message
66
+ return new Response('Error', { status: 400 })
67
+ })
68
+
69
+ const res = await app.handle(
70
+ new Request('http://localhost/test', {
71
+ method: 'POST',
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ body: JSON.stringify({ name: 1 }), // Invalid type for 'name'
76
+ }),
77
+ )
78
+
79
+ expect(res.status).toBe(400)
80
+ expect(errorMessage).toContain('data/name must be string')
81
+ expect(await res.text()).toBe('Error')
82
+ })
83
+
84
+ test.todo('HEAD uses GET route, does not add body', async () => {
85
+ const app = new Spiceflow().get('/ids/:id', () => {
86
+ console.trace('GET')
87
+ return {
88
+ message: 'hi',
89
+ length: 10,
90
+ }
91
+ })
92
+
93
+ const res = await app.handle(
94
+ new Request('http://localhost/ids/xxx', { method: 'HEAD' }),
95
+ )
96
+ expect(res.status).toBe(200)
97
+ // expect(res.headers.get('Content-Length')).toBe('10')
98
+ expect(await res.text()).toBe('')
99
+
100
+ // Compare with GET to ensure HEAD is using GET route
101
+ const getRes = await app.handle(
102
+ new Request('http://localhost/ids/xxx', { method: 'GET' }),
103
+ )
104
+ expect(getRes.status).toBe(200)
105
+ expect(await getRes.json()).toEqual({ message: 'hi', length: 10 })
106
+ })
107
+
31
108
  test('GET with query, untyped', async () => {
32
109
  const res = await new Spiceflow()
33
110
  .get('/query', ({ query }) => {
@@ -118,11 +195,11 @@ test('missing route is not found', async () => {
118
195
  test('state works', async () => {
119
196
  const res = await new Spiceflow()
120
197
  .state('id', '')
121
- .use(({ store, request }) => {
122
- store.id = 'xxx'
198
+ .use(({ state, request }) => {
199
+ state.id = 'xxx'
123
200
  })
124
- .get('/get', ({ store }) => {
125
- expect(store.id).toBe('xxx')
201
+ .get('/get', ({ state: state }) => {
202
+ expect(state.id).toBe('xxx')
126
203
  })
127
204
  .handle(new Request('http://localhost/get'))
128
205
  expect(res.status).toBe(200)
package/src/spiceflow.ts CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  JoinPath,
18
18
  LocalHook,
19
19
  MaybeArray,
20
- MergeSchema,
21
20
  MetadataBase,
22
21
  MiddlewareHandler,
23
22
  Reconcile,
@@ -30,14 +29,12 @@ import {
30
29
  } from './types.js'
31
30
  let globalIndex = 0
32
31
 
33
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
34
- // @ts-ignore
35
32
  import OriginalRouter from '@medley/router'
36
33
  import Ajv, { ValidateFunction } from 'ajv'
37
34
  import { z, ZodType } from 'zod'
38
35
  import { zodToJsonSchema } from 'zod-to-json-schema'
39
36
  import { Context, MiddlewareContext } from './context.js'
40
- import { isProduction, NotFoundError, ValidationError } from './error.js'
37
+ import { isProduction, ValidationError } from './error.js'
41
38
  import { isAsyncIterable, redirect } from './utils.js'
42
39
 
43
40
  const ajv = (addFormats.default || addFormats)(
@@ -74,9 +71,7 @@ export type InternalRoute = {
74
71
  validateBody?: ValidateFunction
75
72
  validateQuery?: ValidateFunction
76
73
  validateParams?: ValidateFunction
77
- prefix: string
78
-
79
- // store: Record<any, any>
74
+ // prefix: string
80
75
  }
81
76
 
82
77
  type MedleyRouter = {
@@ -88,14 +83,16 @@ type MedleyRouter = {
88
83
  | undefined
89
84
  register: (path: string | undefined) => Record<string, InternalRoute>
90
85
  }
91
- /**
92
- * Router class
93
- */
86
+
87
+ const notFoundHandler = (c) => {
88
+ return new Response('Not Found', { status: 404 })
89
+ }
90
+
94
91
  export class Spiceflow<
95
92
  const in out BasePath extends string = '',
96
93
  const in out Scoped extends boolean = true,
97
94
  const in out Singleton extends SingletonBase = {
98
- store: {}
95
+ state: {}
99
96
  },
100
97
  const in out Definitions extends DefinitionBase = {
101
98
  type: {}
@@ -113,7 +110,7 @@ export class Spiceflow<
113
110
  private middlewares: Function[] = []
114
111
  private onErrorHandlers: OnError[] = []
115
112
  private routes: InternalRoute[] = []
116
- private defaultStore: Record<any, any> = {}
113
+ private defaultState: Record<any, any> = {}
117
114
  private topLevelApp?: AnySpiceflow
118
115
 
119
116
  /** @internal */
@@ -151,7 +148,6 @@ export class Spiceflow<
151
148
  const store = this.router.register(path)
152
149
  let route: InternalRoute = {
153
150
  ...rest,
154
- prefix: this.prefix || '',
155
151
  method: (method || '') as any,
156
152
  path: path || '',
157
153
  handler: handler!,
@@ -166,6 +162,7 @@ export class Spiceflow<
166
162
 
167
163
  private match(method: string, path: string) {
168
164
  let root = this
165
+ let foundApp: AnySpiceflow | undefined
169
166
  const result = bfsFind(this, (app) => {
170
167
  app.topLevelApp = root
171
168
  let prefix = this.getAppAndParents(app)
@@ -180,10 +177,12 @@ export class Spiceflow<
180
177
  }
181
178
  const medleyRoute = app.router.find(pathWithoutPrefix)
182
179
  if (!medleyRoute) {
180
+ foundApp = app
183
181
  return
184
182
  }
185
183
 
186
184
  let internalRoute: InternalRoute = medleyRoute.store[method]
185
+
187
186
  if (internalRoute) {
188
187
  const params = medleyRoute.params || {}
189
188
 
@@ -194,9 +193,46 @@ export class Spiceflow<
194
193
  }
195
194
  return res
196
195
  }
196
+ if (method === 'HEAD') {
197
+ let internalRouteGet: InternalRoute = medleyRoute.store['GET']
198
+ if (!internalRouteGet?.handler) {
199
+ return
200
+ }
201
+ return {
202
+ app,
203
+ internalRoute: {
204
+ hooks: {},
205
+ handler: async (c) => {
206
+ const response = await internalRouteGet.handler(c)
207
+ if (response instanceof Response) {
208
+ return new Response('', {
209
+ status: response.status,
210
+ statusText: response.statusText,
211
+ headers: response.headers,
212
+ })
213
+ }
214
+ return new Response(null, { status: 200 })
215
+ },
216
+ method,
217
+ path,
218
+ } as InternalRoute,
219
+ params: medleyRoute.params,
220
+ }
221
+ }
197
222
  })
198
223
 
199
- return result
224
+ return (
225
+ result || {
226
+ app: foundApp || root,
227
+ internalRoute: {
228
+ hooks: {},
229
+ handler: notFoundHandler,
230
+ method,
231
+ path,
232
+ } as InternalRoute,
233
+ params: {},
234
+ }
235
+ )
200
236
  }
201
237
 
202
238
  state<const Name extends string | number | symbol, Value>(
@@ -206,8 +242,8 @@ export class Spiceflow<
206
242
  BasePath,
207
243
  Scoped,
208
244
  {
209
- store: Reconcile<
210
- Singleton['store'],
245
+ state: Reconcile<
246
+ Singleton['state'],
211
247
  {
212
248
  [name in Name]: Value
213
249
  }
@@ -217,7 +253,7 @@ export class Spiceflow<
217
253
  Metadata,
218
254
  Routes
219
255
  > {
220
- this.defaultStore[name] = value
256
+ this.defaultState[name] = value
221
257
  return this as any
222
258
  }
223
259
 
@@ -667,45 +703,6 @@ export class Spiceflow<
667
703
 
668
704
  private scoped?: Scoped = true as Scoped
669
705
 
670
- // group is not needed, you can add another prefixed app instead
671
- // group<
672
- // const Prefix extends string,
673
- // const NewSpiceflow extends Spiceflow<any, any, any, any, any, any, any, any>
674
- // >(
675
- // prefix: Prefix,
676
- // run: (
677
- // group: Spiceflow<
678
- // `${BasePath}${Prefix}`,
679
- // Scoped,
680
- // Singleton,
681
- // Definitions,
682
- // Metadata,
683
- // {},
684
- // Ephemeral,
685
- // Volatile
686
- // >
687
- // ) => NewSpiceflow
688
- // ): Spiceflow<
689
- // BasePath,
690
- // Scoped,
691
- // Singleton,
692
- // Definitions,
693
- // Metadata,
694
- // Prettify<Routes & NewSpiceflow['_routes']>,
695
- // Ephemeral,
696
- // Volatile
697
- // > {
698
- // let thisRouter = this.routers[0]
699
- // this.routers.push(
700
- // ...instance.routers.map((r) => ({
701
- // ...r,
702
- // prefix: (thisRouter.prefix || '') + r.prefix
703
- // }))
704
- // )
705
-
706
- // return this
707
- // }
708
-
709
706
  use<const NewSpiceflow extends AnySpiceflow>(
710
707
  instance: NewSpiceflow,
711
708
  ): IsAny<NewSpiceflow> extends true
@@ -725,7 +722,7 @@ export class Spiceflow<
725
722
  MiddlewareHandler<
726
723
  Schema,
727
724
  {
728
- store: Singleton['store']
725
+ state: Singleton['state']
729
726
  }
730
727
  >
731
728
  >,
@@ -752,12 +749,6 @@ export class Spiceflow<
752
749
  return this
753
750
  }
754
751
 
755
- /**
756
- * Pass a request through all matching route handles and return a response
757
- * @param request The `Request`
758
- * @param platform Platform specific context {@link Platform}
759
- * @returns The final `Response`
760
- */
761
752
  async handle(request: Request): Promise<Response> {
762
753
  let u = new URL(request.url, 'http://localhost')
763
754
  let path = u.pathname + u.search
@@ -773,39 +764,27 @@ export class Spiceflow<
773
764
 
774
765
  const route = this.match(request.method, path)
775
766
 
776
- if (!route) {
777
- const error = new NotFoundError(`${path} not found`)
778
- const res = await this.runErrorHandlers({
779
- onErrorHandlers,
780
- error,
781
- request,
782
- })
783
- if (res) return res
784
- return new Response(`Not Found`, {
785
- status: 404,
786
- })
787
- }
788
767
  onErrorHandlers = this.getAppsInScope(route.app).flatMap(
789
768
  (x) => x.onErrorHandlers,
790
769
  )
791
770
  let {
792
771
  params,
793
- app: { defaultStore },
772
+ app: { defaultState: defaultStore },
794
773
  } = route
795
774
  const middlewares = this.getAppsInScope(route.app).flatMap(
796
775
  (x) => x.middlewares,
797
776
  )
798
777
  // console.log({ onReqHandlers })
799
- let store = { ...defaultStore }
778
+ let state = { ...defaultStore }
800
779
 
801
780
  let content = route?.internalRoute?.hooks?.content
802
781
 
803
782
  if (route.internalRoute?.validateBody) {
804
783
  // TODO don't clone the request
805
784
  let typedRequest =
806
- request instanceof TypedRequest
785
+ request instanceof SpiceflowRequest
807
786
  ? request
808
- : new TypedRequest(request)
787
+ : new SpiceflowRequest(request)
809
788
  typedRequest.validateBody = route.internalRoute?.validateBody
810
789
  request = typedRequest
811
790
  }
@@ -813,27 +792,31 @@ export class Spiceflow<
813
792
  let query = parseQuery.parse((u.search || '').slice(1))
814
793
 
815
794
  let index = 0
816
-
795
+ let context = {
796
+ ...defaultContext,
797
+ request,
798
+ state,
799
+ path,
800
+ query,
801
+ params,
802
+ redirect,
803
+ } satisfies MiddlewareContext<any>
804
+ let handlerResponse: Response | undefined
817
805
  const next = async () => {
818
806
  if (index < middlewares.length) {
819
807
  const middleware = middlewares[index]
820
808
  index++
821
- let context = {
822
- request,
823
- store,
824
- path,
825
- query,
826
- params,
827
- redirect,
828
- } satisfies MiddlewareContext<any>
809
+
829
810
  const result = await middleware(context, next)
830
811
 
831
812
  if (!result && index < middlewares.length) {
832
- return await next()
833
813
  } else if (result) {
834
814
  return await turnHandlerResultIntoResponse(result)
835
815
  }
836
816
  }
817
+ if (handlerResponse) {
818
+ return handlerResponse
819
+ }
837
820
 
838
821
  query = runValidation(query, route.internalRoute?.validateQuery)
839
822
  params = runValidation(
@@ -841,29 +824,18 @@ export class Spiceflow<
841
824
  route.internalRoute?.validateParams,
842
825
  )
843
826
 
844
- // console.log(route)
845
-
846
- const res = route.internalRoute?.handler({
847
- ...defaultContext,
848
- request,
849
- params: params as any,
850
- redirect,
851
- store,
852
- query,
853
- // body,
854
- path,
855
-
856
- // platform
857
- } satisfies Context<any, any, any>)
827
+ const res = route.internalRoute?.handler(context)
858
828
  if (isAsyncIterable(res)) {
859
- return await this.handleStream({
829
+ handlerResponse = await this.handleStream({
860
830
  generator: res,
861
831
  request,
862
832
  onErrorHandlers,
863
833
  })
834
+ return handlerResponse
864
835
  }
865
836
 
866
- return await turnHandlerResultIntoResponse(res)
837
+ handlerResponse = await turnHandlerResultIntoResponse(res)
838
+ return handlerResponse
867
839
  }
868
840
  const response = await next()
869
841
 
@@ -875,11 +847,13 @@ export class Spiceflow<
875
847
  error: err,
876
848
  request,
877
849
  })
878
- if (res) return res
850
+ if (res instanceof Response) return res
851
+
879
852
  let status = err?.status ?? 500
880
- return new Response(err?.message || 'Internal Server Error', {
853
+ res ||= new Response(err?.message || 'Internal Server Error', {
881
854
  status,
882
855
  })
856
+ return res
883
857
  }
884
858
  }
885
859
 
@@ -1003,7 +977,7 @@ export class Spiceflow<
1003
977
  req.url || '',
1004
978
  `http://${req.headers.host || hostname || 'localhost'}`,
1005
979
  )
1006
- const typedRequest = new TypedRequest(url.toString(), {
980
+ const typedRequest = new SpiceflowRequest(url.toString(), {
1007
981
  method: req.method,
1008
982
  headers: req.headers as HeadersInit,
1009
983
  body:
@@ -1223,7 +1197,7 @@ function bfsFind<T>(
1223
1197
  }
1224
1198
  return
1225
1199
  }
1226
- export class TypedRequest<T = any> extends Request {
1200
+ export class SpiceflowRequest<T = any> extends Request {
1227
1201
  validateBody?: ValidateFunction
1228
1202
 
1229
1203
  async json(): Promise<T> {
package/src/types.test.ts CHANGED
@@ -48,3 +48,4 @@ test('`use` on Spiceflow return', async () => {
48
48
  expect(res.status).toBe(200)
49
49
  expect(await res.json()).toEqual('hi')
50
50
  })
51
+