@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,213 @@
1
+ const encoder = new TextEncoder()
2
+ const decoder = new TextDecoder()
3
+
4
+ const getSubtle = () => {
5
+ const crypto = globalThis.crypto
6
+ if (!crypto?.subtle) {
7
+ throw new Error('WebCrypto not available')
8
+ }
9
+ return crypto.subtle
10
+ }
11
+
12
+ const base64Encode = (bytes: Uint8Array): string => {
13
+ if (typeof Buffer !== 'undefined') {
14
+ return Buffer.from(bytes).toString('base64')
15
+ }
16
+ let binary = ''
17
+ for (let i = 0; i < bytes.length; i++) {
18
+ binary += String.fromCharCode(bytes[i]!)
19
+ }
20
+ return btoa(binary)
21
+ }
22
+
23
+ const base64Decode = (input: string): Uint8Array => {
24
+ if (typeof Buffer !== 'undefined') {
25
+ return new Uint8Array(Buffer.from(input, 'base64'))
26
+ }
27
+ const binary = atob(input)
28
+ const bytes = new Uint8Array(binary.length)
29
+ for (let i = 0; i < binary.length; i++) {
30
+ bytes[i] = binary.charCodeAt(i)
31
+ }
32
+ return bytes
33
+ }
34
+
35
+ const toBase64Url = (bytes: Uint8Array): string => {
36
+ return base64Encode(bytes)
37
+ .replace(/\+/g, '-')
38
+ .replace(/\//g, '_')
39
+ .replace(/=+$/g, '')
40
+ }
41
+
42
+ const fromBase64Url = (input: string): Uint8Array => {
43
+ let base64 = input.replace(/-/g, '+').replace(/_/g, '/')
44
+ while (base64.length % 4 !== 0) base64 += '='
45
+ return base64Decode(base64)
46
+ }
47
+
48
+ const deriveKey = async (secret: string): Promise<CryptoKey> => {
49
+ const subtle = getSubtle()
50
+ const hash = await subtle.digest('SHA-256', encoder.encode(secret))
51
+ return subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, [
52
+ 'encrypt',
53
+ 'decrypt',
54
+ ])
55
+ }
56
+
57
+ export const encryptJSON = async (
58
+ secret: string,
59
+ value: unknown
60
+ ): Promise<string> => {
61
+ const crypto = globalThis.crypto
62
+ if (!crypto?.getRandomValues) {
63
+ throw new Error('WebCrypto not available')
64
+ }
65
+ const subtle = getSubtle()
66
+ const iv = crypto.getRandomValues(new Uint8Array(12))
67
+ const key = await deriveKey(secret)
68
+ const plaintext = encoder.encode(JSON.stringify(value))
69
+ const encrypted = await subtle.encrypt(
70
+ { name: 'AES-GCM', iv },
71
+ key,
72
+ plaintext
73
+ )
74
+ const cipherBytes = new Uint8Array(encrypted)
75
+ const out = new Uint8Array(iv.length + cipherBytes.length)
76
+ out.set(iv, 0)
77
+ out.set(cipherBytes, iv.length)
78
+ return toBase64Url(out)
79
+ }
80
+
81
+ export const decryptJSON = async <T>(
82
+ secret: string,
83
+ token: string
84
+ ): Promise<T> => {
85
+ const subtle = getSubtle()
86
+ const data = fromBase64Url(token)
87
+ if (data.length < 13) {
88
+ throw new Error('Invalid encrypted payload')
89
+ }
90
+ const iv = data.slice(0, 12)
91
+ const ciphertext = data.slice(12)
92
+ const key = await deriveKey(secret)
93
+ const decrypted = await subtle.decrypt(
94
+ { name: 'AES-GCM', iv },
95
+ key,
96
+ ciphertext
97
+ )
98
+ return JSON.parse(decoder.decode(new Uint8Array(decrypted))) as T
99
+ }
100
+
101
+ /**
102
+ * Envelope encryption utilities.
103
+ *
104
+ * Each secret gets its own random DEK (data encryption key).
105
+ * The DEK is wrapped (encrypted) by a KEK (key encryption key) — typically an env var.
106
+ * The actual secret is encrypted with the DEK.
107
+ *
108
+ * KEK rotation only re-wraps the DEK, never touches the ciphertext.
109
+ */
110
+
111
+ const importRawKey = async (rawBytes: Uint8Array): Promise<CryptoKey> => {
112
+ const subtle = getSubtle()
113
+ return subtle.importKey(
114
+ 'raw',
115
+ rawBytes.buffer as ArrayBuffer,
116
+ { name: 'AES-GCM' },
117
+ true,
118
+ ['encrypt', 'decrypt']
119
+ )
120
+ }
121
+
122
+ export const generateDEK = async (): Promise<string> => {
123
+ const raw = globalThis.crypto.getRandomValues(new Uint8Array(32))
124
+ return toBase64Url(raw)
125
+ }
126
+
127
+ export const wrapDEK = async (
128
+ kek: string,
129
+ plaintextDEK: string
130
+ ): Promise<string> => {
131
+ return encryptJSON(kek, plaintextDEK)
132
+ }
133
+
134
+ export const unwrapDEK = async (
135
+ kek: string,
136
+ wrappedDEK: string
137
+ ): Promise<string> => {
138
+ return decryptJSON<string>(kek, wrappedDEK)
139
+ }
140
+
141
+ const encryptWithDEK = async (
142
+ dekBase64: string,
143
+ value: unknown
144
+ ): Promise<string> => {
145
+ const crypto = globalThis.crypto
146
+ const subtle = getSubtle()
147
+ const iv = crypto.getRandomValues(new Uint8Array(12))
148
+ const key = await importRawKey(fromBase64Url(dekBase64))
149
+ const plaintext = encoder.encode(JSON.stringify(value))
150
+ const encrypted = await subtle.encrypt(
151
+ { name: 'AES-GCM', iv },
152
+ key,
153
+ plaintext
154
+ )
155
+ const cipherBytes = new Uint8Array(encrypted)
156
+ const out = new Uint8Array(iv.length + cipherBytes.length)
157
+ out.set(iv, 0)
158
+ out.set(cipherBytes, iv.length)
159
+ return toBase64Url(out)
160
+ }
161
+
162
+ const decryptWithDEK = async <T>(
163
+ dekBase64: string,
164
+ token: string
165
+ ): Promise<T> => {
166
+ const subtle = getSubtle()
167
+ const data = fromBase64Url(token)
168
+ if (data.length < 13) {
169
+ throw new Error('Invalid encrypted payload')
170
+ }
171
+ const iv = data.slice(0, 12)
172
+ const ciphertext = data.slice(12)
173
+ const key = await importRawKey(fromBase64Url(dekBase64))
174
+ const decrypted = await subtle.decrypt(
175
+ { name: 'AES-GCM', iv },
176
+ key,
177
+ ciphertext
178
+ )
179
+ return JSON.parse(decoder.decode(new Uint8Array(decrypted))) as T
180
+ }
181
+
182
+ export interface EnvelopeEncryptResult {
183
+ ciphertext: string
184
+ wrappedDEK: string
185
+ }
186
+
187
+ export const envelopeEncrypt = async (
188
+ kek: string,
189
+ value: unknown
190
+ ): Promise<EnvelopeEncryptResult> => {
191
+ const dek = await generateDEK()
192
+ const ciphertext = await encryptWithDEK(dek, value)
193
+ const wrappedDEK = await wrapDEK(kek, dek)
194
+ return { ciphertext, wrappedDEK }
195
+ }
196
+
197
+ export const envelopeDecrypt = async <T>(
198
+ kek: string,
199
+ ciphertext: string,
200
+ wrappedDEK: string
201
+ ): Promise<T> => {
202
+ const dek = await unwrapDEK(kek, wrappedDEK)
203
+ return decryptWithDEK<T>(dek, ciphertext)
204
+ }
205
+
206
+ export const envelopeRewrap = async (
207
+ oldKEK: string,
208
+ newKEK: string,
209
+ wrappedDEK: string
210
+ ): Promise<string> => {
211
+ const dek = await unwrapDEK(oldKEK, wrappedDEK)
212
+ return wrapDEK(newKEK, dek)
213
+ }
@@ -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
+ })