@pikku/core 0.12.3 → 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.
- package/CHANGELOG.md +17 -1
- package/dist/dev/hot-reload.d.ts +10 -0
- package/dist/dev/hot-reload.js +158 -0
- package/dist/middleware-runner.d.ts +1 -0
- package/dist/middleware-runner.js +5 -0
- package/dist/permissions.d.ts +1 -0
- package/dist/permissions.js +5 -0
- package/dist/services/in-memory-workflow-service.d.ts +15 -2
- package/dist/services/in-memory-workflow-service.js +56 -11
- package/dist/wirings/ai-agent/ai-agent-prepare.js +16 -1
- package/dist/wirings/channel/channel-middleware-runner.d.ts +1 -0
- package/dist/wirings/channel/channel-middleware-runner.js +5 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +7 -8
- package/package.json +3 -2
- package/src/dev/hot-reload.test.ts +484 -0
- package/src/dev/hot-reload.ts +212 -0
- package/src/middleware-runner.ts +6 -0
- package/src/permissions.ts +8 -0
- package/src/services/in-memory-workflow-service.test.ts +5 -5
- package/src/services/in-memory-workflow-service.ts +78 -14
- package/src/wirings/ai-agent/ai-agent-prepare.ts +25 -2
- package/src/wirings/channel/channel-middleware-runner.ts +6 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +12 -9
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
package/src/middleware-runner.ts
CHANGED
|
@@ -124,6 +124,12 @@ const middlewareCache: Record<
|
|
|
124
124
|
gateway: {},
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export const clearMiddlewareCache = () => {
|
|
128
|
+
for (const key of Object.keys(middlewareCache) as PikkuWiringTypes[]) {
|
|
129
|
+
middlewareCache[key] = {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
/**
|
|
128
134
|
* Combines wiring-specific middleware with function-level middleware.
|
|
129
135
|
*
|