@pikku/core 0.12.4 → 0.12.6

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 (235) hide show
  1. package/CHANGELOG.md +21 -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/content-service.d.ts +2 -0
  9. package/dist/services/in-memory-workflow-service.d.ts +1 -0
  10. package/dist/services/in-memory-workflow-service.js +16 -11
  11. package/dist/services/workflow-service.d.ts +1 -0
  12. package/dist/wirings/ai-agent/ai-agent-prepare.js +16 -1
  13. package/dist/wirings/channel/channel-middleware-runner.d.ts +1 -0
  14. package/dist/wirings/channel/channel-middleware-runner.js +5 -0
  15. package/dist/wirings/http/http-runner.js +1 -1
  16. package/dist/wirings/workflow/pikku-workflow-service.d.ts +6 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.js +51 -16
  18. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  19. package/package.json +5 -3
  20. package/src/crypto-utils.test.ts +214 -0
  21. package/src/crypto-utils.ts +213 -0
  22. package/src/dev/hot-reload.test.ts +484 -0
  23. package/src/dev/hot-reload.ts +212 -0
  24. package/src/errors/error-handler.ts +62 -0
  25. package/src/errors/error.test.ts +195 -0
  26. package/src/errors/errors.ts +374 -0
  27. package/src/errors/index.ts +2 -0
  28. package/src/factory-functions.test.ts +109 -0
  29. package/src/function/function-runner.test.ts +536 -0
  30. package/src/function/function-runner.ts +365 -0
  31. package/src/function/functions.types.ts +304 -0
  32. package/src/function/index.ts +5 -0
  33. package/src/handle-error.test.ts +424 -0
  34. package/src/handle-error.ts +69 -0
  35. package/src/index.ts +118 -0
  36. package/src/internal.ts +6 -0
  37. package/src/middleware/auth-apikey.test.ts +363 -0
  38. package/src/middleware/auth-apikey.ts +47 -0
  39. package/src/middleware/auth-bearer.test.ts +450 -0
  40. package/src/middleware/auth-bearer.ts +72 -0
  41. package/src/middleware/auth-cookie.test.ts +528 -0
  42. package/src/middleware/auth-cookie.ts +80 -0
  43. package/src/middleware/cors.test.ts +424 -0
  44. package/src/middleware/cors.ts +104 -0
  45. package/src/middleware/index.ts +5 -0
  46. package/src/middleware/remote-auth.test.ts +488 -0
  47. package/src/middleware/remote-auth.ts +68 -0
  48. package/src/middleware/timeout.ts +15 -0
  49. package/src/middleware-runner.test.ts +418 -0
  50. package/src/middleware-runner.ts +240 -0
  51. package/src/permissions.test.ts +434 -0
  52. package/src/permissions.ts +327 -0
  53. package/src/pikku-request.ts +23 -0
  54. package/src/pikku-response.ts +5 -0
  55. package/src/pikku-state.test.ts +224 -0
  56. package/src/pikku-state.ts +216 -0
  57. package/src/run-tests-script.test.ts +49 -0
  58. package/src/schema.test.ts +249 -0
  59. package/src/schema.ts +151 -0
  60. package/src/services/ai-agent-runner-service.ts +41 -0
  61. package/src/services/ai-run-state-service.ts +20 -0
  62. package/src/services/ai-storage-service.ts +39 -0
  63. package/src/services/content-service.ts +81 -0
  64. package/src/services/deployment-service.ts +22 -0
  65. package/src/services/gateway-service.ts +20 -0
  66. package/src/services/gopass-secrets.ts +98 -0
  67. package/src/services/in-memory-ai-run-state-service.ts +73 -0
  68. package/src/services/in-memory-trigger-service.ts +64 -0
  69. package/src/services/in-memory-workflow-service.test.ts +351 -0
  70. package/src/services/in-memory-workflow-service.ts +512 -0
  71. package/src/services/index.ts +56 -0
  72. package/src/services/jwt-service.ts +30 -0
  73. package/src/services/local-content.ts +120 -0
  74. package/src/services/local-gateway-service.ts +62 -0
  75. package/src/services/local-secrets.test.ts +80 -0
  76. package/src/services/local-secrets.ts +94 -0
  77. package/src/services/local-variables.test.ts +61 -0
  78. package/src/services/local-variables.ts +39 -0
  79. package/src/services/logger-console.test.ts +118 -0
  80. package/src/services/logger-console.ts +98 -0
  81. package/src/services/logger.ts +57 -0
  82. package/src/services/scheduler-service.ts +86 -0
  83. package/src/services/schema-service.ts +33 -0
  84. package/src/services/scoped-secret-service.test.ts +74 -0
  85. package/src/services/scoped-secret-service.ts +41 -0
  86. package/src/services/secret-service.ts +38 -0
  87. package/src/services/trigger-service.ts +17 -0
  88. package/src/services/typed-secret-service.test.ts +93 -0
  89. package/src/services/typed-secret-service.ts +70 -0
  90. package/src/services/typed-variables-service.test.ts +73 -0
  91. package/src/services/typed-variables-service.ts +81 -0
  92. package/src/services/user-session-service.test.ts +113 -0
  93. package/src/services/user-session-service.ts +86 -0
  94. package/src/services/variables-service.ts +11 -0
  95. package/src/services/workflow-service.ts +96 -0
  96. package/src/testing/index.ts +2 -0
  97. package/src/testing/service-tests.ts +873 -0
  98. package/src/time-utils.test.ts +56 -0
  99. package/src/time-utils.ts +107 -0
  100. package/src/types/core.types.ts +478 -0
  101. package/src/types/state.types.ts +188 -0
  102. package/src/utils/hash.test.ts +68 -0
  103. package/src/utils/hash.ts +26 -0
  104. package/src/utils.test.ts +350 -0
  105. package/src/utils.ts +129 -0
  106. package/src/version.test.ts +80 -0
  107. package/src/version.ts +25 -0
  108. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  109. package/src/wirings/ai-agent/ai-agent-helpers.test.ts +152 -0
  110. package/src/wirings/ai-agent/ai-agent-helpers.ts +76 -0
  111. package/src/wirings/ai-agent/ai-agent-memory.ts +310 -0
  112. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +115 -0
  113. package/src/wirings/ai-agent/ai-agent-model-config.ts +43 -0
  114. package/src/wirings/ai-agent/ai-agent-prepare.ts +659 -0
  115. package/src/wirings/ai-agent/ai-agent-registry.test.ts +37 -0
  116. package/src/wirings/ai-agent/ai-agent-registry.ts +82 -0
  117. package/src/wirings/ai-agent/ai-agent-runner.test.ts +319 -0
  118. package/src/wirings/ai-agent/ai-agent-runner.ts +773 -0
  119. package/src/wirings/ai-agent/ai-agent-stream.test.ts +859 -0
  120. package/src/wirings/ai-agent/ai-agent-stream.ts +1153 -0
  121. package/src/wirings/ai-agent/ai-agent.types.ts +382 -0
  122. package/src/wirings/ai-agent/index.ts +37 -0
  123. package/src/wirings/channel/channel-common.ts +90 -0
  124. package/src/wirings/channel/channel-handler.ts +250 -0
  125. package/src/wirings/channel/channel-middleware-runner.ts +126 -0
  126. package/src/wirings/channel/channel-runner.ts +166 -0
  127. package/src/wirings/channel/channel-store.ts +29 -0
  128. package/src/wirings/channel/channel.types.ts +172 -0
  129. package/src/wirings/channel/define-channel-routes.ts +25 -0
  130. package/src/wirings/channel/eventhub-service.ts +34 -0
  131. package/src/wirings/channel/eventhub-store.ts +13 -0
  132. package/src/wirings/channel/index.ts +24 -0
  133. package/src/wirings/channel/local/index.ts +3 -0
  134. package/src/wirings/channel/local/local-channel-handler.ts +81 -0
  135. package/src/wirings/channel/local/local-channel-runner.test.ts +215 -0
  136. package/src/wirings/channel/local/local-channel-runner.ts +210 -0
  137. package/src/wirings/channel/local/local-eventhub-service.test.ts +95 -0
  138. package/src/wirings/channel/local/local-eventhub-service.ts +101 -0
  139. package/src/wirings/channel/log-channels.ts +20 -0
  140. package/src/wirings/channel/pikku-abstract-channel-handler.test.ts +54 -0
  141. package/src/wirings/channel/pikku-abstract-channel-handler.ts +45 -0
  142. package/src/wirings/channel/serverless/index.ts +5 -0
  143. package/src/wirings/channel/serverless/serverless-channel-runner.ts +289 -0
  144. package/src/wirings/cli/channel/cli-channel-runner.ts +136 -0
  145. package/src/wirings/cli/channel/index.ts +1 -0
  146. package/src/wirings/cli/cli-runner.test.ts +403 -0
  147. package/src/wirings/cli/cli-runner.ts +529 -0
  148. package/src/wirings/cli/cli.types.ts +358 -0
  149. package/src/wirings/cli/command-parser.test.ts +445 -0
  150. package/src/wirings/cli/command-parser.ts +504 -0
  151. package/src/wirings/cli/define-cli-commands.ts +24 -0
  152. package/src/wirings/cli/index.ts +19 -0
  153. package/src/wirings/gateway/gateway-runner.test.ts +474 -0
  154. package/src/wirings/gateway/gateway-runner.ts +411 -0
  155. package/src/wirings/gateway/gateway.types.ts +149 -0
  156. package/src/wirings/gateway/index.ts +13 -0
  157. package/src/wirings/http/http-routes.test.ts +322 -0
  158. package/src/wirings/http/http-routes.ts +197 -0
  159. package/src/wirings/http/http-runner.test.ts +144 -0
  160. package/src/wirings/http/http-runner.ts +588 -0
  161. package/src/wirings/http/http.types.ts +369 -0
  162. package/src/wirings/http/index.ts +28 -0
  163. package/src/wirings/http/log-http-routes.ts +22 -0
  164. package/src/wirings/http/pikku-fetch-http-request.test.ts +237 -0
  165. package/src/wirings/http/pikku-fetch-http-request.ts +170 -0
  166. package/src/wirings/http/pikku-fetch-http-response.test.ts +82 -0
  167. package/src/wirings/http/pikku-fetch-http-response.ts +147 -0
  168. package/src/wirings/http/routers/http-router.ts +13 -0
  169. package/src/wirings/http/routers/path-to-regex.test.ts +319 -0
  170. package/src/wirings/http/routers/path-to-regex.ts +126 -0
  171. package/src/wirings/http/web-request.test.ts +236 -0
  172. package/src/wirings/http/web-request.ts +104 -0
  173. package/src/wirings/mcp/index.ts +27 -0
  174. package/src/wirings/mcp/mcp-endpoint-registry.test.ts +389 -0
  175. package/src/wirings/mcp/mcp-endpoint-registry.ts +159 -0
  176. package/src/wirings/mcp/mcp-runner.ts +323 -0
  177. package/src/wirings/mcp/mcp.types.ts +216 -0
  178. package/src/wirings/node/index.ts +2 -0
  179. package/src/wirings/node/node.types.ts +23 -0
  180. package/src/wirings/oauth2/index.ts +3 -0
  181. package/src/wirings/oauth2/oauth2-client.test.ts +929 -0
  182. package/src/wirings/oauth2/oauth2-client.ts +335 -0
  183. package/src/wirings/oauth2/oauth2.types.ts +69 -0
  184. package/src/wirings/oauth2/wire-oauth2-credential.ts +23 -0
  185. package/src/wirings/queue/index.ts +31 -0
  186. package/src/wirings/queue/queue-runner.test.ts +710 -0
  187. package/src/wirings/queue/queue-runner.ts +192 -0
  188. package/src/wirings/queue/queue.types.ts +189 -0
  189. package/src/wirings/queue/register-queue-helper.ts +60 -0
  190. package/src/wirings/queue/validate-worker-config.test.ts +108 -0
  191. package/src/wirings/queue/validate-worker-config.ts +116 -0
  192. package/src/wirings/rpc/index.ts +4 -0
  193. package/src/wirings/rpc/rpc-runner.ts +460 -0
  194. package/src/wirings/rpc/rpc-types.ts +56 -0
  195. package/src/wirings/rpc/wire-addon.ts +21 -0
  196. package/src/wirings/scheduler/index.ts +11 -0
  197. package/src/wirings/scheduler/log-schedulers.ts +20 -0
  198. package/src/wirings/scheduler/scheduler-runner.test.ts +660 -0
  199. package/src/wirings/scheduler/scheduler-runner.ts +133 -0
  200. package/src/wirings/scheduler/scheduler.types.ts +53 -0
  201. package/src/wirings/secret/index.ts +9 -0
  202. package/src/wirings/secret/secret.types.ts +32 -0
  203. package/src/wirings/secret/validate-secret-definitions.test.ts +140 -0
  204. package/src/wirings/secret/validate-secret-definitions.ts +82 -0
  205. package/src/wirings/trigger/index.ts +10 -0
  206. package/src/wirings/trigger/pikku-trigger-service.ts +112 -0
  207. package/src/wirings/trigger/trigger-runner.test.ts +79 -0
  208. package/src/wirings/trigger/trigger-runner.ts +135 -0
  209. package/src/wirings/trigger/trigger.types.ts +178 -0
  210. package/src/wirings/variable/index.ts +8 -0
  211. package/src/wirings/variable/validate-variable-definitions.test.ts +91 -0
  212. package/src/wirings/variable/validate-variable-definitions.ts +69 -0
  213. package/src/wirings/variable/variable.types.ts +22 -0
  214. package/src/wirings/workflow/dsl/index.ts +31 -0
  215. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +320 -0
  216. package/src/wirings/workflow/dsl/workflow-runner.ts +28 -0
  217. package/src/wirings/workflow/graph/graph-node.ts +222 -0
  218. package/src/wirings/workflow/graph/graph-runner.test.ts +487 -0
  219. package/src/wirings/workflow/graph/graph-runner.ts +816 -0
  220. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  221. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  222. package/src/wirings/workflow/graph/index.ts +19 -0
  223. package/src/wirings/workflow/graph/template.test.ts +49 -0
  224. package/src/wirings/workflow/graph/template.ts +42 -0
  225. package/src/wirings/workflow/graph/wire-workflow-graph.ts +29 -0
  226. package/src/wirings/workflow/graph/workflow-graph.types.ts +104 -0
  227. package/src/wirings/workflow/index.ts +82 -0
  228. package/src/wirings/workflow/pikku-workflow-service.test.ts +200 -0
  229. package/src/wirings/workflow/pikku-workflow-service.ts +1229 -0
  230. package/src/wirings/workflow/workflow-helpers.test.ts +129 -0
  231. package/src/wirings/workflow/workflow-helpers.ts +79 -0
  232. package/src/wirings/workflow/workflow.types.ts +276 -0
  233. package/tsconfig.json +14 -0
  234. package/tsconfig.tsbuildinfo +1 -0
  235. package/lcov.info +0 -18891
