@pikku/core 0.12.4 → 0.12.5

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 (229) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/dev/hot-reload.d.ts +10 -0
  3. package/dist/dev/hot-reload.js +158 -0
  4. package/dist/middleware-runner.d.ts +1 -0
  5. package/dist/middleware-runner.js +5 -0
  6. package/dist/permissions.d.ts +1 -0
  7. package/dist/permissions.js +5 -0
  8. package/dist/services/in-memory-workflow-service.js +7 -11
  9. package/dist/wirings/ai-agent/ai-agent-prepare.js +16 -1
  10. package/dist/wirings/channel/channel-middleware-runner.d.ts +1 -0
  11. package/dist/wirings/channel/channel-middleware-runner.js +5 -0
  12. package/dist/wirings/workflow/pikku-workflow-service.js +7 -8
  13. package/package.json +3 -2
  14. package/src/crypto-utils.test.ts +214 -0
  15. package/src/crypto-utils.ts +213 -0
  16. package/src/dev/hot-reload.test.ts +484 -0
  17. package/src/dev/hot-reload.ts +212 -0
  18. package/src/errors/error-handler.ts +62 -0
  19. package/src/errors/error.test.ts +195 -0
  20. package/src/errors/errors.ts +374 -0
  21. package/src/errors/index.ts +2 -0
  22. package/src/factory-functions.test.ts +109 -0
  23. package/src/function/function-runner.test.ts +536 -0
  24. package/src/function/function-runner.ts +365 -0
  25. package/src/function/functions.types.ts +304 -0
  26. package/src/function/index.ts +5 -0
  27. package/src/handle-error.test.ts +424 -0
  28. package/src/handle-error.ts +69 -0
  29. package/src/index.ts +118 -0
  30. package/src/internal.ts +6 -0
  31. package/src/middleware/auth-apikey.test.ts +363 -0
  32. package/src/middleware/auth-apikey.ts +47 -0
  33. package/src/middleware/auth-bearer.test.ts +450 -0
  34. package/src/middleware/auth-bearer.ts +72 -0
  35. package/src/middleware/auth-cookie.test.ts +528 -0
  36. package/src/middleware/auth-cookie.ts +80 -0
  37. package/src/middleware/cors.test.ts +424 -0
  38. package/src/middleware/cors.ts +104 -0
  39. package/src/middleware/index.ts +5 -0
  40. package/src/middleware/remote-auth.test.ts +488 -0
  41. package/src/middleware/remote-auth.ts +68 -0
  42. package/src/middleware/timeout.ts +15 -0
  43. package/src/middleware-runner.test.ts +418 -0
  44. package/src/middleware-runner.ts +240 -0
  45. package/src/permissions.test.ts +434 -0
  46. package/src/permissions.ts +327 -0
  47. package/src/pikku-request.ts +23 -0
  48. package/src/pikku-response.ts +5 -0
  49. package/src/pikku-state.test.ts +224 -0
  50. package/src/pikku-state.ts +216 -0
  51. package/src/run-tests-script.test.ts +49 -0
  52. package/src/schema.test.ts +249 -0
  53. package/src/schema.ts +151 -0
  54. package/src/services/ai-agent-runner-service.ts +41 -0
  55. package/src/services/ai-run-state-service.ts +20 -0
  56. package/src/services/ai-storage-service.ts +39 -0
  57. package/src/services/content-service.ts +76 -0
  58. package/src/services/deployment-service.ts +22 -0
  59. package/src/services/gateway-service.ts +20 -0
  60. package/src/services/gopass-secrets.ts +98 -0
  61. package/src/services/in-memory-ai-run-state-service.ts +73 -0
  62. package/src/services/in-memory-trigger-service.ts +64 -0
  63. package/src/services/in-memory-workflow-service.test.ts +351 -0
  64. package/src/services/in-memory-workflow-service.ts +502 -0
  65. package/src/services/index.ts +56 -0
  66. package/src/services/jwt-service.ts +30 -0
  67. package/src/services/local-content.ts +120 -0
  68. package/src/services/local-gateway-service.ts +62 -0
  69. package/src/services/local-secrets.test.ts +80 -0
  70. package/src/services/local-secrets.ts +94 -0
  71. package/src/services/local-variables.test.ts +61 -0
  72. package/src/services/local-variables.ts +39 -0
  73. package/src/services/logger-console.test.ts +118 -0
  74. package/src/services/logger-console.ts +98 -0
  75. package/src/services/logger.ts +57 -0
  76. package/src/services/scheduler-service.ts +86 -0
  77. package/src/services/schema-service.ts +33 -0
  78. package/src/services/scoped-secret-service.test.ts +74 -0
  79. package/src/services/scoped-secret-service.ts +41 -0
  80. package/src/services/secret-service.ts +38 -0
  81. package/src/services/trigger-service.ts +17 -0
  82. package/src/services/typed-secret-service.test.ts +93 -0
  83. package/src/services/typed-secret-service.ts +70 -0
  84. package/src/services/typed-variables-service.test.ts +73 -0
  85. package/src/services/typed-variables-service.ts +81 -0
  86. package/src/services/user-session-service.test.ts +113 -0
  87. package/src/services/user-session-service.ts +86 -0
  88. package/src/services/variables-service.ts +11 -0
  89. package/src/services/workflow-service.ts +95 -0
  90. package/src/testing/index.ts +2 -0
  91. package/src/testing/service-tests.ts +873 -0
  92. package/src/time-utils.test.ts +56 -0
  93. package/src/time-utils.ts +107 -0
  94. package/src/types/core.types.ts +478 -0
  95. package/src/types/state.types.ts +188 -0
  96. package/src/utils/hash.test.ts +68 -0
  97. package/src/utils/hash.ts +26 -0
  98. package/src/utils.test.ts +350 -0
  99. package/src/utils.ts +129 -0
  100. package/src/version.test.ts +80 -0
  101. package/src/version.ts +25 -0
  102. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  103. package/src/wirings/ai-agent/ai-agent-helpers.test.ts +152 -0
  104. package/src/wirings/ai-agent/ai-agent-helpers.ts +76 -0
  105. package/src/wirings/ai-agent/ai-agent-memory.ts +310 -0
  106. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +115 -0
  107. package/src/wirings/ai-agent/ai-agent-model-config.ts +43 -0
  108. package/src/wirings/ai-agent/ai-agent-prepare.ts +659 -0
  109. package/src/wirings/ai-agent/ai-agent-registry.test.ts +37 -0
  110. package/src/wirings/ai-agent/ai-agent-registry.ts +82 -0
  111. package/src/wirings/ai-agent/ai-agent-runner.test.ts +319 -0
  112. package/src/wirings/ai-agent/ai-agent-runner.ts +773 -0
  113. package/src/wirings/ai-agent/ai-agent-stream.test.ts +859 -0
  114. package/src/wirings/ai-agent/ai-agent-stream.ts +1153 -0
  115. package/src/wirings/ai-agent/ai-agent.types.ts +382 -0
  116. package/src/wirings/ai-agent/index.ts +37 -0
  117. package/src/wirings/channel/channel-common.ts +90 -0
  118. package/src/wirings/channel/channel-handler.ts +250 -0
  119. package/src/wirings/channel/channel-middleware-runner.ts +126 -0
  120. package/src/wirings/channel/channel-runner.ts +166 -0
  121. package/src/wirings/channel/channel-store.ts +29 -0
  122. package/src/wirings/channel/channel.types.ts +172 -0
  123. package/src/wirings/channel/define-channel-routes.ts +25 -0
  124. package/src/wirings/channel/eventhub-service.ts +34 -0
  125. package/src/wirings/channel/eventhub-store.ts +13 -0
  126. package/src/wirings/channel/index.ts +24 -0
  127. package/src/wirings/channel/local/index.ts +3 -0
  128. package/src/wirings/channel/local/local-channel-handler.ts +81 -0
  129. package/src/wirings/channel/local/local-channel-runner.test.ts +215 -0
  130. package/src/wirings/channel/local/local-channel-runner.ts +210 -0
  131. package/src/wirings/channel/local/local-eventhub-service.test.ts +95 -0
  132. package/src/wirings/channel/local/local-eventhub-service.ts +101 -0
  133. package/src/wirings/channel/log-channels.ts +20 -0
  134. package/src/wirings/channel/pikku-abstract-channel-handler.test.ts +54 -0
  135. package/src/wirings/channel/pikku-abstract-channel-handler.ts +45 -0
  136. package/src/wirings/channel/serverless/index.ts +5 -0
  137. package/src/wirings/channel/serverless/serverless-channel-runner.ts +289 -0
  138. package/src/wirings/cli/channel/cli-channel-runner.ts +136 -0
  139. package/src/wirings/cli/channel/index.ts +1 -0
  140. package/src/wirings/cli/cli-runner.test.ts +403 -0
  141. package/src/wirings/cli/cli-runner.ts +529 -0
  142. package/src/wirings/cli/cli.types.ts +358 -0
  143. package/src/wirings/cli/command-parser.test.ts +445 -0
  144. package/src/wirings/cli/command-parser.ts +504 -0
  145. package/src/wirings/cli/define-cli-commands.ts +24 -0
  146. package/src/wirings/cli/index.ts +19 -0
  147. package/src/wirings/gateway/gateway-runner.test.ts +474 -0
  148. package/src/wirings/gateway/gateway-runner.ts +411 -0
  149. package/src/wirings/gateway/gateway.types.ts +149 -0
  150. package/src/wirings/gateway/index.ts +13 -0
  151. package/src/wirings/http/http-routes.test.ts +322 -0
  152. package/src/wirings/http/http-routes.ts +197 -0
  153. package/src/wirings/http/http-runner.test.ts +144 -0
  154. package/src/wirings/http/http-runner.ts +588 -0
  155. package/src/wirings/http/http.types.ts +369 -0
  156. package/src/wirings/http/index.ts +28 -0
  157. package/src/wirings/http/log-http-routes.ts +22 -0
  158. package/src/wirings/http/pikku-fetch-http-request.test.ts +237 -0
  159. package/src/wirings/http/pikku-fetch-http-request.ts +170 -0
  160. package/src/wirings/http/pikku-fetch-http-response.test.ts +82 -0
  161. package/src/wirings/http/pikku-fetch-http-response.ts +147 -0
  162. package/src/wirings/http/routers/http-router.ts +13 -0
  163. package/src/wirings/http/routers/path-to-regex.test.ts +319 -0
  164. package/src/wirings/http/routers/path-to-regex.ts +126 -0
  165. package/src/wirings/http/web-request.test.ts +236 -0
  166. package/src/wirings/http/web-request.ts +104 -0
  167. package/src/wirings/mcp/index.ts +27 -0
  168. package/src/wirings/mcp/mcp-endpoint-registry.test.ts +389 -0
  169. package/src/wirings/mcp/mcp-endpoint-registry.ts +159 -0
  170. package/src/wirings/mcp/mcp-runner.ts +323 -0
  171. package/src/wirings/mcp/mcp.types.ts +216 -0
  172. package/src/wirings/node/index.ts +2 -0
  173. package/src/wirings/node/node.types.ts +23 -0
  174. package/src/wirings/oauth2/index.ts +3 -0
  175. package/src/wirings/oauth2/oauth2-client.test.ts +929 -0
  176. package/src/wirings/oauth2/oauth2-client.ts +335 -0
  177. package/src/wirings/oauth2/oauth2.types.ts +69 -0
  178. package/src/wirings/oauth2/wire-oauth2-credential.ts +23 -0
  179. package/src/wirings/queue/index.ts +31 -0
  180. package/src/wirings/queue/queue-runner.test.ts +710 -0
  181. package/src/wirings/queue/queue-runner.ts +192 -0
  182. package/src/wirings/queue/queue.types.ts +189 -0
  183. package/src/wirings/queue/register-queue-helper.ts +60 -0
  184. package/src/wirings/queue/validate-worker-config.test.ts +108 -0
  185. package/src/wirings/queue/validate-worker-config.ts +116 -0
  186. package/src/wirings/rpc/index.ts +4 -0
  187. package/src/wirings/rpc/rpc-runner.ts +460 -0
  188. package/src/wirings/rpc/rpc-types.ts +56 -0
  189. package/src/wirings/rpc/wire-addon.ts +21 -0
  190. package/src/wirings/scheduler/index.ts +11 -0
  191. package/src/wirings/scheduler/log-schedulers.ts +20 -0
  192. package/src/wirings/scheduler/scheduler-runner.test.ts +660 -0
  193. package/src/wirings/scheduler/scheduler-runner.ts +133 -0
  194. package/src/wirings/scheduler/scheduler.types.ts +53 -0
  195. package/src/wirings/secret/index.ts +9 -0
  196. package/src/wirings/secret/secret.types.ts +32 -0
  197. package/src/wirings/secret/validate-secret-definitions.test.ts +140 -0
  198. package/src/wirings/secret/validate-secret-definitions.ts +82 -0
  199. package/src/wirings/trigger/index.ts +10 -0
  200. package/src/wirings/trigger/pikku-trigger-service.ts +112 -0
  201. package/src/wirings/trigger/trigger-runner.test.ts +79 -0
  202. package/src/wirings/trigger/trigger-runner.ts +135 -0
  203. package/src/wirings/trigger/trigger.types.ts +178 -0
  204. package/src/wirings/variable/index.ts +8 -0
  205. package/src/wirings/variable/validate-variable-definitions.test.ts +91 -0
  206. package/src/wirings/variable/validate-variable-definitions.ts +69 -0
  207. package/src/wirings/variable/variable.types.ts +22 -0
  208. package/src/wirings/workflow/dsl/index.ts +31 -0
  209. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +320 -0
  210. package/src/wirings/workflow/dsl/workflow-runner.ts +28 -0
  211. package/src/wirings/workflow/graph/graph-node.ts +222 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +487 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +816 -0
  214. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  215. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  216. package/src/wirings/workflow/graph/index.ts +19 -0
  217. package/src/wirings/workflow/graph/template.test.ts +49 -0
  218. package/src/wirings/workflow/graph/template.ts +42 -0
  219. package/src/wirings/workflow/graph/wire-workflow-graph.ts +29 -0
  220. package/src/wirings/workflow/graph/workflow-graph.types.ts +104 -0
  221. package/src/wirings/workflow/index.ts +82 -0
  222. package/src/wirings/workflow/pikku-workflow-service.test.ts +200 -0
  223. package/src/wirings/workflow/pikku-workflow-service.ts +1179 -0
  224. package/src/wirings/workflow/workflow-helpers.test.ts +129 -0
  225. package/src/wirings/workflow/workflow-helpers.ts +79 -0
  226. package/src/wirings/workflow/workflow.types.ts +274 -0
  227. package/tsconfig.json +14 -0
  228. package/tsconfig.tsbuildinfo +1 -0
  229. package/lcov.info +0 -18891
