@mpen/routekit 0.1.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 (102) hide show
  1. package/README.md +526 -0
  2. package/dist/bin.mjs +574 -0
  3. package/package.json +95 -0
  4. package/src/bin/gen-api-client.test.ts +70 -0
  5. package/src/bin/gen-api-client.ts +986 -0
  6. package/src/client/headers.ts +31 -0
  7. package/src/client/index.ts +8 -0
  8. package/src/client/promise.ts +11 -0
  9. package/src/client/react/index.test.tsx +266 -0
  10. package/src/client/react/index.ts +431 -0
  11. package/src/client/responses.test.ts +151 -0
  12. package/src/client/responses.ts +278 -0
  13. package/src/client/transport.ts +74 -0
  14. package/src/client/transports/body-codec.ts +61 -0
  15. package/src/client/transports/fetch.ts +113 -0
  16. package/src/client/tsconfig.json +9 -0
  17. package/src/client/types.ts +15 -0
  18. package/src/client/url.ts +31 -0
  19. package/src/index.ts +63 -0
  20. package/src/router/fetch-types.ts +13 -0
  21. package/src/router/handlers/index.ts +2 -0
  22. package/src/router/handlers/openapi/index.ts +2 -0
  23. package/src/router/handlers/openapi/openapi.ts +293 -0
  24. package/src/router/integration/zod-openapi.test.ts +74 -0
  25. package/src/router/lib/charset.test.ts +22 -0
  26. package/src/router/lib/charset.ts +133 -0
  27. package/src/router/lib/collections.ts +3 -0
  28. package/src/router/lib/format.test.ts +67 -0
  29. package/src/router/lib/format.ts +35 -0
  30. package/src/router/lib/host.ts +4 -0
  31. package/src/router/lib/json-schema.ts +6 -0
  32. package/src/router/lib/media-type.test.ts +122 -0
  33. package/src/router/lib/media-type.ts +289 -0
  34. package/src/router/lib/pathname.test.ts +18 -0
  35. package/src/router/lib/pathname.ts +19 -0
  36. package/src/router/lib/route-names.ts +70 -0
  37. package/src/router/lib/route-normalize.test.ts +36 -0
  38. package/src/router/lib/route-normalize.ts +67 -0
  39. package/src/router/lib/schema-merge.ts +56 -0
  40. package/src/router/middleware/accept-ctx.test.ts +33 -0
  41. package/src/router/middleware/accept-ctx.ts +12 -0
  42. package/src/router/middleware/body-limit.test.ts +112 -0
  43. package/src/router/middleware/body-limit.ts +121 -0
  44. package/src/router/middleware/content-type-context.ts +0 -0
  45. package/src/router/middleware/cors.test.ts +269 -0
  46. package/src/router/middleware/cors.ts +490 -0
  47. package/src/router/middleware/csrf.test.ts +106 -0
  48. package/src/router/middleware/csrf.ts +192 -0
  49. package/src/router/middleware/define.ts +249 -0
  50. package/src/router/middleware/index.ts +34 -0
  51. package/src/router/middleware/jsxhtml-response.ts +0 -0
  52. package/src/router/middleware/oas-swagger.ts +0 -0
  53. package/src/router/middleware/rate-limit.test.ts +886 -0
  54. package/src/router/middleware/rate-limit.ts +920 -0
  55. package/src/router/middleware/request-id-ctx.test.ts +183 -0
  56. package/src/router/middleware/request-id-ctx.ts +135 -0
  57. package/src/router/middleware/request-logger-format.test.ts +16 -0
  58. package/src/router/middleware/request-logger-format.ts +269 -0
  59. package/src/router/middleware/request-logger.test.ts +267 -0
  60. package/src/router/middleware/request-logger.ts +131 -0
  61. package/src/router/middleware/start-time-ctx.ts +5 -0
  62. package/src/router/request.ts +611 -0
  63. package/src/router/response/core.ts +181 -0
  64. package/src/router/response/directives.ts +233 -0
  65. package/src/router/response/formats/content/bodyless.ts +54 -0
  66. package/src/router/response/formats/content/content.ts +79 -0
  67. package/src/router/response/formats/content/index.ts +2 -0
  68. package/src/router/response/formats/json-rpc/index.ts +2 -0
  69. package/src/router/response/formats/problem/badRequest.ts +90 -0
  70. package/src/router/response/formats/problem/conflict.ts +90 -0
  71. package/src/router/response/formats/problem/created.ts +40 -0
  72. package/src/router/response/formats/problem/index.ts +27 -0
  73. package/src/router/response/formats/problem/notFound.ts +90 -0
  74. package/src/router/response/formats/problem/permissionDenied.ts +90 -0
  75. package/src/router/response/formats/problem/problem.test.ts +888 -0
  76. package/src/router/response/formats/problem/rateLimited.ts +90 -0
  77. package/src/router/response/formats/problem/responses.ts +219 -0
  78. package/src/router/response/formats/problem/root-errors.ts +48 -0
  79. package/src/router/response/formats/problem/sessionExpired.ts +90 -0
  80. package/src/router/response/formats/problem/types.ts +170 -0
  81. package/src/router/response/formats/problem/unauthenticated.ts +90 -0
  82. package/src/router/response/formats/problem/valibot.ts +410 -0
  83. package/src/router/response/formats/status/index.ts +1 -0
  84. package/src/router/response/formats/status/responses.ts +59 -0
  85. package/src/router/response/formats/status/status.test.ts +21 -0
  86. package/src/router/response/framers.ts +85 -0
  87. package/src/router/response/index.ts +28 -0
  88. package/src/router/response/openapi.test.ts +96 -0
  89. package/src/router/response/openapi.ts +1 -0
  90. package/src/router/response/serializers.ts +66 -0
  91. package/src/router/response/stream.ts +35 -0
  92. package/src/router/router.test.ts +1571 -0
  93. package/src/router/router.ts +1965 -0
  94. package/src/router/routes/index.ts +46 -0
  95. package/src/router/routes/valibot/index.ts +18 -0
  96. package/src/router/routes/valibot/valibot.ts +1393 -0
  97. package/src/router/routes/valibot.test.ts +286 -0
  98. package/src/router/routes/zod/index.ts +18 -0
  99. package/src/router/routes/zod/zod.ts +1318 -0
  100. package/src/router/routes/zod.test.ts +280 -0
  101. package/src/router/server-interface.ts +31 -0
  102. package/src/router/types.ts +657 -0
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env -S bun test
2
+ import { describe, expect, it } from 'bun:test'
3
+ import { HttpMethod } from '@mpen/http'
4
+ import { Router } from '../router'
5
+ import { acceptCtx } from './accept-ctx'
6
+
7
+ describe(acceptCtx.name, () => {
8
+ it('adds parsed Accept header values to the context', async () => {
9
+ const router = new Router()
10
+
11
+ router.use(acceptCtx())
12
+ router.add({
13
+ method: HttpMethod.GET,
14
+ path: '/',
15
+ handler: ({ accept }) => new Response(JSON.stringify(accept)),
16
+ })
17
+
18
+ const request = new Request('https://example.com/', {
19
+ headers: {
20
+ accept: 'text/plain;q=0.5, application/json, text/html;q=0.9,application/yaml;q=1',
21
+ },
22
+ })
23
+
24
+ const response = await router.fetch(request)
25
+
26
+ expect(await response.json()).toEqual([
27
+ { type: 'application/json', q: 1 },
28
+ { type: 'application/yaml', q: 1 },
29
+ { type: 'text/html', q: 0.9 },
30
+ { type: 'text/plain', q: 0.5 },
31
+ ])
32
+ })
33
+ })
@@ -0,0 +1,12 @@
1
+ import type { AcceptMediaRange, ContextMiddleware } from '../types'
2
+ import { parseAcceptHeader } from '../lib/media-type'
3
+
4
+ /**
5
+ * Attach parsed Accept header values to the request context.
6
+ *
7
+ * @returns Middleware that adds `accept` to the request context.
8
+ */
9
+ export const acceptCtx = (): ContextMiddleware<{ accept: AcceptMediaRange[] }> => (ctx) => {
10
+ const header = ctx.request.headers.get('accept')
11
+ ctx.accept = parseAcceptHeader(header ?? '*/*')
12
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env -S bun test
2
+ import { describe, expect, it, mock } from 'bun:test'
3
+ import { HttpMethod, HttpStatus } from '@mpen/http'
4
+ import { Router } from '../router'
5
+ import { bodyLimit } from './body-limit'
6
+
7
+ const encoder = new TextEncoder()
8
+
9
+ function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
10
+ return new ReadableStream<Uint8Array>({
11
+ start(controller) {
12
+ for (const chunk of chunks) {
13
+ controller.enqueue(encoder.encode(chunk))
14
+ }
15
+ controller.close()
16
+ },
17
+ })
18
+ }
19
+
20
+ describe(bodyLimit.name, () => {
21
+ it('rejects immediately when Content-Length exceeds maxSize', async () => {
22
+ const router = new Router()
23
+ const handler = mock(() => new Response('ok'))
24
+
25
+ router.use(bodyLimit({ maxSize: 9 }))
26
+ router.add({ method: HttpMethod.POST, path: '/upload', handler })
27
+
28
+ const request = new Request('https://example.com/upload', {
29
+ method: HttpMethod.POST,
30
+ headers: { 'content-length': '10' },
31
+ body: makeStream(['ok']),
32
+ })
33
+
34
+ const response = await router.fetch(request)
35
+
36
+ expect(response.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE)
37
+ expect(handler).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('rejects when the streamed body exceeds maxSize', async () => {
41
+ const router = new Router()
42
+
43
+ router.use(bodyLimit({ maxSize: 4 }))
44
+ router.add({
45
+ method: HttpMethod.POST,
46
+ path: '/upload',
47
+ handler: async ({ request }) => new Response(await request.body.text()),
48
+ })
49
+
50
+ const request = new Request('https://example.com/upload', {
51
+ method: HttpMethod.POST,
52
+ body: makeStream(['1234', '5']),
53
+ })
54
+
55
+ const response = await router.fetch(request)
56
+
57
+ expect(response.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE)
58
+ })
59
+
60
+ it('rejects when Content-Length does not match the received bytes', async () => {
61
+ const router = new Router()
62
+
63
+ router.use(bodyLimit({ maxSize: 10 }))
64
+ router.add({
65
+ method: HttpMethod.POST,
66
+ path: '/upload',
67
+ handler: async ({ request }) => new Response(await request.body.text()),
68
+ })
69
+
70
+ const request = new Request('https://example.com/upload', {
71
+ method: HttpMethod.POST,
72
+ headers: { 'content-length': '4' },
73
+ body: makeStream(['abc']),
74
+ })
75
+
76
+ const response = await router.fetch(request)
77
+
78
+ expect(response.status).toBe(HttpStatus.BAD_REQUEST)
79
+ })
80
+
81
+ it('allows requests within the limit and matching Content-Length', async () => {
82
+ const router = new Router()
83
+
84
+ router.use(bodyLimit({ maxSize: 10 }))
85
+ router.add({
86
+ method: HttpMethod.POST,
87
+ path: '/upload',
88
+ handler: async ({ request }) => new Response(await request.body.text()),
89
+ })
90
+
91
+ const request = new Request('https://example.com/upload', {
92
+ method: HttpMethod.POST,
93
+ headers: { 'content-length': '5' },
94
+ body: makeStream(['hello']),
95
+ })
96
+
97
+ const response = await router.fetch(request)
98
+
99
+ expect(response.status).toBe(HttpStatus.OK)
100
+ expect(await response.text()).toBe('hello')
101
+ })
102
+
103
+ it('declares its rejection responses on affected routes', () => {
104
+ const router = new Router().use(bodyLimit({ maxSize: 10 }))
105
+ router.add({ method: HttpMethod.POST, path: '/upload', handler: () => new Response() })
106
+
107
+ expect(router.getRoutes()[0]?.schema?.response?.body).toEqual({
108
+ [HttpStatus.BAD_REQUEST]: { type: 'string' },
109
+ [HttpStatus.PAYLOAD_TOO_LARGE]: { type: 'string' },
110
+ })
111
+ })
112
+ })
@@ -0,0 +1,121 @@
1
+ import { HttpStatus } from '@mpen/http'
2
+ import { RequestBodyLengthMismatchError, RequestBodyTooLargeError } from '../request'
3
+ import { text } from '../response/formats/content'
4
+ import { defineMiddleware, type DeclaredMiddleware } from './define'
5
+ import type { AnyContext } from '../types'
6
+
7
+ export interface MaxContentSizeOptions {
8
+ maxSize: number
9
+ }
10
+
11
+ const utf8encoder = new TextEncoder()
12
+
13
+ function parseContentLength(value: string | null): number | null {
14
+ if (!value) return null
15
+ const trimmed = value.trim()
16
+ if (!trimmed) return null
17
+ const parsed = Number(trimmed)
18
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) return null
19
+ return parsed
20
+ }
21
+
22
+ function chunkByteLength(chunk: Uint8Array | string): number {
23
+ if (typeof chunk === 'string') return utf8encoder.encode(chunk).length
24
+ return chunk.byteLength
25
+ }
26
+
27
+ /**
28
+ * Enforce a maximum request body size while preserving access to the incoming stream.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * router.use(bodyLimit({maxSize: 1024 * 1024}))
33
+ * ```
34
+ *
35
+ * @param options - Configuration for maximum request body size enforcement.
36
+ * @returns Middleware that rejects oversized bodies and mismatched Content-Length values.
37
+ */
38
+ export function bodyLimit<Ctx extends object = AnyContext>(
39
+ options: MaxContentSizeOptions,
40
+ ): DeclaredMiddleware<{}, Ctx> {
41
+ const textResponse = {
42
+ schema: { type: 'string' },
43
+ parse(value: unknown): string {
44
+ if (typeof value !== 'string') {
45
+ throw new TypeError('Body limit responses must contain a string body.')
46
+ }
47
+ return value
48
+ },
49
+ }
50
+
51
+ return defineMiddleware({
52
+ responses: {
53
+ [HttpStatus.BAD_REQUEST]: textResponse,
54
+ [HttpStatus.PAYLOAD_TOO_LARGE]: textResponse,
55
+ },
56
+ async run(ctx, { next, forward, respond }) {
57
+ const maxSize = options.maxSize
58
+ const contentLength = parseContentLength(ctx.request.headers.get('content-length'))
59
+
60
+ if (contentLength != null && contentLength > maxSize) {
61
+ void ctx.request.body.stream()?.cancel()
62
+ return respond(text('Payload Too Large', { status: HttpStatus.PAYLOAD_TOO_LARGE }))
63
+ }
64
+
65
+ const bodyStream = ctx.request.body.stream()
66
+ if (!bodyStream) {
67
+ if (contentLength != null && contentLength !== 0) {
68
+ return respond(text('Bad Request', { status: HttpStatus.BAD_REQUEST }))
69
+ }
70
+ return forward(await next())
71
+ }
72
+
73
+ const reader = bodyStream.getReader()
74
+ let bytesRead = 0
75
+
76
+ const monitoredBody = new ReadableStream<Uint8Array>({
77
+ async pull(controller) {
78
+ const result = await reader.read()
79
+ if (result.done) {
80
+ if (contentLength != null && bytesRead !== contentLength) {
81
+ controller.error(
82
+ new RequestBodyLengthMismatchError(contentLength, bytesRead),
83
+ )
84
+ return
85
+ }
86
+ controller.close()
87
+ return
88
+ }
89
+
90
+ const value = result.value
91
+ bytesRead += chunkByteLength(value)
92
+ if (bytesRead > maxSize) {
93
+ await reader.cancel()
94
+ controller.error(new RequestBodyTooLargeError(maxSize, bytesRead))
95
+ return
96
+ }
97
+ controller.enqueue(value)
98
+ },
99
+ cancel(reason) {
100
+ return reader.cancel(reason)
101
+ },
102
+ })
103
+
104
+ ctx.request = ctx.request.withBody(ctx.request.body.withStream(monitoredBody))
105
+
106
+ try {
107
+ return forward(await next())
108
+ } catch (err) {
109
+ if (err instanceof RequestBodyTooLargeError) {
110
+ return respond(
111
+ text('Payload Too Large', { status: HttpStatus.PAYLOAD_TOO_LARGE }),
112
+ )
113
+ }
114
+ if (err instanceof RequestBodyLengthMismatchError) {
115
+ return respond(text('Bad Request', { status: HttpStatus.BAD_REQUEST }))
116
+ }
117
+ throw err
118
+ }
119
+ },
120
+ })
121
+ }
File without changes
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env -S bun test
2
+ import { describe, expect, it, mock } from 'bun:test'
3
+ import { HttpMethod, HttpStatus } from '@mpen/http'
4
+ import { Router } from '../router'
5
+ import { cors } from './cors'
6
+
7
+ describe(cors.name, () => {
8
+ it('adds wildcard allow-origin by default', async () => {
9
+ const router = new Router()
10
+ router.use(cors({ origin: '*' }))
11
+ router.add({
12
+ method: HttpMethod.GET,
13
+ path: '/data',
14
+ handler: () => new Response('ok'),
15
+ })
16
+
17
+ const response = await router.fetch(
18
+ new Request('https://api.example.com/data', {
19
+ headers: { origin: 'https://app.example.com' },
20
+ }),
21
+ )
22
+
23
+ expect(response.headers.get('access-control-allow-origin')).toBe('*')
24
+ })
25
+
26
+ it('adds CORS headers to logical response bodies', async () => {
27
+ const router = new Router()
28
+ router.use(cors({ origin: '*' }))
29
+ router.add({
30
+ method: HttpMethod.GET,
31
+ path: '/data',
32
+ handler: () => ({ ok: true }),
33
+ })
34
+
35
+ const response = await router.fetch(
36
+ new Request('https://api.example.com/data', {
37
+ headers: { origin: 'https://app.example.com' },
38
+ }),
39
+ )
40
+
41
+ expect(response.headers.get('access-control-allow-origin')).toBe('*')
42
+ expect(await response.json()).toEqual({ ok: true })
43
+ })
44
+
45
+ it('supports an origin resolver function', async () => {
46
+ const router = new Router()
47
+ router.use(
48
+ cors({
49
+ origin: (origin) => (origin?.endsWith('.example.com') ? origin : null),
50
+ }),
51
+ )
52
+ router.add({
53
+ method: HttpMethod.GET,
54
+ path: '/data',
55
+ handler: () => new Response('ok'),
56
+ })
57
+
58
+ const allowed = await router.fetch(
59
+ new Request('https://api.example.com/data', {
60
+ headers: { origin: 'https://app.example.com' },
61
+ }),
62
+ )
63
+
64
+ const denied = await router.fetch(
65
+ new Request('https://api.example.com/data', {
66
+ headers: { origin: 'https://evil.example' },
67
+ }),
68
+ )
69
+
70
+ expect(allowed.headers.get('access-control-allow-origin')).toBe('https://app.example.com')
71
+ expect(denied.headers.has('access-control-allow-origin')).toBe(false)
72
+ })
73
+
74
+ it('echoes the origin when credentials are enabled', async () => {
75
+ const router = new Router()
76
+ router.use(cors({ origin: '*', credentials: true }))
77
+ router.add({
78
+ method: HttpMethod.GET,
79
+ path: '/data',
80
+ handler: () => new Response('ok'),
81
+ })
82
+
83
+ const response = await router.fetch(
84
+ new Request('https://api.example.com/data', {
85
+ headers: { origin: 'https://app.example.com' },
86
+ }),
87
+ )
88
+
89
+ expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example.com')
90
+ expect(response.headers.get('access-control-allow-credentials')).toBe('true')
91
+ expect(response.headers.get('vary')).toContain('Origin')
92
+ })
93
+
94
+ it('exposes response headers when configured', async () => {
95
+ const router = new Router()
96
+ router.use(cors({ origin: '*', exposeHeaders: ['x-trace', 'x-request-id'] }))
97
+ router.add({
98
+ method: HttpMethod.GET,
99
+ path: '/data',
100
+ handler: () => new Response('ok'),
101
+ })
102
+
103
+ const response = await router.fetch(
104
+ new Request('https://api.example.com/data', {
105
+ headers: { origin: 'https://app.example.com' },
106
+ }),
107
+ )
108
+
109
+ expect(response.headers.get('access-control-expose-headers')).toBe('x-trace, x-request-id')
110
+ })
111
+
112
+ it('accepts a static allowMethods list', async () => {
113
+ const router = new Router()
114
+ router.use(cors({ origin: '*', allowMethods: ['GET', 'POST'] }))
115
+ router.add({
116
+ method: HttpMethod.OPTIONS,
117
+ path: '/widgets',
118
+ handler: () => new Response('ok'),
119
+ })
120
+
121
+ const response = await router.fetch(
122
+ new Request('https://api.example.com/widgets', {
123
+ method: HttpMethod.OPTIONS,
124
+ headers: {
125
+ origin: 'https://app.example.com',
126
+ 'access-control-request-method': 'POST',
127
+ },
128
+ }),
129
+ )
130
+
131
+ expect(response.headers.get('access-control-allow-methods')).toBe('GET, POST')
132
+ })
133
+
134
+ it('accepts a dynamic allowMethods resolver', async () => {
135
+ const router = new Router()
136
+ router.use(
137
+ cors({
138
+ origin: '*',
139
+ allowMethods: (origin) =>
140
+ origin === 'https://app.example.com' ? ['GET'] : ['POST'],
141
+ }),
142
+ )
143
+ router.add({
144
+ method: HttpMethod.OPTIONS,
145
+ path: '/widgets',
146
+ handler: () => new Response('ok'),
147
+ })
148
+
149
+ const response = await router.fetch(
150
+ new Request('https://api.example.com/widgets', {
151
+ method: HttpMethod.OPTIONS,
152
+ headers: {
153
+ origin: 'https://app.example.com',
154
+ 'access-control-request-method': 'POST',
155
+ },
156
+ }),
157
+ )
158
+
159
+ expect(response.headers.get('access-control-allow-methods')).toBe('GET')
160
+ })
161
+
162
+ it('allows localhost origins when dev is enabled', async () => {
163
+ const router = new Router()
164
+ router.use(cors({ origin: 'https://app.example.com', dev: true }))
165
+ router.add({
166
+ method: HttpMethod.GET,
167
+ path: '/data',
168
+ handler: () => new Response('ok'),
169
+ })
170
+
171
+ const response = await router.fetch(
172
+ new Request('https://api.example.com/data', {
173
+ headers: { origin: 'http://localhost:3000' },
174
+ }),
175
+ )
176
+
177
+ expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
178
+ })
179
+
180
+ it('allows localhost origins when allowLocalhost is enabled', async () => {
181
+ const router = new Router()
182
+ router.use(cors({ origin: 'https://app.example.com', allowLocalhost: true }))
183
+ router.add({
184
+ method: HttpMethod.GET,
185
+ path: '/data',
186
+ handler: () => new Response('ok'),
187
+ })
188
+
189
+ const response = await router.fetch(
190
+ new Request('https://api.example.com/data', {
191
+ headers: { origin: 'http://127.0.0.1:3000' },
192
+ }),
193
+ )
194
+
195
+ expect(response.headers.get('access-control-allow-origin')).toBe('http://127.0.0.1:3000')
196
+ })
197
+
198
+ it('handles CORS preflight requests', async () => {
199
+ const router = new Router()
200
+ const handler = mock(() => new Response('ok'))
201
+ router.use(cors({ origin: '*', maxAge: 600 }))
202
+ router.add({
203
+ method: HttpMethod.OPTIONS,
204
+ path: '/widgets',
205
+ handler,
206
+ })
207
+
208
+ const response = await router.fetch(
209
+ new Request('https://api.example.com/widgets', {
210
+ method: HttpMethod.OPTIONS,
211
+ headers: {
212
+ origin: 'https://app.example.com',
213
+ 'access-control-request-method': 'POST',
214
+ 'access-control-request-headers': 'x-test, content-type',
215
+ },
216
+ }),
217
+ )
218
+
219
+ expect(response.status).toBe(HttpStatus.NO_CONTENT)
220
+ expect(handler).not.toHaveBeenCalled()
221
+ expect(response.headers.get('access-control-allow-origin')).toBe('*')
222
+ expect(response.headers.get('access-control-allow-methods')).toBe(
223
+ 'GET, HEAD, PUT, POST, DELETE, PATCH',
224
+ )
225
+ expect(response.headers.get('access-control-allow-headers')).toBe('x-test, content-type')
226
+ expect(response.headers.get('access-control-max-age')).toBe('600')
227
+ })
228
+
229
+ it('uses custom allowHeaders and preflightStatus', async () => {
230
+ const router = new Router()
231
+ router.use(
232
+ cors({
233
+ origin: '*',
234
+ allowHeaders: ['x-custom', 'content-type'],
235
+ preflightStatus: HttpStatus.OK,
236
+ }),
237
+ )
238
+ router.add({
239
+ method: HttpMethod.OPTIONS,
240
+ path: '/widgets',
241
+ handler: () => new Response('ok'),
242
+ })
243
+
244
+ const response = await router.fetch(
245
+ new Request('https://api.example.com/widgets', {
246
+ method: HttpMethod.OPTIONS,
247
+ headers: {
248
+ origin: 'https://app.example.com',
249
+ 'access-control-request-method': 'POST',
250
+ },
251
+ }),
252
+ )
253
+
254
+ expect(response.status).toBe(HttpStatus.OK)
255
+ expect(response.headers.get('access-control-allow-headers')).toBe('x-custom, content-type')
256
+ })
257
+
258
+ it('declares only applicable preflight route responses', () => {
259
+ const router = new Router().use(cors({ origin: '*' }))
260
+ router.add({ method: HttpMethod.GET, path: '/widgets', handler: () => new Response() })
261
+ router.add({ method: HttpMethod.OPTIONS, path: '/widgets', handler: () => new Response() })
262
+
263
+ const [getRoute, optionsRoute] = router.getRoutes()
264
+ expect(getRoute?.schema).toBeUndefined()
265
+ expect(optionsRoute?.schema?.response?.body).toEqual({
266
+ [HttpStatus.NO_CONTENT]: { type: 'null' },
267
+ })
268
+ })
269
+ })