@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,267 @@
1
+ #!/usr/bin/env -S bun test
2
+ import { describe, expect, it } from 'bun:test'
3
+ import { HttpMethod, HttpStatus } from '@mpen/http'
4
+ import { JsonLogger, MemoryLogger, TerminalLogger } from '@mpen/logger'
5
+ import { Router } from '../router'
6
+ import { response as routekitResponse } from '../response'
7
+ import { requestIdCtx } from './request-id-ctx'
8
+ import { requestLogger } from './request-logger'
9
+ import {
10
+ formatRoutekitTerminalLogRecord,
11
+ transformRoutekitJsonLogRecord,
12
+ } from './request-logger-format'
13
+
14
+ describe(requestLogger.name, () => {
15
+ it('logs correlated start and completion activity records for matched requests', async () => {
16
+ const logger = new MemoryLogger()
17
+ const router = new Router()
18
+ .setLogger(logger)
19
+ .useRequest(requestIdCtx({ generate: () => 'req.1' }))
20
+ .useRequest(requestLogger({ trustedClientAddressHeader: 'x-forwarded-for' }))
21
+ .get('/users/:id', () => routekitResponse({ ok: true }))
22
+
23
+ const response = await router.fetch(
24
+ new Request('https://example.com/users/1', {
25
+ method: HttpMethod.GET,
26
+ headers: {
27
+ 'user-agent': 'routekit-test/1',
28
+ 'x-forwarded-for': '203.0.113.9, 10.0.0.1',
29
+ },
30
+ }),
31
+ )
32
+
33
+ expect(response.status).toBe(HttpStatus.OK)
34
+ expect(logger.logs).toHaveLength(2)
35
+ expect(logger.logs[0]).toEqual({
36
+ level: 'info',
37
+ data: ['request started'],
38
+ context: {
39
+ 'client.address': '203.0.113.9',
40
+ 'http.request.method': HttpMethod.GET,
41
+ 'http.route': '/users/:id',
42
+ 'request.id': 'req.1',
43
+ 'url.path': '/users/1',
44
+ 'user_agent.original': 'routekit-test/1',
45
+ },
46
+ metadata: { activity: { name: 'request', phase: 'start' } },
47
+ })
48
+ expect(logger.logs[1]?.level).toBe('info')
49
+ expect(logger.logs[1]?.data).toEqual(['request completed'])
50
+ expect(logger.logs[1]?.context).toMatchObject({
51
+ 'event.name': 'http.server.request',
52
+ 'http.request.method': HttpMethod.GET,
53
+ 'http.response.body.size': 11,
54
+ 'http.response.status_code': HttpStatus.OK,
55
+ 'http.route': '/users/:id',
56
+ 'request.id': 'req.1',
57
+ 'url.path': '/users/1',
58
+ 'user_agent.original': 'routekit-test/1',
59
+ 'client.address': '203.0.113.9',
60
+ })
61
+ expect(logger.logs[1]?.metadata).toEqual({ activity: { name: 'request', phase: 'end' } })
62
+ expect(typeof logger.logs[1]?.context.duration_ms).toBe('number')
63
+ })
64
+
65
+ it('covers generated responses and reports server failures at error severity', async () => {
66
+ const logger = new MemoryLogger()
67
+ const router = new Router()
68
+ .setLogger(logger)
69
+ .useRequest(requestIdCtx({ generate: () => 'req.generated' }))
70
+ .useRequest(requestLogger())
71
+ .get('/boom', () => {
72
+ throw new Error('boom')
73
+ })
74
+
75
+ const missing = await router.fetch(new Request('https://example.com/missing'))
76
+ const failed = await router.fetch(new Request('https://example.com/boom'))
77
+
78
+ expect(missing.status).toBe(HttpStatus.NOT_FOUND)
79
+ expect(failed.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
80
+
81
+ const completions = logger.logs.filter((record) => record.data[0] === 'request completed')
82
+ expect(completions).toHaveLength(2)
83
+ expect(completions[0]?.context['http.response.status_code']).toBe(HttpStatus.NOT_FOUND)
84
+ expect(completions[0]?.level).toBe('info')
85
+ expect(completions[1]?.context['http.response.status_code']).toBe(
86
+ HttpStatus.INTERNAL_SERVER_ERROR,
87
+ )
88
+ expect(completions[1]?.level).toBe('error')
89
+ })
90
+
91
+ it('does not record forwarded client addresses without an explicitly trusted header', async () => {
92
+ const logger = new MemoryLogger()
93
+ const router = new Router()
94
+ .setLogger(logger)
95
+ .useRequest(requestIdCtx({ generate: () => 'req.ignored' }))
96
+ .useRequest(requestLogger())
97
+ .get('/users', () => new Response(null))
98
+
99
+ await router.fetch(
100
+ new Request('https://example.com/users', {
101
+ headers: {
102
+ 'x-forwarded-for': '203.0.113.9',
103
+ },
104
+ }),
105
+ )
106
+
107
+ expect(logger.logs[0]?.context['client.address']).toBeUndefined()
108
+ expect(logger.logs[1]?.context['client.address']).toBeUndefined()
109
+ })
110
+
111
+ it('writes one named JSON access event without activity lifecycle details', async () => {
112
+ const lines: string[] = []
113
+ const logger = new JsonLogger({
114
+ transformRecord: transformRoutekitJsonLogRecord,
115
+ writeLine: (line) => lines.push(line),
116
+ })
117
+ const router = new Router()
118
+ .setLogger(logger)
119
+ .useRequest(requestIdCtx({ generate: () => 'req.json' }))
120
+ .useRequest(requestLogger())
121
+ .get('/users', () => routekitResponse({ ok: true }))
122
+
123
+ await router.fetch(new Request('https://example.com/users'))
124
+
125
+ expect(lines).toHaveLength(1)
126
+ const payload = JSON.parse(lines[0]!)
127
+ expect(payload['event.name']).toBe('http.server.request')
128
+ expect(payload.message).toBeUndefined()
129
+ expect(payload['activity.name']).toBeUndefined()
130
+ expect(payload['activity.phase']).toBeUndefined()
131
+ expect(payload['request.id']).toBe('req.json')
132
+ expect(payload['http.response.status_code']).toBe(HttpStatus.OK)
133
+ })
134
+ })
135
+
136
+ describe(formatRoutekitTerminalLogRecord.name, () => {
137
+ it('renders compact contextual activity start and completion output', () => {
138
+ const lines: string[] = []
139
+ const logger = new TerminalLogger({
140
+ color: false,
141
+ formatRecord: formatRoutekitTerminalLogRecord,
142
+ maxWidth: 200,
143
+ write: (line) => lines.push(line),
144
+ }).withContext({ 'service.name': 'api' })
145
+ const activity = logger.startActivity('request', {
146
+ 'request.id': 'req.1',
147
+ 'http.request.method': 'GET',
148
+ 'url.path': '/users/1',
149
+ hidden: 'not-rendered',
150
+ })
151
+
152
+ activity.end({
153
+ context: { 'http.response.body.size': 1536, 'http.response.status_code': 200 },
154
+ })
155
+
156
+ const output = lines.join('')
157
+
158
+ expect(output).toContain('[api] [req.1] \u2192 GET /users/1')
159
+ expect(output).toContain('[api] [req.1] \u2190 GET /users/1 200')
160
+ expect(output).toContain('ms 1.50 KiB')
161
+ expect(output).not.toContain('request started')
162
+ expect(output).not.toContain('request completed')
163
+ expect(output).not.toContain('not-rendered')
164
+ expect(
165
+ output
166
+ .trimEnd()
167
+ .split('\n')
168
+ .every((line) => !line.endsWith(' ')),
169
+ ).toBe(true)
170
+ })
171
+
172
+ it('colors request correlations, completion status, and slow duration indicators', () => {
173
+ const lines: string[] = []
174
+ const logger = new TerminalLogger({
175
+ color: true,
176
+ formatRecord: formatRoutekitTerminalLogRecord,
177
+ maxWidth: 200,
178
+ write: (line) => lines.push(line),
179
+ }).withContext({ 'service.name': 'api' })
180
+ const fast = logger.startActivity('request', {
181
+ 'request.id': '1',
182
+ 'http.request.method': 'GET',
183
+ 'url.path': '/fast',
184
+ })
185
+ const slow = logger.startActivity('request', {
186
+ 'request.id': '2',
187
+ 'http.request.method': 'POST',
188
+ 'url.path': '/slow',
189
+ })
190
+
191
+ fast.end({ context: { 'http.response.status_code': 204, duration_ms: 1 } })
192
+ slow.end({ context: { 'http.response.status_code': 503, duration_ms: 1200 } })
193
+ logger
194
+ .withContext({
195
+ 'request.id': '3',
196
+ 'http.request.method': 'GET',
197
+ 'url.path': '/manual-slow',
198
+ duration_ms: 1200,
199
+ })
200
+ .info('slow checkpoint')
201
+ logger
202
+ .withContext({
203
+ 'request.id': '4',
204
+ 'http.request.method': 'GET',
205
+ 'url.path': '/manual-medium',
206
+ duration_ms: 300,
207
+ })
208
+ .info('medium checkpoint')
209
+
210
+ const output = lines.join('')
211
+
212
+ expect(output).toContain('\x1B[1m\x1B[94m[api]\x1B[39m\x1B[22m')
213
+ expect(output).toContain('\x1B[1m\x1B[97mGET\x1B[39m\x1B[22m')
214
+ expect(output).toContain('\x1B[1m\x1B[92m204\x1B[39m\x1B[22m')
215
+ expect(output).toContain('\x1B[1m\x1B[91m503\x1B[39m\x1B[22m')
216
+ expect(output).toContain('\x1B[91m1200ms\x1B[39m')
217
+ expect(output).toContain('\x1B[93m300ms\x1B[39m')
218
+ expect(output.match(/\x1B\[(?:9[1-6]|3[5-6])m\[[1234]\]/gu)).toHaveLength(6)
219
+ })
220
+
221
+ it('formats durations with precision based on elapsed time', () => {
222
+ const lines: string[] = []
223
+ const logger = new TerminalLogger({
224
+ color: false,
225
+ formatRecord: formatRoutekitTerminalLogRecord,
226
+ maxWidth: 200,
227
+ write: (line) => lines.push(line),
228
+ }).withContext({
229
+ 'request.id': 'req.1',
230
+ 'http.request.method': 'GET',
231
+ 'url.path': '/timing',
232
+ })
233
+
234
+ logger.withContext({ duration_ms: 0.493 }).info('sub-ms')
235
+ logger.withContext({ duration_ms: 1.088 }).info('single-ms')
236
+ logger.withContext({ duration_ms: 16.726 }).info('double-ms')
237
+ logger.withContext({ duration_ms: 100 }).info('hundred-ms')
238
+ logger.withContext({ duration_ms: 1200.4 }).info('slow-ms')
239
+
240
+ const output = lines.join('')
241
+
242
+ expect(output).toContain('0.49ms')
243
+ expect(output).toContain('1.1ms')
244
+ expect(output).toContain('16.7ms')
245
+ expect(output).toContain('100ms')
246
+ expect(output).toContain('1200ms')
247
+ })
248
+
249
+ it('keeps ordinary request-context messages as messages', () => {
250
+ const lines: string[] = []
251
+ const logger = new TerminalLogger({
252
+ color: false,
253
+ formatRecord: formatRoutekitTerminalLogRecord,
254
+ maxWidth: 200,
255
+ write: (line) => lines.push(line),
256
+ }).withContext({
257
+ 'request.id': 'req.1',
258
+ 'http.request.method': 'POST',
259
+ 'url.path': '/todos',
260
+ })
261
+
262
+ logger.info('request started')
263
+
264
+ expect(lines.join('')).toContain('[req.1] POST /todos request started')
265
+ expect(lines.join('')).not.toContain('\u2192')
266
+ })
267
+ })
@@ -0,0 +1,131 @@
1
+ import { LogLevel } from '@mpen/logger'
2
+ import type { AnyContext, RequestMiddleware } from '../types'
3
+ import { ROUTEKIT_HTTP_SERVER_REQUEST_EVENT_NAME } from './request-logger-format'
4
+
5
+ /**
6
+ * Options for [`requestLogger`]{@link requestLogger}.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const options: RequestLoggerOptions = {
11
+ * completionLevel: response => response.status >= 500 ? LogLevel.ERROR : LogLevel.INFO,
12
+ * }
13
+ * ```
14
+ */
15
+ export interface RequestLoggerOptions {
16
+ /**
17
+ * Activity label used for start and completion records.
18
+ * @defaultValue `'request'`
19
+ */
20
+ activityName?: string
21
+ /**
22
+ * Completion severity or a function selecting severity from the final response.
23
+ */
24
+ completionLevel?: LogLevel | ((response: Response) => LogLevel)
25
+ /**
26
+ * Header containing a trusted proxy-provided original client address.
27
+ *
28
+ * Only configure this when direct clients cannot provide or alter this header. For
29
+ * `x-forwarded-for`, the first comma-separated address is recorded as `client.address`.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * requestLogger({ trustedClientAddressHeader: 'x-forwarded-for' })
34
+ * ```
35
+ */
36
+ trustedClientAddressHeader?: string
37
+ }
38
+
39
+ function defaultCompletionLevel(response: Response): LogLevel {
40
+ return response.status >= 500 ? LogLevel.ERROR : LogLevel.INFO
41
+ }
42
+
43
+ const HTTP_SERVER_REQUEST_EVENT = {
44
+ 'event.name': ROUTEKIT_HTTP_SERVER_REQUEST_EVENT_NAME,
45
+ } as const
46
+
47
+ function getResponseBodySize(response: Response): number | undefined {
48
+ if (response.body == null) {
49
+ return 0
50
+ }
51
+
52
+ const value = response.headers.get('content-length')
53
+ if (value == null || !/^\d+$/u.test(value)) {
54
+ return undefined
55
+ }
56
+
57
+ const size = Number(value)
58
+ return Number.isSafeInteger(size) ? size : undefined
59
+ }
60
+
61
+ function getClientAddress(
62
+ request: { headers: Headers },
63
+ trustedHeader: string | undefined,
64
+ ): string | undefined {
65
+ if (trustedHeader == null) {
66
+ return undefined
67
+ }
68
+
69
+ const value = request.headers.get(trustedHeader)?.trim()
70
+ if (!value) {
71
+ return undefined
72
+ }
73
+
74
+ return trustedHeader.toLowerCase() === 'x-forwarded-for'
75
+ ? value.split(',', 1)[0]?.trim()
76
+ : value
77
+ }
78
+
79
+ /**
80
+ * Log a timed request activity through the request's contextual logger.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * router.useRequest(requestIdCtx())
85
+ * router.useRequest(requestLogger())
86
+ * ```
87
+ *
88
+ * @param options - Request activity naming and severity options.
89
+ * @returns Request-boundary middleware that logs final status and duration.
90
+ * @typeParam Ctx - Context containing the correlated request identifier.
91
+ */
92
+ export function requestLogger<
93
+ Ctx extends { requestId: string } & object = AnyContext & { requestId: string },
94
+ >(options: RequestLoggerOptions = {}): RequestMiddleware<{}, Ctx> {
95
+ const activityName = options.activityName ?? 'request'
96
+
97
+ return async (ctx, next) => {
98
+ const clientAddress = getClientAddress(ctx.request, options.trustedClientAddressHeader)
99
+ const userAgent = ctx.request.headers.get('user-agent') ?? undefined
100
+ const activity = ctx.logger.startActivity(activityName, {
101
+ ...(clientAddress == null ? {} : { 'client.address': clientAddress }),
102
+ ...(userAgent == null ? {} : { 'user_agent.original': userAgent }),
103
+ })
104
+ ctx.logger = activity.logger
105
+
106
+ try {
107
+ const response = await next()
108
+ const bodySize = getResponseBodySize(response)
109
+ const level =
110
+ typeof options.completionLevel === 'function'
111
+ ? options.completionLevel(response)
112
+ : (options.completionLevel ?? defaultCompletionLevel(response))
113
+ activity.end({
114
+ context: {
115
+ ...HTTP_SERVER_REQUEST_EVENT,
116
+ 'http.response.status_code': response.status,
117
+ ...(bodySize == null ? {} : { 'http.response.body.size': bodySize }),
118
+ },
119
+ level,
120
+ })
121
+ return response
122
+ } catch (error) {
123
+ activity.end({
124
+ context: HTTP_SERVER_REQUEST_EVENT,
125
+ data: [error],
126
+ level: LogLevel.ERROR,
127
+ })
128
+ throw error
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,5 @@
1
+ import type { ContextMiddleware } from '../types'
2
+
3
+ export const startTimeCtx = (): ContextMiddleware<{ startTime: number }> => (ctx) => {
4
+ ctx.startTime = Date.now()
5
+ }