@pikku/core 0.12.56 → 0.12.57
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 +12 -0
- package/dist/dev/hot-reload.d.ts +7 -3
- package/dist/dev/hot-reload.js +90 -47
- package/dist/dev/reload-meta.d.ts +24 -0
- package/dist/dev/reload-meta.js +99 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +15 -8
- package/src/dev/hot-reload.ts +99 -54
- package/src/dev/reload-meta.test.ts +154 -0
- package/src/dev/reload-meta.ts +138 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.12.57
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 60ad8cb: fix dev-server hot reload so edited AND new functions/routes apply without a restart
|
|
6
|
+
- `@pikku/core`: the hot reloader fed raw zod `input`/`output` schemas into the JSON-schema map, so `compileAllSchemas` threw `Failed to compile schema` on every reload and the reload aborted (only the function body sometimes swapped, half-updated). It now registers function implementations only and leaves schemas to the codegen JSON output. New function exports are registered too (previously only already-registered names were replaced). Reloads write into the startup functions map directly to avoid a race with the dev watcher's codegen-scoped state swap, and re-import via a uniquely-named sibling copy since neither Bun nor tsx bust the module cache on a `?t=` query.
|
|
7
|
+
- New `reloadGeneratedMeta` (exported from `@pikku/core/dev`) re-reads the regenerated wiring meta + JSON schemas into the running process so new/changed routes, RPCs, queues and agents resolve without a restart.
|
|
8
|
+
- `@pikku/cli`: `pikku dev` now calls `reloadGeneratedMeta` after each watch-triggered codegen pass and re-imports the changed files once fresh meta is in state, so a NEW route in a changed wiring file registers (its `wireHTTP` no longer no-ops on missing meta).
|
|
9
|
+
- `@pikku/schema-cfworker`: `compileSchema` recompiles when a schema's value changes (not only on first sight), so hot-reloaded schemas take effect.
|
|
10
|
+
|
|
11
|
+
- 8f5c998: Fix dev hot-reload dropping runtime-registered function/queue meta. `reloadGeneratedMeta` replaced the whole `function`/`queue` meta maps with the generated JSON, wiping entries the framework registers at service-init (the workflow orchestrator, per-workflow queue workers, and other `addFunction`'d internals that never appear in the generated files). Workflow jobs then failed with `Function meta not found: pikkuWorkflowOrchestrator`. The reload now merges over the existing maps so those internals survive.
|
|
12
|
+
|
|
1
13
|
## 0.12.56
|
|
2
14
|
|
|
3
15
|
### Patch Changes
|
package/dist/dev/hot-reload.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { Logger } from '../services/logger.js';
|
|
2
|
+
export * from './reload-meta.js';
|
|
2
3
|
interface PikkuDevReloaderOptions {
|
|
3
4
|
srcDirectories: string[];
|
|
4
5
|
logger: Logger;
|
|
5
6
|
pikkuDir?: string;
|
|
6
7
|
}
|
|
7
|
-
export
|
|
8
|
+
export interface PikkuDevReloaderHandle {
|
|
8
9
|
close: () => void;
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/** Re-import every file changed since the last drain (post-codegen, once
|
|
11
|
+
* fresh meta is in state). */
|
|
12
|
+
reimportPending: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<PikkuDevReloaderHandle>;
|
package/dist/dev/hot-reload.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { watch } from 'node:fs';
|
|
2
|
-
import { stat, readFile } from 'node:fs/promises';
|
|
3
|
-
import { join, resolve, relative } from 'node:path';
|
|
2
|
+
import { stat, readFile, copyFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve, relative } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { register } from 'tsx/esm/api';
|
|
6
6
|
import { pikkuState } from '../pikku-state.js';
|
|
7
|
-
import { addFunction } from '../function/function-runner.js';
|
|
8
7
|
import { clearMiddlewareCache } from '../middleware-runner.js';
|
|
9
8
|
import { clearPermissionsCache } from '../permissions.js';
|
|
10
9
|
import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
|
|
11
10
|
import { httpRouter } from '../wirings/http/routers/http-router.js';
|
|
12
|
-
|
|
11
|
+
export * from './reload-meta.js';
|
|
13
12
|
const isFunctionConfig = (value) => {
|
|
14
13
|
return (typeof value === 'object' &&
|
|
15
14
|
value !== null &&
|
|
@@ -44,11 +43,31 @@ const ensureTsxRegistered = () => {
|
|
|
44
43
|
register();
|
|
45
44
|
tsxRegistered = true;
|
|
46
45
|
};
|
|
46
|
+
let tempCounter = 0;
|
|
47
47
|
const reimportModule = async (filePath, useTsx = false) => {
|
|
48
48
|
try {
|
|
49
49
|
if (useTsx) {
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Import a uniquely-named sibling copy: a `?t=` query does NOT bust
|
|
51
|
+
// the cache on either runtime (Bun keys module identity on the bare
|
|
52
|
+
// path; tsx's transform cache keys on the file path too), so a fresh
|
|
53
|
+
// path is the only reliable re-import. Same directory → identical
|
|
54
|
+
// resolution for relative and package-`imports` (#…) specifiers. The
|
|
55
|
+
// dot-prefix keeps it out of the watcher.
|
|
56
|
+
if (!process.versions.bun) {
|
|
57
|
+
// Node needs tsx's loader to import raw .ts; Bun imports it natively.
|
|
58
|
+
ensureTsxRegistered();
|
|
59
|
+
}
|
|
60
|
+
const abs = resolve(filePath);
|
|
61
|
+
const tempPath = join(dirname(abs), `.pikku-hot-${++tempCounter}-${basename(abs)}`);
|
|
62
|
+
await copyFile(abs, tempPath);
|
|
63
|
+
try {
|
|
64
|
+
return await import(pathToFileURL(tempPath).href);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
68
|
+
// Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
|
|
69
|
+
});
|
|
70
|
+
}
|
|
52
71
|
}
|
|
53
72
|
const content = await readFile(resolve(filePath), 'utf-8');
|
|
54
73
|
const dataUrl = 'data:text/javascript;base64,' + Buffer.from(content).toString('base64');
|
|
@@ -62,7 +81,10 @@ const isWatchedTsFile = (filename) => {
|
|
|
62
81
|
return (filename.endsWith('.ts') &&
|
|
63
82
|
!filename.endsWith('.test.ts') &&
|
|
64
83
|
!filename.endsWith('.d.ts') &&
|
|
65
|
-
!filename.endsWith('.gen.ts')
|
|
84
|
+
!filename.endsWith('.gen.ts') &&
|
|
85
|
+
// Hidden files: editor/sed atomic-write temps and our own hot-reload
|
|
86
|
+
// sibling copies must never trigger a reload of themselves.
|
|
87
|
+
!basename(filename).startsWith('.'));
|
|
66
88
|
};
|
|
67
89
|
export async function pikkuDevReloader(options) {
|
|
68
90
|
const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
|
|
@@ -73,6 +95,7 @@ export async function pikkuDevReloader(options) {
|
|
|
73
95
|
const handleFileChange = async (changedTsFile) => {
|
|
74
96
|
const start = Date.now();
|
|
75
97
|
const reloadedNames = [];
|
|
98
|
+
const addedNames = [];
|
|
76
99
|
const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
|
|
77
100
|
if (!srcDir)
|
|
78
101
|
return;
|
|
@@ -87,48 +110,59 @@ export async function pikkuDevReloader(options) {
|
|
|
87
110
|
logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`);
|
|
88
111
|
return;
|
|
89
112
|
}
|
|
90
|
-
|
|
113
|
+
// Register every function-config export — replacing known functions AND
|
|
114
|
+
// adding new ones (a brand-new function becomes callable as soon as its
|
|
115
|
+
// meta lands via reloadGeneratedMeta after the next codegen pass).
|
|
116
|
+
// Write into the map captured at startup, NOT pikkuState's current one:
|
|
117
|
+
// a dev-server watcher may have temporarily swapped in a codegen-scoped
|
|
118
|
+
// map for the same file event (runAllWithCommandState), and a write to
|
|
119
|
+
// that map is silently discarded when it restores the original.
|
|
120
|
+
// Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
|
|
121
|
+
// while the schema map carries codegen-generated JSON schemas — mixing the
|
|
122
|
+
// two crashed every reload. Fresh JSON schemas arrive via
|
|
123
|
+
// reloadGeneratedMeta once codegen has re-emitted them.
|
|
91
124
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
92
|
-
if (isFunctionConfig(exportValue)
|
|
93
|
-
|
|
125
|
+
if (!isFunctionConfig(exportValue))
|
|
126
|
+
continue;
|
|
127
|
+
const isNew = !functionsMap.has(exportName);
|
|
128
|
+
functionsMap.set(exportName, exportValue);
|
|
129
|
+
if (isNew)
|
|
130
|
+
addedNames.push(exportName);
|
|
131
|
+
else
|
|
94
132
|
reloadedNames.push(exportName);
|
|
95
|
-
if (exportValue.input) {
|
|
96
|
-
addSchema(exportName, exportValue.input);
|
|
97
|
-
schemasChanged = true;
|
|
98
|
-
}
|
|
99
|
-
if (exportValue.output) {
|
|
100
|
-
addSchema(`${exportName}Output`, exportValue.output);
|
|
101
|
-
schemasChanged = true;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
133
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
catch (err) {
|
|
115
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
-
if (msg.includes('SchemaService') ||
|
|
117
|
-
(msg.includes('schema') && msg.includes('not'))) {
|
|
118
|
-
logger.warn('Schema recompilation skipped (no SchemaService)');
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
logger.error(`Schema recompilation failed: ${msg}`);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
134
|
+
// Re-importing the module re-ran its wire* side effects (wireHTTP et al
|
|
135
|
+
// are keyed map-sets, so re-registration replaces/adds) — reset the
|
|
136
|
+
// router and caches even when no function export changed, so a
|
|
137
|
+
// wiring-only file edit rebuilds the route matchers too.
|
|
138
|
+
clearMiddlewareCache();
|
|
139
|
+
clearPermissionsCache();
|
|
140
|
+
clearChannelMiddlewareCache();
|
|
141
|
+
httpRouter.reset();
|
|
142
|
+
if (reloadedNames.length > 0 || addedNames.length > 0) {
|
|
126
143
|
const elapsed = Date.now() - start;
|
|
127
|
-
|
|
144
|
+
const parts = [];
|
|
145
|
+
if (reloadedNames.length > 0)
|
|
146
|
+
parts.push(reloadedNames.join(', '));
|
|
147
|
+
if (addedNames.length > 0)
|
|
148
|
+
parts.push(`new: ${addedNames.join(', ')}`);
|
|
149
|
+
logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`);
|
|
128
150
|
}
|
|
129
151
|
};
|
|
130
152
|
let debounceTimer;
|
|
131
153
|
const pendingChanges = new Set();
|
|
154
|
+
// Files re-imported since the last reimportPending() drain. A dev-server
|
|
155
|
+
// watcher drains this after its codegen pass so wire* registrations that
|
|
156
|
+
// were skipped for missing meta (a NEW route) run again with fresh meta.
|
|
157
|
+
const postCodegenQueue = new Set();
|
|
158
|
+
const safeHandleFileChange = async (file) => {
|
|
159
|
+
try {
|
|
160
|
+
await handleFileChange(file);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
132
166
|
const scheduleReload = (filePath) => {
|
|
133
167
|
pendingChanges.add(filePath);
|
|
134
168
|
if (debounceTimer)
|
|
@@ -137,12 +171,8 @@ export async function pikkuDevReloader(options) {
|
|
|
137
171
|
const files = [...pendingChanges];
|
|
138
172
|
pendingChanges.clear();
|
|
139
173
|
for (const file of files) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
145
|
-
}
|
|
174
|
+
postCodegenQueue.add(file);
|
|
175
|
+
await safeHandleFileChange(file);
|
|
146
176
|
}
|
|
147
177
|
}, 50);
|
|
148
178
|
};
|
|
@@ -168,5 +198,18 @@ export async function pikkuDevReloader(options) {
|
|
|
168
198
|
watcher.close();
|
|
169
199
|
}
|
|
170
200
|
},
|
|
201
|
+
// Drain the queue of recently re-imported files and import them again.
|
|
202
|
+
// A dev-server watcher calls this AFTER its codegen pass has refreshed
|
|
203
|
+
// the generated meta (reloadGeneratedMeta): wire* registrations skip
|
|
204
|
+
// routes whose meta doesn't exist yet, so a wiring file changed
|
|
205
|
+
// alongside a NEW function only registers its new route when
|
|
206
|
+
// re-imported after the fresh meta has landed.
|
|
207
|
+
reimportPending: async () => {
|
|
208
|
+
const files = [...postCodegenQueue];
|
|
209
|
+
postCodegenQueue.clear();
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
await safeHandleFileChange(file);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
171
214
|
};
|
|
172
215
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Logger } from '../services/logger.js';
|
|
2
|
+
import type { SchemaService } from '../services/schema-service.js';
|
|
3
|
+
export interface ReloadGeneratedMetaOptions {
|
|
4
|
+
/** The project's generated output directory (the CLI's resolved outDir). */
|
|
5
|
+
pikkuDir: string;
|
|
6
|
+
logger: Logger;
|
|
7
|
+
/** Used to recompile validators for changed schemas; falls back to the
|
|
8
|
+
* schema service on the registered singleton services. */
|
|
9
|
+
schemaService?: SchemaService;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Re-reads the codegen output (wiring meta + JSON schemas) into the running
|
|
13
|
+
* process so new and changed functions become callable without a server
|
|
14
|
+
* restart. The generated `*-meta.gen.ts` files are plain
|
|
15
|
+
* `pikkuState(area, key, <json>)` side effects, but they cannot be
|
|
16
|
+
* re-imported after a dev-time codegen — the ESM cache pins both the wrapper
|
|
17
|
+
* and its JSON import — so this reads the JSON sources directly and applies
|
|
18
|
+
* the same state.
|
|
19
|
+
*
|
|
20
|
+
* Meant to be called by a dev-server watcher after each codegen pass. Routes
|
|
21
|
+
* registered by NEW `wireHTTP` files are not picked up (their modules were
|
|
22
|
+
* never imported); those still need a restart.
|
|
23
|
+
*/
|
|
24
|
+
export declare function reloadGeneratedMeta(options: ReloadGeneratedMetaOptions): Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { pikkuState } from '../pikku-state.js';
|
|
4
|
+
import { addSchema, compileAllSchemas } from '../schema.js';
|
|
5
|
+
import { clearMiddlewareCache } from '../middleware-runner.js';
|
|
6
|
+
import { clearPermissionsCache } from '../permissions.js';
|
|
7
|
+
import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
|
|
8
|
+
import { httpRouter } from '../wirings/http/routers/http-router.js';
|
|
9
|
+
const readJson = async (logger, file) => {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = await readFile(file, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// The project doesn't use this wiring type — nothing generated.
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
logger.error(`Hot-reload could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Re-reads the codegen output (wiring meta + JSON schemas) into the running
|
|
28
|
+
* process so new and changed functions become callable without a server
|
|
29
|
+
* restart. The generated `*-meta.gen.ts` files are plain
|
|
30
|
+
* `pikkuState(area, key, <json>)` side effects, but they cannot be
|
|
31
|
+
* re-imported after a dev-time codegen — the ESM cache pins both the wrapper
|
|
32
|
+
* and its JSON import — so this reads the JSON sources directly and applies
|
|
33
|
+
* the same state.
|
|
34
|
+
*
|
|
35
|
+
* Meant to be called by a dev-server watcher after each codegen pass. Routes
|
|
36
|
+
* registered by NEW `wireHTTP` files are not picked up (their modules were
|
|
37
|
+
* never imported); those still need a restart.
|
|
38
|
+
*/
|
|
39
|
+
export async function reloadGeneratedMeta(options) {
|
|
40
|
+
const { pikkuDir, logger, schemaService } = options;
|
|
41
|
+
const dir = resolve(pikkuDir);
|
|
42
|
+
const functionsMeta = await readJson(logger, join(dir, 'function/pikku-functions-meta.gen.json'));
|
|
43
|
+
// Merge over the existing map, don't replace it: framework internals like
|
|
44
|
+
// pikkuWorkflowOrchestrator / the per-workflow queue workers are registered
|
|
45
|
+
// at service-init (pikku-workflow-service.ts), never in the generated JSON —
|
|
46
|
+
// a wholesale replace drops them and workflow jobs then fail with
|
|
47
|
+
// "Function meta not found: pikkuWorkflowOrchestrator".
|
|
48
|
+
if (functionsMeta) {
|
|
49
|
+
const existing = pikkuState(null, 'function', 'meta') ?? {};
|
|
50
|
+
pikkuState(null, 'function', 'meta', { ...existing, ...functionsMeta });
|
|
51
|
+
}
|
|
52
|
+
const httpMeta = await readJson(logger, join(dir, 'http/pikku-http-wirings-meta.gen.json'));
|
|
53
|
+
if (httpMeta)
|
|
54
|
+
pikkuState(null, 'http', 'meta', httpMeta);
|
|
55
|
+
const rpcMeta = await readJson(logger, join(dir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json'));
|
|
56
|
+
if (rpcMeta)
|
|
57
|
+
pikkuState(null, 'rpc', 'meta', rpcMeta);
|
|
58
|
+
const queueMeta = await readJson(logger, join(dir, 'queue/pikku-queue-workers-wirings-meta.gen.json'));
|
|
59
|
+
// Same reason as function meta: the workflow service adds its orchestrator /
|
|
60
|
+
// step queues (and wf-orchestrator-* / wf-step-* per-workflow queues) here at
|
|
61
|
+
// init, absent from the generated JSON — merge so they survive the reload.
|
|
62
|
+
if (queueMeta) {
|
|
63
|
+
const existing = pikkuState(null, 'queue', 'meta') ?? {};
|
|
64
|
+
pikkuState(null, 'queue', 'meta', { ...existing, ...queueMeta });
|
|
65
|
+
}
|
|
66
|
+
const agentMeta = await readJson(logger, join(dir, 'agent/pikku-agent-wirings-meta.gen.json'));
|
|
67
|
+
if (agentMeta?.agentsMeta) {
|
|
68
|
+
pikkuState(null, 'agent', 'agentsMeta', agentMeta.agentsMeta);
|
|
69
|
+
}
|
|
70
|
+
// Generated JSON schemas: <outDir>/schemas/schemas/<Name>.schema.json.
|
|
71
|
+
// Re-adding replaces the map entry; the schema service recompiles any
|
|
72
|
+
// validator whose stored schema value no longer matches.
|
|
73
|
+
const schemasDir = join(dir, 'schemas', 'schemas');
|
|
74
|
+
let schemaFiles = [];
|
|
75
|
+
try {
|
|
76
|
+
schemaFiles = await readdir(schemasDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// No generated schemas — a schema-less project.
|
|
80
|
+
}
|
|
81
|
+
for (const file of schemaFiles) {
|
|
82
|
+
if (!file.endsWith('.schema.json'))
|
|
83
|
+
continue;
|
|
84
|
+
const schema = await readJson(logger, join(schemasDir, file));
|
|
85
|
+
if (schema) {
|
|
86
|
+
addSchema(file.slice(0, -'.schema.json'.length), schema);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
clearMiddlewareCache();
|
|
90
|
+
clearPermissionsCache();
|
|
91
|
+
clearChannelMiddlewareCache();
|
|
92
|
+
httpRouter.reset();
|
|
93
|
+
try {
|
|
94
|
+
compileAllSchemas(logger, schemaService);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.error(`Hot-reload schema recompilation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -152,29 +152,36 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
|
|
|
152
152
|
assert.ok(reloadLog, 'Should log hot-reload message')
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
-
test('should
|
|
155
|
+
test('should register a brand-new function export', async (t) => {
|
|
156
156
|
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
157
157
|
|
|
158
158
|
addFunction('registeredFunc', {
|
|
159
159
|
func: async () => ({ name: 'registered' }),
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
-
await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
|
|
163
|
-
|
|
164
162
|
reloader = await pikkuDevReloader({
|
|
165
163
|
srcDirectories: [tmpDir],
|
|
166
164
|
logger: mockLogger,
|
|
167
165
|
pikkuDir: tmpDir,
|
|
168
166
|
})
|
|
169
167
|
|
|
170
|
-
await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "
|
|
168
|
+
await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
|
|
171
169
|
|
|
172
170
|
await wait(300)
|
|
173
171
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
172
|
+
const func = pikkuState(null, 'function', 'functions').get('unknownFunc')!
|
|
173
|
+
assert.ok(func, 'New function export should be registered')
|
|
174
|
+
assert.deepEqual(await func.func({} as any, {}, {} as any), {
|
|
175
|
+
name: 'unknown',
|
|
176
|
+
})
|
|
177
|
+
const newLog = mockLogger
|
|
178
|
+
.getLogs()
|
|
179
|
+
.find(
|
|
180
|
+
(l) =>
|
|
181
|
+
l.message.includes('Hot-reloaded') &&
|
|
182
|
+
l.message.includes('new: unknownFunc')
|
|
183
|
+
)
|
|
184
|
+
assert.ok(newLog, 'Should log the newly registered function')
|
|
178
185
|
})
|
|
179
186
|
|
|
180
187
|
test('should keep old code when JS import fails', async (t) => {
|
package/src/dev/hot-reload.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { watch, type FSWatcher } from 'node:fs'
|
|
2
|
-
import { stat, readFile } from 'node:fs/promises'
|
|
3
|
-
import { join, resolve, relative } from 'node:path'
|
|
2
|
+
import { stat, readFile, copyFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, resolve, relative } from 'node:path'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
|
|
6
6
|
import { register } from 'tsx/esm/api'
|
|
7
7
|
|
|
8
8
|
import { pikkuState } from '../pikku-state.js'
|
|
9
|
-
import { addFunction } from '../function/function-runner.js'
|
|
10
9
|
import { clearMiddlewareCache } from '../middleware-runner.js'
|
|
11
10
|
import { clearPermissionsCache } from '../permissions.js'
|
|
12
11
|
import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
|
|
13
12
|
import { httpRouter } from '../wirings/http/routers/http-router.js'
|
|
14
|
-
import { addSchema, compileAllSchemas } from '../schema.js'
|
|
15
13
|
import type { Logger } from '../services/logger.js'
|
|
16
14
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
17
15
|
|
|
16
|
+
export * from './reload-meta.js'
|
|
17
|
+
|
|
18
18
|
interface PikkuDevReloaderOptions {
|
|
19
19
|
srcDirectories: string[]
|
|
20
20
|
logger: Logger
|
|
@@ -65,16 +65,37 @@ const ensureTsxRegistered = () => {
|
|
|
65
65
|
tsxRegistered = true
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
let tempCounter = 0
|
|
69
|
+
|
|
68
70
|
const reimportModule = async (
|
|
69
71
|
filePath: string,
|
|
70
72
|
useTsx = false
|
|
71
73
|
): Promise<Record<string, unknown> | null> => {
|
|
72
74
|
try {
|
|
73
75
|
if (useTsx) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// Import a uniquely-named sibling copy: a `?t=` query does NOT bust
|
|
77
|
+
// the cache on either runtime (Bun keys module identity on the bare
|
|
78
|
+
// path; tsx's transform cache keys on the file path too), so a fresh
|
|
79
|
+
// path is the only reliable re-import. Same directory → identical
|
|
80
|
+
// resolution for relative and package-`imports` (#…) specifiers. The
|
|
81
|
+
// dot-prefix keeps it out of the watcher.
|
|
82
|
+
if (!process.versions.bun) {
|
|
83
|
+
// Node needs tsx's loader to import raw .ts; Bun imports it natively.
|
|
84
|
+
ensureTsxRegistered()
|
|
85
|
+
}
|
|
86
|
+
const abs = resolve(filePath)
|
|
87
|
+
const tempPath = join(
|
|
88
|
+
dirname(abs),
|
|
89
|
+
`.pikku-hot-${++tempCounter}-${basename(abs)}`
|
|
77
90
|
)
|
|
91
|
+
await copyFile(abs, tempPath)
|
|
92
|
+
try {
|
|
93
|
+
return await import(pathToFileURL(tempPath).href)
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
96
|
+
// Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
|
|
97
|
+
})
|
|
98
|
+
}
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
const content = await readFile(resolve(filePath), 'utf-8')
|
|
@@ -91,13 +112,23 @@ const isWatchedTsFile = (filename: string): boolean => {
|
|
|
91
112
|
filename.endsWith('.ts') &&
|
|
92
113
|
!filename.endsWith('.test.ts') &&
|
|
93
114
|
!filename.endsWith('.d.ts') &&
|
|
94
|
-
!filename.endsWith('.gen.ts')
|
|
115
|
+
!filename.endsWith('.gen.ts') &&
|
|
116
|
+
// Hidden files: editor/sed atomic-write temps and our own hot-reload
|
|
117
|
+
// sibling copies must never trigger a reload of themselves.
|
|
118
|
+
!basename(filename).startsWith('.')
|
|
95
119
|
)
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export interface PikkuDevReloaderHandle {
|
|
123
|
+
close: () => void
|
|
124
|
+
/** Re-import every file changed since the last drain (post-codegen, once
|
|
125
|
+
* fresh meta is in state). */
|
|
126
|
+
reimportPending: () => Promise<void>
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
export async function pikkuDevReloader(
|
|
99
130
|
options: PikkuDevReloaderOptions
|
|
100
|
-
): Promise<
|
|
131
|
+
): Promise<PikkuDevReloaderHandle> {
|
|
101
132
|
const { srcDirectories, logger, pikkuDir = '.pikku' } = options
|
|
102
133
|
const absSrcDirs = srcDirectories.map((d) => resolve(d))
|
|
103
134
|
const absPikkuDir = resolve(pikkuDir)
|
|
@@ -108,6 +139,7 @@ export async function pikkuDevReloader(
|
|
|
108
139
|
const handleFileChange = async (changedTsFile: string) => {
|
|
109
140
|
const start = Date.now()
|
|
110
141
|
const reloadedNames: string[] = []
|
|
142
|
+
const addedNames: string[] = []
|
|
111
143
|
|
|
112
144
|
const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d))
|
|
113
145
|
if (!srcDir) return
|
|
@@ -134,54 +166,59 @@ export async function pikkuDevReloader(
|
|
|
134
166
|
return
|
|
135
167
|
}
|
|
136
168
|
|
|
137
|
-
|
|
138
|
-
|
|
169
|
+
// Register every function-config export — replacing known functions AND
|
|
170
|
+
// adding new ones (a brand-new function becomes callable as soon as its
|
|
171
|
+
// meta lands via reloadGeneratedMeta after the next codegen pass).
|
|
172
|
+
// Write into the map captured at startup, NOT pikkuState's current one:
|
|
173
|
+
// a dev-server watcher may have temporarily swapped in a codegen-scoped
|
|
174
|
+
// map for the same file event (runAllWithCommandState), and a write to
|
|
175
|
+
// that map is silently discarded when it restores the original.
|
|
176
|
+
// Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
|
|
177
|
+
// while the schema map carries codegen-generated JSON schemas — mixing the
|
|
178
|
+
// two crashed every reload. Fresh JSON schemas arrive via
|
|
179
|
+
// reloadGeneratedMeta once codegen has re-emitted them.
|
|
139
180
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
140
|
-
if (isFunctionConfig(exportValue)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
addSchema(exportName, exportValue.input)
|
|
146
|
-
schemasChanged = true
|
|
147
|
-
}
|
|
148
|
-
if (exportValue.output) {
|
|
149
|
-
addSchema(`${exportName}Output`, exportValue.output)
|
|
150
|
-
schemasChanged = true
|
|
151
|
-
}
|
|
152
|
-
}
|
|
181
|
+
if (!isFunctionConfig(exportValue)) continue
|
|
182
|
+
const isNew = !functionsMap.has(exportName)
|
|
183
|
+
functionsMap.set(exportName, exportValue)
|
|
184
|
+
if (isNew) addedNames.push(exportName)
|
|
185
|
+
else reloadedNames.push(exportName)
|
|
153
186
|
}
|
|
154
187
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
compileAllSchemas(logger)
|
|
164
|
-
} catch (err) {
|
|
165
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
166
|
-
if (
|
|
167
|
-
msg.includes('SchemaService') ||
|
|
168
|
-
(msg.includes('schema') && msg.includes('not'))
|
|
169
|
-
) {
|
|
170
|
-
logger.warn('Schema recompilation skipped (no SchemaService)')
|
|
171
|
-
} else {
|
|
172
|
-
logger.error(`Schema recompilation failed: ${msg}`)
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
188
|
+
// Re-importing the module re-ran its wire* side effects (wireHTTP et al
|
|
189
|
+
// are keyed map-sets, so re-registration replaces/adds) — reset the
|
|
190
|
+
// router and caches even when no function export changed, so a
|
|
191
|
+
// wiring-only file edit rebuilds the route matchers too.
|
|
192
|
+
clearMiddlewareCache()
|
|
193
|
+
clearPermissionsCache()
|
|
194
|
+
clearChannelMiddlewareCache()
|
|
195
|
+
httpRouter.reset()
|
|
177
196
|
|
|
197
|
+
if (reloadedNames.length > 0 || addedNames.length > 0) {
|
|
178
198
|
const elapsed = Date.now() - start
|
|
179
|
-
|
|
199
|
+
const parts: string[] = []
|
|
200
|
+
if (reloadedNames.length > 0) parts.push(reloadedNames.join(', '))
|
|
201
|
+
if (addedNames.length > 0) parts.push(`new: ${addedNames.join(', ')}`)
|
|
202
|
+
logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`)
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
|
|
183
206
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
184
207
|
const pendingChanges = new Set<string>()
|
|
208
|
+
// Files re-imported since the last reimportPending() drain. A dev-server
|
|
209
|
+
// watcher drains this after its codegen pass so wire* registrations that
|
|
210
|
+
// were skipped for missing meta (a NEW route) run again with fresh meta.
|
|
211
|
+
const postCodegenQueue = new Set<string>()
|
|
212
|
+
|
|
213
|
+
const safeHandleFileChange = async (file: string) => {
|
|
214
|
+
try {
|
|
215
|
+
await handleFileChange(file)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.error(
|
|
218
|
+
`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
185
222
|
|
|
186
223
|
const scheduleReload = (filePath: string) => {
|
|
187
224
|
pendingChanges.add(filePath)
|
|
@@ -190,13 +227,8 @@ export async function pikkuDevReloader(
|
|
|
190
227
|
const files = [...pendingChanges]
|
|
191
228
|
pendingChanges.clear()
|
|
192
229
|
for (const file of files) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
} catch (err) {
|
|
196
|
-
logger.error(
|
|
197
|
-
`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
|
|
198
|
-
)
|
|
199
|
-
}
|
|
230
|
+
postCodegenQueue.add(file)
|
|
231
|
+
await safeHandleFileChange(file)
|
|
200
232
|
}
|
|
201
233
|
}, 50)
|
|
202
234
|
}
|
|
@@ -229,5 +261,18 @@ export async function pikkuDevReloader(
|
|
|
229
261
|
watcher.close()
|
|
230
262
|
}
|
|
231
263
|
},
|
|
264
|
+
// Drain the queue of recently re-imported files and import them again.
|
|
265
|
+
// A dev-server watcher calls this AFTER its codegen pass has refreshed
|
|
266
|
+
// the generated meta (reloadGeneratedMeta): wire* registrations skip
|
|
267
|
+
// routes whose meta doesn't exist yet, so a wiring file changed
|
|
268
|
+
// alongside a NEW function only registers its new route when
|
|
269
|
+
// re-imported after the fresh meta has landed.
|
|
270
|
+
reimportPending: async () => {
|
|
271
|
+
const files = [...postCodegenQueue]
|
|
272
|
+
postCodegenQueue.clear()
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
await safeHandleFileChange(file)
|
|
275
|
+
}
|
|
276
|
+
},
|
|
232
277
|
}
|
|
233
278
|
}
|