@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 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
@@ -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 declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<{
8
+ export interface PikkuDevReloaderHandle {
8
9
  close: () => void;
9
- }>;
10
- export {};
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>;
@@ -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
- import { addSchema, compileAllSchemas } from '../schema.js';
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
- ensureTsxRegistered();
51
- return await import(`${pathToFileURL(resolve(filePath)).href}?t=${Date.now()}`);
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
- let schemasChanged = false;
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) && functionsMap.has(exportName)) {
93
- addFunction(exportName, exportValue);
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
- if (reloadedNames.length > 0) {
106
- clearMiddlewareCache();
107
- clearPermissionsCache();
108
- clearChannelMiddlewareCache();
109
- httpRouter.reset();
110
- if (schemasChanged) {
111
- try {
112
- compileAllSchemas(logger);
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
- logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`);
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
- try {
141
- await handleFileChange(file);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.56",
3
+ "version": "0.12.57",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -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 not replace a function that is not registered', async (t) => {
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: "updated" }')
168
+ await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
171
169
 
172
170
  await wait(300)
173
171
 
174
- assert.equal(
175
- pikkuState(null, 'function', 'functions').has('unknownFunc'),
176
- false
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) => {
@@ -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
- ensureTsxRegistered()
75
- return await import(
76
- `${pathToFileURL(resolve(filePath)).href}?t=${Date.now()}`
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<{ close: () => void }> {
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
- let schemasChanged = false
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) && functionsMap.has(exportName)) {
141
- addFunction(exportName, exportValue)
142
- reloadedNames.push(exportName)
143
-
144
- if (exportValue.input) {
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
- if (reloadedNames.length > 0) {
156
- clearMiddlewareCache()
157
- clearPermissionsCache()
158
- clearChannelMiddlewareCache()
159
- httpRouter.reset()
160
-
161
- if (schemasChanged) {
162
- try {
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
- logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`)
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
- try {
194
- await handleFileChange(file)
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
  }