@@ -0,0 +1,319 @@
1
+ import { test, describe, beforeEach } from 'node:test'
2
+ import * as assert from 'node:assert/strict'
3
+ import { PathToRegexRouter } from './path-to-regex.js'
4
+ import { resetPikkuState, pikkuState } from '../../../pikku-state.js'
5
+ import type { HTTPMethod } from '../http.types.js'
6
+
7
+ describe('PathToRegexRouter', () => {
8
+ let router: PathToRegexRouter
9
+
10
+ // Helper to create mock route wiring
11
+ const mockRoute = (route: string) => ({
12
+ route,
13
+ method: 'get' as HTTPMethod,
14
+ func: (() => {}) as any,
15
+ })
16
+
17
+ beforeEach(() => {
18
+ resetPikkuState()
19
+ router = new PathToRegexRouter()
20
+ })
21
+
22
+ describe('Static Route Optimization', () => {
23
+ test('should correctly identify static routes', () => {
24
+ // Setup test routes
25
+ pikkuState(
26
+ null,
27
+ 'http',
28
+ 'routes',
29
+ new Map([
30
+ [
31
+ 'get',
32
+ new Map([
33
+ ['/static', mockRoute('/static')],
34
+ ['/api/about', mockRoute('/api/about')],
35
+ ['/health', mockRoute('/health')],
36
+ ]),
37
+ ],
38
+ ])
39
+ )
40
+ pikkuState(null, 'http', 'middleware', new Map())
41
+ pikkuState(null, 'channel', 'channels', new Map())
42
+
43
+ router.initialize()
44
+
45
+ // Test static routes - should return exact matches with empty params
46
+ const staticResult = router.match('get', '/static')
47
+ assert.ok(staticResult, 'Static route should match')
48
+ assert.strictEqual(staticResult.route, '/static')
49
+ assert.deepStrictEqual(staticResult.params, {})
50
+
51
+ const aboutResult = router.match('get', '/api/about')
52
+ assert.ok(aboutResult, 'Static route /api/about should match')
53
+ assert.strictEqual(aboutResult.route, '/api/about')
54
+ assert.deepStrictEqual(aboutResult.params, {})
55
+ })
56
+
57
+ test('should handle dynamic routes with parameters', () => {
58
+ // Setup test routes with parameters
59
+ pikkuState(
60
+ null,
61
+ 'http',
62
+ 'routes',
63
+ new Map([
64
+ [
65
+ 'get',
66
+ new Map([
67
+ ['/api/users/:id', mockRoute('/api/users/:id')],
68
+ [
69
+ '/posts/:slug/comments/:commentId',
70
+ mockRoute('/posts/:slug/comments/:commentId'),
71
+ ],
72
+ ]),
73
+ ],
74
+ ])
75
+ )
76
+ pikkuState(null, 'http', 'middleware', new Map())
77
+ pikkuState(null, 'channel', 'channels', new Map())
78
+
79
+ router.initialize()
80
+
81
+ // Test dynamic routes - should return matches with extracted parameters
82
+ const userResult = router.match('get', '/api/users/123')
83
+ assert.ok(userResult, 'Dynamic route should match')
84
+ assert.strictEqual(userResult.route, '/api/users/:id')
85
+ assert.strictEqual(userResult.params.id, '123')
86
+
87
+ const commentResult = router.match(
88
+ 'get',
89
+ '/posts/hello-world/comments/456'
90
+ )
91
+ assert.ok(commentResult, 'Complex dynamic route should match')
92
+ assert.strictEqual(
93
+ commentResult.route,
94
+ '/posts/:slug/comments/:commentId'
95
+ )
96
+ assert.strictEqual(commentResult.params.slug, 'hello-world')
97
+ assert.strictEqual(commentResult.params.commentId, '456')
98
+ })
99
+
100
+ test('should handle multi-parameter routes', () => {
101
+ // Setup routes with multiple parameters
102
+ pikkuState(
103
+ null,
104
+ 'http',
105
+ 'routes',
106
+ new Map([
107
+ [
108
+ 'get',
109
+ new Map([
110
+ [
111
+ '/api/:version/users/:userId',
112
+ mockRoute('/api/:version/users/:userId'),
113
+ ],
114
+ [
115
+ '/blog/:year/:month/:slug',
116
+ mockRoute('/blog/:year/:month/:slug'),
117
+ ],
118
+ ]),
119
+ ],
120
+ ])
121
+ )
122
+ pikkuState(null, 'http', 'middleware', new Map())
123
+ pikkuState(null, 'channel', 'channels', new Map())
124
+
125
+ router.initialize()
126
+
127
+ // Test multi-parameter routes
128
+ const apiResult = router.match('get', '/api/v2/users/456')
129
+ assert.ok(apiResult, 'Multi-parameter route should match')
130
+ assert.strictEqual(apiResult.route, '/api/:version/users/:userId')
131
+ assert.strictEqual(apiResult.params.version, 'v2')
132
+ assert.strictEqual(apiResult.params.userId, '456')
133
+
134
+ const blogResult = router.match('get', '/blog/2024/03/hello-world')
135
+ assert.ok(blogResult, 'Deep multi-parameter route should match')
136
+ assert.strictEqual(blogResult.route, '/blog/:year/:month/:slug')
137
+ assert.strictEqual(blogResult.params.year, '2024')
138
+ assert.strictEqual(blogResult.params.month, '03')
139
+ assert.strictEqual(blogResult.params.slug, 'hello-world')
140
+ })
141
+
142
+ test('should prioritize static routes over dynamic routes for performance', () => {
143
+ // Setup mixed routes where static could conflict with dynamic
144
+ pikkuState(
145
+ null,
146
+ 'http',
147
+ 'routes',
148
+ new Map([
149
+ [
150
+ 'get',
151
+ new Map([
152
+ ['/api/users', mockRoute('/api/users')], // static
153
+ ['/api/:resource', mockRoute('/api/:resource')], // dynamic that could match above
154
+ ]),
155
+ ],
156
+ ])
157
+ )
158
+ pikkuState(null, 'http', 'middleware', new Map())
159
+ pikkuState(null, 'channel', 'channels', new Map())
160
+
161
+ router.initialize()
162
+
163
+ // Static route should be found first (O(1) lookup)
164
+ const result = router.match('get', '/api/users')
165
+ assert.ok(result, 'Route should match')
166
+ assert.strictEqual(result.route, '/api/users') // Should match static, not dynamic
167
+ assert.deepStrictEqual(result.params, {})
168
+ })
169
+ })
170
+
171
+ describe('Path Normalization', () => {
172
+ test('should normalize paths without leading slash', () => {
173
+ pikkuState(
174
+ null,
175
+ 'http',
176
+ 'routes',
177
+ new Map([
178
+ [
179
+ 'get',
180
+ new Map([
181
+ ['test', mockRoute('test')], // route without leading slash
182
+ ['api/health', mockRoute('api/health')],
183
+ ]),
184
+ ],
185
+ ])
186
+ )
187
+ pikkuState(null, 'http', 'middleware', new Map())
188
+ pikkuState(null, 'channel', 'channels', new Map())
189
+
190
+ router.initialize()
191
+
192
+ // Should match with or without leading slash in request
193
+ const result1 = router.match('get', '/test')
194
+ assert.ok(result1, 'Should match /test')
195
+ assert.strictEqual(result1.route, 'test')
196
+
197
+ const result2 = router.match('get', 'test')
198
+ assert.ok(result2, 'Should match test')
199
+ assert.strictEqual(result2.route, 'test')
200
+
201
+ const result3 = router.match('get', '/api/health')
202
+ assert.ok(result3, 'Should match /api/health')
203
+ assert.strictEqual(result3.route, 'api/health')
204
+ })
205
+ })
206
+
207
+ describe('Channel Routes Integration', () => {
208
+ test('should handle channel routes as GET routes', () => {
209
+ pikkuState(null, 'http', 'routes', new Map())
210
+ pikkuState(null, 'http', 'middleware', new Map())
211
+ pikkuState(
212
+ null,
213
+ 'channel',
214
+ 'channels',
215
+ new Map([
216
+ ['websocket-test', { name: 'websocket-test', route: '/ws/test' }],
217
+ ['chat', { name: 'chat', route: '/chat/:room' }],
218
+ ])
219
+ )
220
+
221
+ router.initialize()
222
+
223
+ // Channel routes should be accessible via GET method
224
+ const wsResult = router.match('get', '/ws/test')
225
+ assert.ok(wsResult, 'Static channel route should match')
226
+ assert.strictEqual(wsResult.route, '/ws/test')
227
+ assert.deepStrictEqual(wsResult.params, {})
228
+
229
+ const chatResult = router.match('get', '/chat/general')
230
+ assert.ok(chatResult, 'Dynamic channel route should match')
231
+ assert.strictEqual(chatResult.route, '/chat/:room')
232
+ assert.strictEqual(chatResult.params.room, 'general')
233
+ })
234
+ })
235
+
236
+ describe('Middleware Integration', () => {
237
+ test('should match route without returning middleware', () => {
238
+ const globalMiddleware = [async () => {}]
239
+ const routeMiddleware = [async () => {}]
240
+
241
+ pikkuState(
242
+ null,
243
+ 'http',
244
+ 'routes',
245
+ new Map([['get', new Map([['/api/test', mockRoute('/api/test')]])]])
246
+ )
247
+ pikkuState(
248
+ null,
249
+ 'http',
250
+ 'middleware',
251
+ new Map([
252
+ ['*', globalMiddleware],
253
+ ['/api/test', routeMiddleware],
254
+ ])
255
+ )
256
+ pikkuState(null, 'channel', 'channels', new Map())
257
+
258
+ router.initialize()
259
+
260
+ const result = router.match('get', '/api/test')
261
+ assert.ok(result, 'Route should match')
262
+ // Router only returns route and params, middleware is handled separately
263
+ assert.strictEqual(result.route, '/api/test')
264
+ assert.deepStrictEqual(result.params, {})
265
+ })
266
+ })
267
+
268
+ describe('No Match Scenarios', () => {
269
+ test('should return null for non-existent routes', () => {
270
+ pikkuState(
271
+ null,
272
+ 'http',
273
+ 'routes',
274
+ new Map([['get', new Map([['/existing', mockRoute('/existing')]])]])
275
+ )
276
+ pikkuState(null, 'http', 'middleware', new Map())
277
+ pikkuState(null, 'channel', 'channels', new Map())
278
+
279
+ router.initialize()
280
+
281
+ const result = router.match('get', '/non-existent')
282
+ assert.strictEqual(result, null)
283
+ })
284
+
285
+ test('should return null for unsupported HTTP methods', () => {
286
+ pikkuState(
287
+ null,
288
+ 'http',
289
+ 'routes',
290
+ new Map([['get', new Map([['/test', mockRoute('/test')]])]])
291
+ )
292
+ pikkuState(null, 'http', 'middleware', new Map())
293
+ pikkuState(null, 'channel', 'channels', new Map())
294
+
295
+ router.initialize()
296
+
297
+ const result = router.match('post', '/test')
298
+ assert.strictEqual(result, null)
299
+ })
300
+ })
301
+
302
+ describe('Lazy Initialization', () => {
303
+ test('should initialize automatically on first match call', () => {
304
+ pikkuState(
305
+ null,
306
+ 'http',
307
+ 'routes',
308
+ new Map([['get', new Map([['/test', mockRoute('/test')]])]])
309
+ )
310
+ pikkuState(null, 'http', 'middleware', new Map())
311
+ pikkuState(null, 'channel', 'channels', new Map())
312
+
313
+ // Don't call initialize manually
314
+ const result = router.match('get', '/test')
315
+ assert.ok(result, 'Should auto-initialize and match')
316
+ assert.strictEqual(result.route, '/test')
317
+ })
318
+ })
319
+ })
@@ -0,0 +1,126 @@
1
+ import type { MatchFunction } from 'path-to-regexp'
2
+ import { match } from 'path-to-regexp'
3
+ import type { MatchResult, Router } from './http-router.js'
4
+ import type { HTTPMethod } from '../http.types.js'
5
+ import { pikkuState } from '../../../pikku-state.js'
6
+
7
+ interface CompiledRoute {
8
+ matcher: MatchFunction<Partial<Record<string, string | string[]>>>
9
+ route: string
10
+ }
11
+
12
+ interface StaticRoute {
13
+ route: string
14
+ }
15
+
16
+ export class PathToRegexRouter implements Router {
17
+ private compiledRoutes: Map<HTTPMethod, Map<string, CompiledRoute>> =
18
+ new Map()
19
+ private staticRoutes: Map<HTTPMethod, Map<string, StaticRoute>> = new Map()
20
+ private isInitialized = false
21
+
22
+ public reset() {
23
+ this.compiledRoutes = new Map()
24
+ this.staticRoutes = new Map()
25
+ this.isInitialized = false
26
+ }
27
+
28
+ public initialize() {
29
+ const routes = pikkuState(null, 'http', 'routes')
30
+ const channelRoutes = pikkuState(null, 'channel', 'channels')
31
+
32
+ // Helper function to compile routes for a given method
33
+ const compileRoutesForMethod = (
34
+ method: HTTPMethod,
35
+ routeEntries: Iterable<[string, any]>
36
+ ) => {
37
+ const methodCompiledRoutes =
38
+ this.compiledRoutes.get(method) || new Map<string, CompiledRoute>()
39
+ const methodStaticRoutes =
40
+ this.staticRoutes.get(method) || new Map<string, StaticRoute>()
41
+
42
+ for (const [routePath] of routeEntries) {
43
+ // Normalize route path - ensure it starts with /
44
+ const normalizedRoutePath = routePath.startsWith('/')
45
+ ? routePath
46
+ : `/${routePath}`
47
+
48
+ // Check if route is static (no parameters or wildcards)
49
+ const isStaticRoute = !/\*|:/.test(normalizedRoutePath)
50
+
51
+ if (isStaticRoute) {
52
+ // Store static routes for O(1) lookup
53
+ methodStaticRoutes.set(normalizedRoutePath, {
54
+ route: routePath, // Keep the original route path for lookup in pikkuState
55
+ })
56
+ } else {
57
+ // Compile dynamic routes with path-to-regexp
58
+ const matcher = match(normalizedRoutePath, {
59
+ decode: decodeURIComponent,
60
+ })
61
+
62
+ methodCompiledRoutes.set(normalizedRoutePath, {
63
+ matcher,
64
+ route: routePath, // Keep the original route path for lookup in pikkuState
65
+ })
66
+ }
67
+ }
68
+
69
+ this.compiledRoutes.set(method, methodCompiledRoutes)
70
+ this.staticRoutes.set(method, methodStaticRoutes)
71
+ }
72
+
73
+ // Precompile all HTTP route matchers
74
+ for (const [method, routeMap] of routes.entries()) {
75
+ compileRoutesForMethod(method, routeMap.entries())
76
+ }
77
+
78
+ // Precompile all channel route matchers (treating them as GET routes for WebSocket upgrades)
79
+ const channelRoutesArray: Array<[string, any]> = Array.from(
80
+ channelRoutes.entries()
81
+ ).map(([, channel]) => [channel.route, channel])
82
+ compileRoutesForMethod('get', channelRoutesArray)
83
+
84
+ this.isInitialized = true
85
+ }
86
+
87
+ match(method: HTTPMethod, path: string): MatchResult {
88
+ if (!this.isInitialized) {
89
+ this.initialize()
90
+ }
91
+
92
+ // Normalize path - ensure it starts with /
93
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
94
+
95
+ // First, try static routes for O(1) lookup
96
+ const methodStaticRoutes = this.staticRoutes.get(method)
97
+ if (methodStaticRoutes) {
98
+ const staticRoute = methodStaticRoutes.get(normalizedPath)
99
+ if (staticRoute) {
100
+ return {
101
+ route: staticRoute.route,
102
+ params: {},
103
+ }
104
+ }
105
+ }
106
+
107
+ // If no static route matched, try dynamic routes
108
+ const methodRoutes = this.compiledRoutes.get(method)
109
+ if (!methodRoutes) {
110
+ return null
111
+ }
112
+
113
+ // Try each compiled route for this method
114
+ for (const [, compiledRoute] of methodRoutes.entries()) {
115
+ const result = compiledRoute.matcher(normalizedPath)
116
+ if (result) {
117
+ return {
118
+ route: compiledRoute.route,
119
+ params: result.params,
120
+ }
121
+ }
122
+ }
123
+
124
+ return null
125
+ }
126
+ }
@@ -0,0 +1,236 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { toWebRequest, applyWebResponse } from './web-request.js'
4
+ import type {
5
+ PikkuHTTPRequest,
6
+ PikkuHTTPResponse,
7
+ PikkuQuery,
8
+ } from './http.types.js'
9
+
10
+ const createMockRequest = (
11
+ overrides: Partial<{
12
+ method: string
13
+ path: string
14
+ headers: Record<string, string>
15
+ query: Record<string, string>
16
+ body: ArrayBuffer
17
+ }> = {}
18
+ ): PikkuHTTPRequest => ({
19
+ method: () => (overrides.method ?? 'get') as any,
20
+ path: () => overrides.path ?? '/test',
21
+ headers: () => overrides.headers ?? {},
22
+ header: (name: string) =>
23
+ (overrides.headers ?? {})[name.toLowerCase()] ?? null,
24
+ cookie: () => null,
25
+ params: () => ({}),
26
+ setParams: () => {},
27
+ query: () => (overrides.query ?? {}) as PikkuQuery,
28
+ json: async () => ({}),
29
+ arrayBuffer: async () => overrides.body ?? new ArrayBuffer(0),
30
+ data: async () => ({}) as any,
31
+ })
32
+
33
+ const createMockResponse = () => {
34
+ const state = {
35
+ statusCode: 0,
36
+ headers: {} as Record<string, string | string[]>,
37
+ body: null as string | null,
38
+ redirectUrl: null as string | null,
39
+ redirectStatus: null as number | null,
40
+ }
41
+
42
+ const res: PikkuHTTPResponse = {
43
+ status(code: number) {
44
+ state.statusCode = code
45
+ return res
46
+ },
47
+ header(name: string, value: string | string[]) {
48
+ state.headers[name.toLowerCase()] = value
49
+ return res
50
+ },
51
+ cookie() {
52
+ return res
53
+ },
54
+ json() {
55
+ return res
56
+ },
57
+ arrayBuffer(data: any) {
58
+ state.body = typeof data === 'string' ? data : null
59
+ return res
60
+ },
61
+ redirect(location: string, status?: number) {
62
+ state.redirectUrl = location
63
+ state.redirectStatus = status ?? 302
64
+ return res
65
+ },
66
+ }
67
+
68
+ return { res, state }
69
+ }
70
+
71
+ describe('toWebRequest', () => {
72
+ test('converts a GET request with correct URL and method', async () => {
73
+ const req = createMockRequest({
74
+ method: 'get',
75
+ path: '/api/test',
76
+ headers: { host: 'example.com' },
77
+ })
78
+
79
+ const webReq = toWebRequest(req)
80
+
81
+ assert.equal(webReq.method, 'GET')
82
+ assert.equal(new URL(webReq.url).pathname, '/api/test')
83
+ assert.equal(new URL(webReq.url).host, 'example.com')
84
+ assert.equal(webReq.body, null)
85
+ })
86
+
87
+ test('copies query parameters', async () => {
88
+ const req = createMockRequest({
89
+ query: { search: 'hello', page: '2' },
90
+ })
91
+
92
+ const webReq = toWebRequest(req)
93
+ const url = new URL(webReq.url)
94
+
95
+ assert.equal(url.searchParams.get('search'), 'hello')
96
+ assert.equal(url.searchParams.get('page'), '2')
97
+ })
98
+
99
+ test('copies all headers', async () => {
100
+ const req = createMockRequest({
101
+ headers: {
102
+ 'content-type': 'application/json',
103
+ authorization: 'Bearer abc123',
104
+ host: 'localhost',
105
+ },
106
+ })
107
+
108
+ const webReq = toWebRequest(req)
109
+
110
+ assert.equal(webReq.headers.get('content-type'), 'application/json')
111
+ assert.equal(webReq.headers.get('authorization'), 'Bearer abc123')
112
+ })
113
+
114
+ test('uses custom baseUrl when provided', async () => {
115
+ const req = createMockRequest({ path: '/callback' })
116
+
117
+ const webReq = toWebRequest(req, 'https://myapp.com')
118
+ const url = new URL(webReq.url)
119
+
120
+ assert.equal(url.origin, 'https://myapp.com')
121
+ assert.equal(url.pathname, '/callback')
122
+ })
123
+
124
+ test('falls back to localhost when no host header', async () => {
125
+ const req = createMockRequest({ path: '/test' })
126
+
127
+ const webReq = toWebRequest(req)
128
+ const url = new URL(webReq.url)
129
+
130
+ assert.equal(url.host, 'localhost')
131
+ })
132
+
133
+ test('POST request includes streaming body', async () => {
134
+ const bodyContent = JSON.stringify({
135
+ username: 'admin',
136
+ password: 'secret',
137
+ })
138
+ const encoder = new TextEncoder()
139
+ const bodyBuffer = encoder.encode(bodyContent).buffer as ArrayBuffer
140
+
141
+ const req = createMockRequest({
142
+ method: 'post',
143
+ path: '/signin',
144
+ headers: { 'content-type': 'application/json', host: 'localhost' },
145
+ body: bodyBuffer,
146
+ })
147
+
148
+ const webReq = toWebRequest(req)
149
+
150
+ assert.equal(webReq.method, 'POST')
151
+ assert.notEqual(webReq.body, null)
152
+
153
+ const text = await webReq.text()
154
+ assert.equal(text, bodyContent)
155
+ })
156
+
157
+ test('GET request has no body', async () => {
158
+ const req = createMockRequest({ method: 'get' })
159
+ const webReq = toWebRequest(req)
160
+ assert.equal(webReq.body, null)
161
+ })
162
+
163
+ test('HEAD request has no body', async () => {
164
+ const req = createMockRequest({ method: 'head' })
165
+ const webReq = toWebRequest(req)
166
+ assert.equal(webReq.body, null)
167
+ })
168
+
169
+ test('OPTIONS request has no body', async () => {
170
+ const req = createMockRequest({ method: 'options' })
171
+ const webReq = toWebRequest(req)
172
+ assert.equal(webReq.body, null)
173
+ })
174
+ })
175
+
176
+ describe('applyWebResponse', () => {
177
+ test('copies status code', async () => {
178
+ const { res, state } = createMockResponse()
179
+ const webRes = new Response('OK', { status: 201 })
180
+
181
+ await applyWebResponse(res, webRes)
182
+
183
+ assert.equal(state.statusCode, 201)
184
+ })
185
+
186
+ test('copies response headers', async () => {
187
+ const { res, state } = createMockResponse()
188
+ const webRes = new Response('', {
189
+ status: 200,
190
+ headers: {
191
+ 'content-type': 'text/html',
192
+ 'x-custom': 'value',
193
+ },
194
+ })
195
+
196
+ await applyWebResponse(res, webRes)
197
+
198
+ assert.equal(state.headers['content-type'], 'text/html')
199
+ assert.equal(state.headers['x-custom'], 'value')
200
+ })
201
+
202
+ test('handles redirect via location header', async () => {
203
+ const { res, state } = createMockResponse()
204
+ const webRes = new Response(null, {
205
+ status: 302,
206
+ headers: { location: 'https://github.com/login/oauth/authorize' },
207
+ })
208
+
209
+ await applyWebResponse(res, webRes)
210
+
211
+ assert.equal(state.redirectUrl, 'https://github.com/login/oauth/authorize')
212
+ assert.equal(state.statusCode, 302)
213
+ })
214
+
215
+ test('writes text body', async () => {
216
+ const { res, state } = createMockResponse()
217
+ const webRes = new Response('{"session": true}', {
218
+ status: 200,
219
+ headers: { 'content-type': 'application/json' },
220
+ })
221
+
222
+ await applyWebResponse(res, webRes)
223
+
224
+ assert.equal(state.body, '{"session": true}')
225
+ })
226
+
227
+ test('does not write empty body', async () => {
228
+ const { res, state } = createMockResponse()
229
+ const webRes = new Response(null, { status: 204 })
230
+
231
+ await applyWebResponse(res, webRes)
232
+
233
+ assert.equal(state.body, null)
234
+ assert.equal(state.statusCode, 204)
235
+ })
236
+ })