@@ -0,0 +1,484 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+
7
+ import { pikkuState, resetPikkuState } from '../pikku-state.js'
8
+ import { addFunction } from '../function/function-runner.js'
9
+ import { fetch, wireHTTP } from '../wirings/http/http-runner.js'
10
+ import { httpRouter } from '../wirings/http/routers/http-router.js'
11
+ import {
12
+ wireScheduler,
13
+ runScheduledTask,
14
+ } from '../wirings/scheduler/scheduler-runner.js'
15
+ import { wireQueueWorker, runQueueJob } from '../wirings/queue/queue-runner.js'
16
+ import { pikkuDevReloader } from './hot-reload.js'
17
+ import {
18
+ PikkuMockRequest,
19
+ PikkuMockResponse,
20
+ } from '../wirings/channel/local/local-channel-runner.test.js'
21
+
22
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
23
+
24
+ const createMockLogger = () => {
25
+ const logs: Array<{ level: string; message: string }> = []
26
+ return {
27
+ info: (msg: string) => logs.push({ level: 'info', message: String(msg) }),
28
+ warn: (msg: string) => logs.push({ level: 'warn', message: String(msg) }),
29
+ error: (msg: string | Error) =>
30
+ logs.push({
31
+ level: 'error',
32
+ message: msg instanceof Error ? msg.message : String(msg),
33
+ }),
34
+ debug: (msg: string) => logs.push({ level: 'debug', message: String(msg) }),
35
+ getLogs: () => logs,
36
+ setLevel: () => {},
37
+ }
38
+ }
39
+
40
+ const writeFunctionModule = async (
41
+ dir: string,
42
+ filename: string,
43
+ returnValue: string
44
+ ) => {
45
+ const jsContent = `export const ${filename.replace('.ts', '')} = { func: async () => (${returnValue}) };\n`
46
+ await writeFile(join(dir, filename.replace('.ts', '.js')), jsContent)
47
+ await writeFile(join(dir, filename), `// ts trigger ${Date.now()}`)
48
+ }
49
+
50
+ describe('pikkuDevReloader', () => {
51
+ let tmpDir: string
52
+ let reloader: { close: () => void } | undefined
53
+ let mockLogger: ReturnType<typeof createMockLogger>
54
+
55
+ beforeEach(async () => {
56
+ resetPikkuState()
57
+ httpRouter.reset()
58
+ tmpDir = await mkdtemp(join(tmpdir(), 'pikku-hot-reload-test-'))
59
+ await writeFile(
60
+ join(tmpDir, 'package.json'),
61
+ JSON.stringify({ type: 'module' })
62
+ )
63
+ mockLogger = createMockLogger()
64
+
65
+ pikkuState(null, 'package', 'singletonServices', {
66
+ logger: mockLogger,
67
+ } as any)
68
+ pikkuState(null, 'package', 'factories', {
69
+ createWireServices: async () => ({}),
70
+ } as any)
71
+ })
72
+
73
+ afterEach(async () => {
74
+ reloader?.close()
75
+ reloader = undefined
76
+ await rm(tmpDir, { recursive: true, force: true })
77
+ })
78
+
79
+ test('should hot-reload a function and pick up new return value', async () => {
80
+ addFunction('myFunc', {
81
+ func: async () => ({ version: 1 }),
82
+ })
83
+
84
+ await writeFunctionModule(tmpDir, 'myFunc.ts', '{ version: 1 }')
85
+
86
+ reloader = await pikkuDevReloader({
87
+ srcDirectories: [tmpDir],
88
+ logger: mockLogger,
89
+ pikkuDir: tmpDir,
90
+ })
91
+
92
+ const funcBefore = pikkuState(null, 'function', 'functions').get('myFunc')!
93
+ assert.deepEqual(await funcBefore.func({} as any, {}, {} as any), {
94
+ version: 1,
95
+ })
96
+
97
+ await writeFunctionModule(tmpDir, 'myFunc.ts', '{ version: 2 }')
98
+
99
+ await wait(300)
100
+
101
+ const funcAfter = pikkuState(null, 'function', 'functions').get('myFunc')!
102
+ assert.deepEqual(await funcAfter.func({} as any, {}, {} as any), {
103
+ version: 2,
104
+ })
105
+
106
+ const reloadLog = mockLogger
107
+ .getLogs()
108
+ .find(
109
+ (l) =>
110
+ l.message.includes('Hot-reloaded') && l.message.includes('myFunc')
111
+ )
112
+ assert.ok(reloadLog, 'Should log hot-reload message')
113
+ })
114
+
115
+ test('should not replace a function that is not registered', async () => {
116
+ addFunction('registeredFunc', {
117
+ func: async () => ({ name: 'registered' }),
118
+ })
119
+
120
+ await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
121
+
122
+ reloader = await pikkuDevReloader({
123
+ srcDirectories: [tmpDir],
124
+ logger: mockLogger,
125
+ pikkuDir: tmpDir,
126
+ })
127
+
128
+ await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "updated" }')
129
+
130
+ await wait(300)
131
+
132
+ assert.equal(
133
+ pikkuState(null, 'function', 'functions').has('unknownFunc'),
134
+ false
135
+ )
136
+ })
137
+
138
+ test('should keep old code when JS import fails', async () => {
139
+ addFunction('badFunc', {
140
+ func: async () => ({ working: true }),
141
+ })
142
+
143
+ await writeFile(
144
+ join(tmpDir, 'badFunc.js'),
145
+ 'export const badFunc = { func: async () => ({ working: true }) };\n'
146
+ )
147
+ await writeFile(join(tmpDir, 'badFunc.ts'), '// initial')
148
+
149
+ reloader = await pikkuDevReloader({
150
+ srcDirectories: [tmpDir],
151
+ logger: mockLogger,
152
+ pikkuDir: tmpDir,
153
+ })
154
+
155
+ await writeFile(
156
+ join(tmpDir, 'badFunc.js'),
157
+ 'this is not valid javascript {{{'
158
+ )
159
+ await writeFile(join(tmpDir, 'badFunc.ts'), `// trigger ${Date.now()}`)
160
+
161
+ await wait(300)
162
+
163
+ const func = pikkuState(null, 'function', 'functions').get('badFunc')!
164
+ assert.deepEqual(await func.func({} as any, {}, {} as any), {
165
+ working: true,
166
+ })
167
+ })
168
+
169
+ test('should ignore non-ts files, test files, and gen files', async () => {
170
+ addFunction('someFunc', {
171
+ func: async () => ({ original: true }),
172
+ })
173
+
174
+ reloader = await pikkuDevReloader({
175
+ srcDirectories: [tmpDir],
176
+ logger: mockLogger,
177
+ pikkuDir: tmpDir,
178
+ })
179
+
180
+ await writeFile(join(tmpDir, 'someFunc.test.ts'), '// test file change')
181
+ await writeFile(join(tmpDir, 'someFunc.d.ts'), '// declaration file change')
182
+ await writeFile(join(tmpDir, 'someFunc.gen.ts'), '// gen file change')
183
+ await writeFile(join(tmpDir, 'readme.md'), '# changed')
184
+
185
+ await wait(300)
186
+
187
+ const reloadLogs = mockLogger
188
+ .getLogs()
189
+ .filter((l) => l.message.includes('Hot-reloaded'))
190
+ assert.equal(reloadLogs.length, 0)
191
+ })
192
+
193
+ test('should hot-reload function used via HTTP wire', async () => {
194
+ const sessionMiddleware = async (_services: any, wire: any, next: any) => {
195
+ wire.setSession?.({ userId: 'test' } as any)
196
+ await next()
197
+ }
198
+
199
+ pikkuState(null, 'function', 'meta', {
200
+ httpFunc: {
201
+ pikkuFuncId: 'httpFunc',
202
+ },
203
+ } as any)
204
+ pikkuState(null, 'http', 'meta', {
205
+ get: {
206
+ '/hot-test': {
207
+ pikkuFuncId: 'httpFunc',
208
+ route: '/hot-test',
209
+ method: 'get',
210
+ },
211
+ },
212
+ post: {},
213
+ delete: {},
214
+ patch: {},
215
+ head: {},
216
+ put: {},
217
+ options: {},
218
+ })
219
+
220
+ addFunction('httpFunc', { func: async () => ({ value: 'old' }) })
221
+
222
+ wireHTTP({
223
+ route: '/hot-test',
224
+ method: 'get',
225
+ func: {
226
+ func: async () => ({ value: 'old' }),
227
+ middleware: [sessionMiddleware],
228
+ },
229
+ })
230
+ httpRouter.initialize()
231
+
232
+ const requestBefore = new PikkuMockRequest('/hot-test', 'get')
233
+ const responseBefore = await fetch(requestBefore)
234
+ assert.deepEqual(await responseBefore.json(), { value: 'old' })
235
+
236
+ await writeFunctionModule(tmpDir, 'httpFunc.ts', '{ value: "new" }')
237
+
238
+ reloader = await pikkuDevReloader({
239
+ srcDirectories: [tmpDir],
240
+ logger: mockLogger,
241
+ pikkuDir: tmpDir,
242
+ })
243
+
244
+ await writeFunctionModule(tmpDir, 'httpFunc.ts', '{ value: "new" }')
245
+
246
+ await wait(300)
247
+
248
+ const funcAfter = pikkuState(null, 'function', 'functions').get('httpFunc')!
249
+ assert.deepEqual(await funcAfter.func({} as any, {}, {} as any), {
250
+ value: 'new',
251
+ })
252
+ })
253
+
254
+ test('should hot-reload function used via scheduler wire', async () => {
255
+ const taskResult = { ref: 'initial' }
256
+
257
+ pikkuState(null, 'scheduler', 'meta')['hotTask'] = {
258
+ pikkuFuncId: 'hotTask',
259
+ name: 'hotTask',
260
+ schedule: '0 0 * * *',
261
+ }
262
+ pikkuState(null, 'function', 'meta')['hotTask'] = {
263
+ pikkuFuncId: 'hotTask',
264
+ inputSchemaName: null,
265
+ outputSchemaName: null,
266
+ }
267
+
268
+ addFunction('hotTask', {
269
+ func: async () => {
270
+ taskResult.ref = 'v1'
271
+ },
272
+ auth: false,
273
+ })
274
+
275
+ wireScheduler({
276
+ name: 'hotTask',
277
+ schedule: '0 0 * * *',
278
+ func: {
279
+ func: async () => {
280
+ taskResult.ref = 'v1'
281
+ },
282
+ auth: false,
283
+ },
284
+ })
285
+
286
+ await runScheduledTask({ name: 'hotTask' })
287
+ assert.equal(taskResult.ref, 'v1')
288
+
289
+ const jsV1 = `export const hotTask = { func: async () => { }, auth: false };\n`
290
+ await writeFile(join(tmpDir, 'hotTask.js'), jsV1)
291
+ await writeFile(join(tmpDir, 'hotTask.ts'), '// initial')
292
+
293
+ reloader = await pikkuDevReloader({
294
+ srcDirectories: [tmpDir],
295
+ logger: mockLogger,
296
+ pikkuDir: tmpDir,
297
+ })
298
+
299
+ // Write updated function via file system and let the watcher pick it up
300
+ const jsV2 = `export const hotTask = { func: async () => { return { reloaded: true }; }, auth: false };\n`
301
+ await writeFile(join(tmpDir, 'hotTask.js'), jsV2)
302
+ await writeFile(join(tmpDir, 'hotTask.ts'), `// trigger ${Date.now()}`)
303
+
304
+ await wait(300)
305
+
306
+ // Verify the function was reloaded via the watcher
307
+ const reloadLog = mockLogger
308
+ .getLogs()
309
+ .find(
310
+ (l) =>
311
+ l.message.includes('Hot-reloaded') && l.message.includes('hotTask')
312
+ )
313
+ assert.ok(reloadLog, 'Should log hot-reload for hotTask')
314
+
315
+ // runScheduledTask uses runPikkuFunc which reads from the functions Map,
316
+ // so it will pick up the hot-reloaded function (no longer sets taskResult.ref)
317
+ await runScheduledTask({ name: 'hotTask' })
318
+
319
+ // The reloaded function no longer sets taskResult.ref, so it should
320
+ // still be 'v1' (proving the old function was replaced)
321
+ assert.equal(taskResult.ref, 'v1')
322
+ })
323
+
324
+ test('should hot-reload function used via queue wire', async () => {
325
+ pikkuState(null, 'queue', 'meta')['hot-queue'] = {
326
+ pikkuFuncId: 'queue_hot-queue',
327
+ name: 'hot-queue',
328
+ }
329
+ pikkuState(null, 'function', 'meta')['queue_hot-queue'] = {
330
+ pikkuFuncId: 'queue_hot-queue',
331
+ inputSchemaName: null,
332
+ outputSchemaName: null,
333
+ }
334
+
335
+ addFunction('queue_hot-queue', {
336
+ func: async () => ({ result: 'v1' }),
337
+ auth: false,
338
+ })
339
+
340
+ wireQueueWorker({
341
+ name: 'hot-queue',
342
+ func: {
343
+ func: async () => ({ result: 'v1' }),
344
+ auth: false,
345
+ },
346
+ })
347
+
348
+ const job = {
349
+ id: 'job-1',
350
+ queueName: 'hot-queue',
351
+ status: async () => 'active' as const,
352
+ data: {},
353
+ }
354
+
355
+ const resultV1 = await runQueueJob({ job })
356
+ assert.deepEqual(resultV1, { result: 'v1' })
357
+
358
+ addFunction('queue_hot-queue', {
359
+ func: async () => ({ result: 'v2' }),
360
+ auth: false,
361
+ })
362
+
363
+ const job2 = {
364
+ id: 'job-2',
365
+ queueName: 'hot-queue',
366
+ status: async () => 'active' as const,
367
+ data: {},
368
+ }
369
+
370
+ const resultV2 = await runQueueJob({ job: job2 })
371
+ assert.deepEqual(resultV2, { result: 'v2' })
372
+ })
373
+
374
+ test('should debounce rapid file changes', async () => {
375
+ addFunction('debounceFunc', {
376
+ func: async () => ({ count: 0 }),
377
+ })
378
+
379
+ await writeFunctionModule(tmpDir, 'debounceFunc.ts', '{ count: 0 }')
380
+
381
+ reloader = await pikkuDevReloader({
382
+ srcDirectories: [tmpDir],
383
+ logger: mockLogger,
384
+ pikkuDir: tmpDir,
385
+ })
386
+
387
+ for (let i = 1; i <= 5; i++) {
388
+ await writeFunctionModule(tmpDir, 'debounceFunc.ts', `{ count: ${i} }`)
389
+ await wait(10)
390
+ }
391
+
392
+ await wait(300)
393
+
394
+ const func = pikkuState(null, 'function', 'functions').get('debounceFunc')!
395
+ const result = await func.func({} as any, {}, {} as any)
396
+ assert.equal(result.count, 5)
397
+ })
398
+
399
+ test('should watch subdirectories', async () => {
400
+ const subDir = join(tmpDir, 'functions')
401
+ await mkdir(subDir)
402
+
403
+ addFunction('subFunc', {
404
+ func: async () => ({ nested: false }),
405
+ })
406
+
407
+ const jsContent = `export const subFunc = { func: async () => ({ nested: false }) };\n`
408
+ await writeFile(join(subDir, 'subFunc.js'), jsContent)
409
+ await writeFile(join(subDir, 'subFunc.ts'), '// initial')
410
+
411
+ reloader = await pikkuDevReloader({
412
+ srcDirectories: [tmpDir],
413
+ logger: mockLogger,
414
+ pikkuDir: tmpDir,
415
+ })
416
+
417
+ const jsContentNew = `export const subFunc = { func: async () => ({ nested: true }) };\n`
418
+ await writeFile(join(subDir, 'subFunc.js'), jsContentNew)
419
+ await writeFile(join(subDir, 'subFunc.ts'), `// trigger ${Date.now()}`)
420
+
421
+ await wait(300)
422
+
423
+ const func = pikkuState(null, 'function', 'functions').get('subFunc')!
424
+ assert.deepEqual(await func.func({} as any, {}, {} as any), {
425
+ nested: true,
426
+ })
427
+ })
428
+
429
+ test('should properly clean up on close', async () => {
430
+ addFunction('cleanupFunc', {
431
+ func: async () => ({ v: 1 }),
432
+ })
433
+
434
+ await writeFunctionModule(tmpDir, 'cleanupFunc.ts', '{ v: 1 }')
435
+
436
+ reloader = await pikkuDevReloader({
437
+ srcDirectories: [tmpDir],
438
+ logger: mockLogger,
439
+ pikkuDir: tmpDir,
440
+ })
441
+
442
+ reloader.close()
443
+
444
+ await writeFunctionModule(tmpDir, 'cleanupFunc.ts', '{ v: 999 }')
445
+
446
+ await wait(300)
447
+
448
+ const func = pikkuState(null, 'function', 'functions').get('cleanupFunc')!
449
+ assert.deepEqual(await func.func({} as any, {}, {} as any), { v: 1 })
450
+
451
+ reloader = undefined
452
+ })
453
+
454
+ test('verifies in-flight request completes with old code after swap', async () => {
455
+ let resolveBlock: (() => void) | undefined
456
+ const blockPromise = new Promise<void>((resolve) => {
457
+ resolveBlock = resolve
458
+ })
459
+
460
+ addFunction('inflightFunc', {
461
+ func: async () => {
462
+ await blockPromise
463
+ return { version: 'old' }
464
+ },
465
+ })
466
+
467
+ const inflightPromise = pikkuState(null, 'function', 'functions')
468
+ .get('inflightFunc')!
469
+ .func({} as any, {}, {} as any)
470
+
471
+ addFunction('inflightFunc', {
472
+ func: async () => ({ version: 'new' }),
473
+ })
474
+
475
+ resolveBlock!()
476
+ const inflightResult = await inflightPromise
477
+ assert.deepEqual(inflightResult, { version: 'old' })
478
+
479
+ const newResult = await pikkuState(null, 'function', 'functions')
480
+ .get('inflightFunc')!
481
+ .func({} as any, {}, {} as any)
482
+ assert.deepEqual(newResult, { version: 'new' })
483
+ })
484
+ })
@@ -0,0 +1,212 @@
1
+ import { watch, type FSWatcher } from 'node:fs'
2
+ import { stat, readFile } from 'node:fs/promises'
3
+ import { join, resolve, relative } from 'node:path'
4
+
5
+ import { pikkuState } from '../pikku-state.js'
6
+ import { addFunction } from '../function/function-runner.js'
7
+ import { clearMiddlewareCache } from '../middleware-runner.js'
8
+ import { clearPermissionsCache } from '../permissions.js'
9
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
10
+ import { httpRouter } from '../wirings/http/routers/http-router.js'
11
+ import { addSchema, compileAllSchemas } from '../schema.js'
12
+ import type { Logger } from '../services/logger.js'
13
+ import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
14
+
15
+ interface PikkuDevReloaderOptions {
16
+ srcDirectories: string[]
17
+ logger: Logger
18
+ pikkuDir?: string
19
+ }
20
+
21
+ const isFunctionConfig = (
22
+ value: unknown
23
+ ): value is CorePikkuFunctionConfig<any, any> => {
24
+ return (
25
+ typeof value === 'object' &&
26
+ value !== null &&
27
+ 'func' in value &&
28
+ typeof (value as any).func === 'function'
29
+ )
30
+ }
31
+
32
+ const findCompiledFile = async (
33
+ tsFile: string,
34
+ srcDir: string,
35
+ pikkuDir: string
36
+ ): Promise<string | null> => {
37
+ const rel = relative(srcDir, tsFile).replace(/\.ts$/, '.js')
38
+ const candidates = [
39
+ join(pikkuDir, 'dist', rel),
40
+ join(srcDir, rel),
41
+ tsFile.replace(/\.ts$/, '.js'),
42
+ ]
43
+ for (const candidate of candidates) {
44
+ try {
45
+ await stat(candidate)
46
+ return candidate
47
+ } catch {
48
+ // not found, try next
49
+ }
50
+ }
51
+ return null
52
+ }
53
+
54
+ // Use data: URLs to import modules. This bypasses TypeScript loaders
55
+ // (e.g. tsx) that intercept file:// imports and break dynamic ESM loading.
56
+ // Each import gets unique content so there's no module cache to worry about.
57
+ const reimportModule = async (
58
+ filePath: string
59
+ ): Promise<Record<string, unknown> | null> => {
60
+ try {
61
+ const content = await readFile(resolve(filePath), 'utf-8')
62
+ const dataUrl =
63
+ 'data:text/javascript;base64,' + Buffer.from(content).toString('base64')
64
+ return await import(dataUrl)
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ const isWatchedTsFile = (filename: string): boolean => {
71
+ return (
72
+ filename.endsWith('.ts') &&
73
+ !filename.endsWith('.test.ts') &&
74
+ !filename.endsWith('.d.ts') &&
75
+ !filename.endsWith('.gen.ts')
76
+ )
77
+ }
78
+
79
+ export async function pikkuDevReloader(
80
+ options: PikkuDevReloaderOptions
81
+ ): Promise<{ close: () => void }> {
82
+ const { srcDirectories, logger, pikkuDir = '.pikku' } = options
83
+ const absSrcDirs = srcDirectories.map((d) => resolve(d))
84
+ const absPikkuDir = resolve(pikkuDir)
85
+ const watchers: FSWatcher[] = []
86
+
87
+ const functionsMap = pikkuState(null, 'function', 'functions')
88
+
89
+ const handleFileChange = async (changedTsFile: string) => {
90
+ const start = Date.now()
91
+ const reloadedNames: string[] = []
92
+
93
+ const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d))
94
+ if (!srcDir) return
95
+
96
+ const compiledFile = await findCompiledFile(
97
+ changedTsFile,
98
+ srcDir,
99
+ absPikkuDir
100
+ )
101
+ if (!compiledFile) {
102
+ logger.warn(
103
+ `Could not find compiled JS for: ${relative(process.cwd(), changedTsFile)}`
104
+ )
105
+ return
106
+ }
107
+
108
+ const mod = await reimportModule(compiledFile)
109
+ if (!mod) {
110
+ logger.error(
111
+ `Failed to import: ${relative(process.cwd(), compiledFile)} (keeping old code)`
112
+ )
113
+ return
114
+ }
115
+
116
+ let schemasChanged = false
117
+
118
+ for (const [exportName, exportValue] of Object.entries(mod)) {
119
+ if (isFunctionConfig(exportValue) && functionsMap.has(exportName)) {
120
+ addFunction(exportName, exportValue)
121
+ reloadedNames.push(exportName)
122
+
123
+ if (exportValue.input) {
124
+ addSchema(exportName, exportValue.input)
125
+ schemasChanged = true
126
+ }
127
+ if (exportValue.output) {
128
+ addSchema(`${exportName}Output`, exportValue.output)
129
+ schemasChanged = true
130
+ }
131
+ }
132
+ }
133
+
134
+ if (reloadedNames.length > 0) {
135
+ clearMiddlewareCache()
136
+ clearPermissionsCache()
137
+ clearChannelMiddlewareCache()
138
+ httpRouter.reset()
139
+
140
+ if (schemasChanged) {
141
+ try {
142
+ compileAllSchemas(logger)
143
+ } catch (err) {
144
+ const msg = err instanceof Error ? err.message : String(err)
145
+ if (
146
+ msg.includes('SchemaService') ||
147
+ (msg.includes('schema') && msg.includes('not'))
148
+ ) {
149
+ logger.warn('Schema recompilation skipped (no SchemaService)')
150
+ } else {
151
+ logger.error(`Schema recompilation failed: ${msg}`)
152
+ return
153
+ }
154
+ }
155
+ }
156
+
157
+ const elapsed = Date.now() - start
158
+ logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`)
159
+ }
160
+ }
161
+
162
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined
163
+ const pendingChanges = new Set<string>()
164
+
165
+ const scheduleReload = (filePath: string) => {
166
+ pendingChanges.add(filePath)
167
+ if (debounceTimer) clearTimeout(debounceTimer)
168
+ debounceTimer = setTimeout(async () => {
169
+ const files = [...pendingChanges]
170
+ pendingChanges.clear()
171
+ for (const file of files) {
172
+ try {
173
+ await handleFileChange(file)
174
+ } catch (err) {
175
+ logger.error(
176
+ `Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
177
+ )
178
+ }
179
+ }
180
+ }, 50)
181
+ }
182
+
183
+ for (const srcDir of absSrcDirs) {
184
+ try {
185
+ const watcher = watch(
186
+ srcDir,
187
+ { recursive: true },
188
+ (eventType, filename) => {
189
+ if (filename && isWatchedTsFile(filename)) {
190
+ scheduleReload(join(srcDir, filename))
191
+ }
192
+ }
193
+ )
194
+ watchers.push(watcher)
195
+ } catch (err: any) {
196
+ logger.error(
197
+ `Failed to watch directory ${srcDir}: ${err?.message || err}`
198
+ )
199
+ }
200
+ }
201
+
202
+ logger.info(`Hot-reload active for: ${srcDirectories.join(', ')}`)
203
+
204
+ return {
205
+ close: () => {
206
+ if (debounceTimer) clearTimeout(debounceTimer)
207
+ for (const watcher of watchers) {
208
+ watcher.close()
209
+ }
210
+ },
211
+ }
212
+ }