@luckystack/devkit 0.10.2 → 0.11.0

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 (43) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -37
  4. package/dist/cli/validateDeploy.d.ts +2 -0
  5. package/dist/functionRegistry.d.ts +72 -0
  6. package/dist/hotReload.d.ts +1 -0
  7. package/dist/importDependencyGraph.d.ts +2 -0
  8. package/dist/index.d.ts +15 -280
  9. package/dist/index.js +551 -424
  10. package/dist/index.js.map +1 -1
  11. package/dist/internal/mapUtils.d.ts +2 -0
  12. package/dist/loader.d.ts +11 -0
  13. package/dist/prismaClientCheck.d.ts +19 -0
  14. package/dist/routeConventions.d.ts +20 -0
  15. package/dist/routeNamingValidation.d.ts +46 -0
  16. package/dist/routingRules.d.ts +62 -0
  17. package/dist/runtimeTypeResolver.d.ts +15 -0
  18. package/dist/supervisor.d.ts +25 -0
  19. package/dist/templateInjector.d.ts +60 -0
  20. package/dist/templateRegistry.d.ts +78 -0
  21. package/dist/typeMap/apiMeta.d.ts +42 -0
  22. package/dist/typeMap/discovery.d.ts +3 -0
  23. package/dist/typeMap/emitterArtifacts.d.ts +94 -0
  24. package/dist/typeMap/extractionDiagnostics.d.ts +16 -0
  25. package/dist/typeMap/extractors.d.ts +18 -0
  26. package/dist/typeMap/functionsMeta.d.ts +5 -0
  27. package/dist/typeMap/routeMeta.d.ts +6 -0
  28. package/dist/typeMap/tsApi.d.ts +12 -0
  29. package/dist/typeMap/tsProgram.d.ts +41 -0
  30. package/dist/typeMap/typeContext.d.ts +21 -0
  31. package/dist/typeMap/zodEmitter.d.ts +8 -0
  32. package/dist/typeMapGenerator.d.ts +5 -0
  33. package/dist/validateDeploy.d.ts +25 -0
  34. package/docs/cli.md +3 -243
  35. package/docs/hot-reload.md +4 -362
  36. package/docs/loader-pipeline.md +3 -322
  37. package/docs/runtime-type-resolver.md +3 -256
  38. package/docs/supervisor.md +3 -290
  39. package/docs/template-customization.md +2 -133
  40. package/docs/ts-program-cache.md +4 -197
  41. package/docs/type-map-generation.md +5 -418
  42. package/package.json +5 -5
  43. package/CLAUDE.md +0 -135
@@ -0,0 +1,25 @@
1
+ import type { DeployConfigShape, ServicesConfigShape } from '@luckystack/core';
2
+ export type ValidationSeverity = 'error' | 'warning';
3
+ export interface ValidationFinding {
4
+ severity: ValidationSeverity;
5
+ code: string;
6
+ message: string;
7
+ /** Human-readable location pointer (e.g. `services.config.ts > presets.api`). */
8
+ location?: string;
9
+ }
10
+ export interface ValidateDeployInput {
11
+ services: ServicesConfigShape;
12
+ deploy: DeployConfigShape;
13
+ /**
14
+ * Environment values to consult for `synchronizedEnvKeys` / `urlEnvKey`
15
+ * presence checks. Defaults to `process.env`. Pass a fixture for tests.
16
+ */
17
+ env?: Record<string, string | undefined>;
18
+ }
19
+ export interface ValidateDeployResult {
20
+ ok: boolean;
21
+ findings: ValidationFinding[];
22
+ errorCount: number;
23
+ warningCount: number;
24
+ }
25
+ export declare const validateDeploy: ({ services, deploy, env, }: ValidateDeployInput) => ValidateDeployResult;
package/docs/cli.md CHANGED
@@ -1,245 +1,5 @@
1
- # CLI (`luckystack-validate-deploy` + `validateDeploy`)
1
+ # Deployment validation CLI
2
2
 
3
- > Dev / build-time only. The CLI is the pre-deploy gate that runs against compiled `services.config.js` + `deploy.config.js` artifacts. The validator itself (`validateDeploy`) is a pure function — safe to call directly from a build script without going through the CLI.
3
+ Validate compiled deployment and service registrations before shipping a multi-service bundle. The validator catches unbound services, broken preset references and unresolved environment references earlier than a live router would. Run `luckystack-validate-deploy --help` for current arguments and exit behavior; the validator source owns its checks and finding shapes.
4
4
 
5
- `@luckystack/devkit` ships a single bin entry:
6
-
7
- ```json
8
- {
9
- "bin": {
10
- "luckystack-validate-deploy": "./dist/cli/validateDeploy.js"
11
- }
12
- }
13
- ```
14
-
15
- The CLI is a thin wrapper around `validateDeploy()` from `src/validateDeploy.ts`. The validator runs nine rules against the project's `services.config` + `deploy.config` snapshots and returns a structured `ValidateDeployResult` with one finding per detected problem.
16
-
17
- ---
18
-
19
- ## CLI surface
20
-
21
- ```
22
- USAGE
23
- luckystack-validate-deploy --deploy <file> --services <file> [options]
24
-
25
- REQUIRED
26
- --deploy, -d <file> Path to compiled deploy.config.js (registers DeployConfig).
27
- --services, -s <file> Path to compiled services.config.js (registers ServicesConfig).
28
-
29
- OPTIONS
30
- --strict Exit 1 on warnings as well as errors.
31
- --help, -h Show this help.
32
- ```
33
-
34
- Argument parsing (`parseArgs(argv)`):
35
-
36
- ```typescript
37
- interface CliArgs {
38
- deploy: string | null;
39
- services: string | null;
40
- failOnWarning: boolean;
41
- }
42
- ```
43
-
44
- Both config paths are required. There is no implicit search of the working directory — the CLI demands an explicit path so build pipelines can't accidentally pick up a stale untracked file.
45
-
46
- ---
47
-
48
- ## Side-effect import order
49
-
50
- ```typescript
51
- await importConfig(args.deploy, 'deploy config');
52
- await importConfig(args.services, 'services config');
53
-
54
- if (!isDeployConfigRegistered()) {
55
- // exit 2 with hint
56
- }
57
- if (!isServicesConfigRegistered()) {
58
- // exit 2 with hint
59
- }
60
- ```
61
-
62
- The compiled config files are expected to call `registerDeployConfig(...)` / `registerServicesConfig(...)` (re-exported from `@luckystack/core`) as a side effect of import. The CLI imports each file via `pathToFileURL(absolute).href` (Windows-safe), then probes the registry with `isDeployConfigRegistered()` / `isServicesConfigRegistered()`.
63
-
64
- If a file imports cleanly but doesn't register, the CLI exits with code 2 and a message:
65
-
66
- ```
67
- [luckystack-validate-deploy] deploy config file did not call registerDeployConfig — nothing to validate.
68
- ```
69
-
70
- This is the most common authoring mistake: forgetting the registration call leaves a silent empty registry, which would otherwise pass validation with zero findings.
71
-
72
- `importConfig` wraps the dynamic import in a try/catch and re-throws with a clearer message including the absolute path:
73
-
74
- ```typescript
75
- const importConfig = async (file: string, label: string): Promise<void> => {
76
- const abs = path.isAbsolute(file) ? file : path.join(process.cwd(), file);
77
- try {
78
- await import(pathToFileURL(abs).href);
79
- } catch (error) {
80
- throw new Error(`[luckystack-validate-deploy] failed to import ${label} at ${abs}: ${message}`);
81
- }
82
- };
83
- ```
84
-
85
- ---
86
-
87
- ## `validateDeploy(input)` — pure validator
88
-
89
- Defined in `packages/devkit/src/validateDeploy.ts`. Public types:
90
-
91
- ```typescript
92
- export type ValidationSeverity = 'error' | 'warning';
93
-
94
- export interface ValidationFinding {
95
- severity: ValidationSeverity;
96
- code: string;
97
- message: string;
98
- /** Human-readable location pointer (e.g. `services.config.ts > presets.api`). */
99
- location?: string;
100
- }
101
-
102
- export interface ValidateDeployInput {
103
- services: ServicesConfigShape;
104
- deploy: DeployConfigShape;
105
- env?: Record<string, string | undefined>; // defaults to process.env
106
- }
107
-
108
- export interface ValidateDeployResult {
109
- ok: boolean;
110
- findings: ValidationFinding[];
111
- errorCount: number;
112
- warningCount: number;
113
- }
114
-
115
- export const validateDeploy = ({ services, deploy, env = process.env }: ValidateDeployInput): ValidateDeployResult;
116
- ```
117
-
118
- `ServicesConfigShape` / `DeployConfigShape` are re-exported from `@luckystack/core` and define the canonical shape of the two config files. The validator never reads from disk — it works exclusively from the in-memory snapshots passed in.
119
-
120
- ### Rules
121
-
122
- | # | Rule | Code | Severity |
123
- |---|---|---|---|
124
- | 1 | Every service is assigned to exactly one preset | `service-unassigned`, `service-in-multiple-presets` | error |
125
- | 2 | Every preset references services that exist | `preset-references-unknown-service` | error |
126
- | 3 | Every environment binding's service matches an actual service | `binding-references-unknown-service` | error |
127
- | 3a | Every environment binding URL parses as a valid URL | `binding-invalid-url` | error |
128
- | 3b | Every environment binding URL declares an explicit port | `binding-missing-port` | error |
129
- | 4 | Every environment's redis/mongo resource keys exist | `unknown-redis-resource`, `unknown-mongo-resource` | error |
130
- | 5 | Fallback envs must share the same redis/mongo resource keys (shared-resource invariant) | `unknown-fallback-env`, `fallback-redis-mismatch`, `fallback-mongo-mismatch` | error |
131
- | 6 | `urlEnvKey` values resolve at config time | `missing-resource-env-var` | warning |
132
- | 7 | `synchronizedEnvKeys` values resolve at config time | `missing-synchronized-env-var` | warning |
133
- | 8 | Services bound in no environment | `service-bound-in-no-environment` | warning |
134
-
135
- Each rule pushes one or more `ValidationFinding` records into a single result array; the validator never throws. The CLI iterates findings and prints each one before deciding the exit code.
136
-
137
- ### Findings output
138
-
139
- ```typescript
140
- const printFinding = (finding: ValidationFinding): void => {
141
- const tag = finding.severity === 'error'
142
- ? `<red bold>ERROR</red>`
143
- : `<yellow bold>WARN </yellow>`;
144
- const code = `<dim>[${finding.code}]</dim>`;
145
- const location = finding.location ? `\n <dim>at ${finding.location}</dim>` : '';
146
- const sink = finding.severity === 'error' ? process.stderr : process.stdout;
147
- sink.write(`${tag} ${code} ${finding.message}${location}\n`);
148
- };
149
- ```
150
-
151
- - Errors go to stderr, warnings to stdout — so CI pipelines that only mirror stderr see failures cleanly.
152
- - ANSI color is only applied when stdout is a TTY (`process.stdout.isTTY`). Piping the CLI through `tee`, redirecting to a file, or running under non-TTY CI yields plain text.
153
- - The trailing summary line is the same as a single finding: `OK — 0 error(s), 2 warning(s)` (green) or `FAILED — 1 error(s), 0 warning(s)` (red).
154
-
155
- ### Exit codes
156
-
157
- | Code | Meaning |
158
- |---|---|
159
- | `0` | No errors. Warnings are allowed unless `--strict`. |
160
- | `1` | At least one error finding, OR `--strict` with at least one warning. |
161
- | `2` | Bad CLI usage: missing required flag, failed import, or config file didn't call its register function. |
162
-
163
- Designed to be a pre-deploy gate — the recommended placement is the last step of `npm run build` or a dedicated CI job after `tsc -p tsconfig.build.json`.
164
-
165
- ---
166
-
167
- ## Calling from a build script
168
-
169
- ```typescript
170
- import { validateDeploy } from '@luckystack/devkit';
171
- import { getServicesConfig, getDeployConfig } from '@luckystack/core';
172
-
173
- await import('./services.config');
174
- await import('./deploy.config');
175
-
176
- const result = validateDeploy({
177
- services: getServicesConfig(),
178
- deploy: getDeployConfig(),
179
- });
180
-
181
- if (!result.ok) {
182
- for (const finding of result.findings) {
183
- console.error(finding);
184
- }
185
- process.exit(1);
186
- }
187
- ```
188
-
189
- `validateDeploy` is pure and has no I/O. Pass a `env` fixture in tests:
190
-
191
- ```typescript
192
- const result = validateDeploy({
193
- services: testServices,
194
- deploy: testDeploy,
195
- env: {
196
- REDIS_URL: 'redis://localhost:6379',
197
- // MONGO_URL deliberately absent to trigger the warning
198
- },
199
- });
200
-
201
- expect(result.findings.map(f => f.code)).toContain('missing-resource-env-var');
202
- ```
203
-
204
- The CLI is sugar; library consumers should reach for `validateDeploy` directly and decide their own exit behavior.
205
-
206
- ---
207
-
208
- ## Failure modes
209
-
210
- | Symptom | Cause | Exit code |
211
- |---|---|---|
212
- | `--deploy and --services are required.` | Missing required flag | 2 |
213
- | `failed to import deploy config at <path>: ...` | The file path is wrong, throws on evaluation, or is missing | 2 |
214
- | `... did not call registerDeployConfig — nothing to validate` | Import succeeded but the consumer forgot the registration call | 2 |
215
- | `ERROR [service-unassigned] ...` | A service in `services.services` isn't in any preset | 1 |
216
- | `ERROR [preset-references-unknown-service] ...` | A preset references a service that doesn't exist | 1 |
217
- | `ERROR [binding-invalid-url] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` doesn't parse as a valid URL | 1 |
218
- | `ERROR [binding-missing-port] ...` | A binding URL in `deploy.config.ts > environments.<env>.bindings.<service>` is missing an explicit port — the router requires one to disambiguate per-preset backends | 1 |
219
- | `ERROR [fallback-redis-mismatch] ...` | Two envs in a fallback chain reference different redis resource keys | 1 |
220
- | `WARN [missing-resource-env-var] ...` | A resource's `urlEnvKey` is unset at config time (boot may still succeed if env is set at runtime) | 0 (1 with `--strict`) |
221
- | `FAILED — N error(s)` | Run had at least one error finding | 1 |
222
- | `OK — 0 error(s), N warning(s)` | No errors; warnings tolerated | 0 |
223
-
224
- ---
225
-
226
- ## Public exports
227
-
228
- Re-exported from `@luckystack/devkit`:
229
-
230
- ```typescript
231
- export { validateDeploy } from './validateDeploy';
232
- export type {
233
- ValidateDeployInput,
234
- ValidateDeployResult,
235
- ValidationFinding,
236
- ValidationSeverity,
237
- } from './validateDeploy';
238
- ```
239
-
240
- Core registry probes used by the CLI live in `@luckystack/core`:
241
-
242
- - `getServicesConfig()` / `isServicesConfigRegistered()`
243
- - `getDeployConfig()` / `isDeployConfigRegistered()`
244
-
245
- These are not re-exported by devkit; consumers calling `validateDeploy` from build scripts should import them directly from core.
5
+ Registration modules are loaded for their side effects, so their paths must point at the intended compiled environment. Treat warnings as failures in stricter deployment pipelines when a warning might hide a topology error. Do not treat a successful static validation as a replacement for runtime health checks or Redis boot-handshake verification.
@@ -1,365 +1,7 @@
1
- # Hot Reload (`setupWatchers`, template injection, dependency graph)
1
+ # Hot reload design
2
2
 
3
- > Dev-only. `setupWatchers()` is a no-op when `process.env.NODE_ENV === 'production'`. Production servers read the generated runtime maps on disk and never run chokidar.
3
+ Development route loading serves requests from in-memory registries; regenerating editor-facing type artifacts should not block the Socket.io request path. File-event bursts are coalesced, and a generation already in progress gets a follow-up pass instead of competing builds. Deferring heavy work lets the server listen before the TypeScript Program is built. Consult the watcher source for event classification and timing rather than maintaining a second event-flow implementation here.
4
4
 
5
- `setupWatchers()` is the single dev-time entry point that turns the project source tree into a live, hot-reloadable route registry. It boots one chokidar watcher for the source tree, one watcher per configured `serverFunctionDirs` root (legacy singular `serverFunctionsDir` still honored when set), and one for `sharedDir`. It coalesces file events into a few coarse buckets, and schedules background type-map regeneration without blocking the Socket.io thread.
5
+ When a shared dependency changes, reload only affected routes rather than every route, but invalidate type caches so subsequent requests use fresh source. Generated outputs must not trigger their own watcher loops. Empty route files can receive starter templates; paired sync files should remain type-consistent. Preserve the distinction between runtime reload and generated developer artifacts: a generation failure can leave IDE hints stale without justifying a production-path dependency on devkit.
6
6
 
7
- It must be called AFTER `initializeAll()` so the initial `devApis`/`devSyncs`/`devFunctions` maps are populated before the first hot-reload event has a chance to mutate them.
8
-
9
- ---
10
-
11
- ## Marker + path segments
12
-
13
- Resolved once per startup so consumers with custom paths or marker names work without forking:
14
-
15
- ```typescript
16
- const apiMarkerSlash = `/${getRoutingRules().apiMarker}/`;
17
- const syncMarkerSlash = `/${getRoutingRules().syncMarker}/`;
18
- const apiMarkerNoLead = `${getRoutingRules().apiMarker}/`;
19
- const syncMarkerNoLead = `${getRoutingRules().syncMarker}/`;
20
-
21
- const pathsCfg = getProjectConfig().paths;
22
- const srcSegment = `/${pathsCfg.srcDir.replaceAll('\\', '/')}/`;
23
- const sharedSegment = `/${pathsCfg.sharedDir.replaceAll('\\', '/')}/`;
24
- // One segment per configured root in `pathsCfg.serverFunctionDirs` (the legacy
25
- // singular `pathsCfg.serverFunctionsDir` is folded into the array at config load):
26
- const serverFunctionsSegments = pathsCfg.serverFunctionDirs.map(
27
- (dir) => `/${dir.replaceAll('\\', '/')}/`,
28
- );
29
- const localesSegment = `${srcSegment}_locales/`;
30
- ```
31
-
32
- The `*NoLead` variants exist because some event paths arrive without a leading slash (e.g. from chokidar add events) and the `*Slash` variants are used for `.includes()` middle-of-path matches.
33
-
34
- ---
35
-
36
- ## Coalescing state
37
-
38
- Three buckets of state, all module-local to the closure created by `setupWatchers`:
39
-
40
- ```typescript
41
- const reloadTimers = new Map<'api' | 'sync' | 'functions' | 'typemap' | 'locales', NodeJS.Timeout>();
42
- const pendingApiUpserts = new Set<string>();
43
- const pendingApiDeletes = new Set<string>();
44
- const pendingSyncUpserts = new Set<string>();
45
- const pendingSyncDeletes = new Set<string>();
46
- ```
47
-
48
- `reloadTimers` is keyed by reload kind. `scheduleReload(key, task, delay = getProjectConfig().dev.hotReloadDebounceMs)` clears any pending timer for that key and queues a new one. This means a flurry of saves coalesces to a single run; the timer's `task` reads the current pending sets and processes them atomically:
49
-
50
- ```typescript
51
- const scheduleReload = (
52
- key: 'api' | 'sync' | 'functions' | 'typemap' | 'locales',
53
- task: () => Promise<void> | void,
54
- delay = getProjectConfig().dev.hotReloadDebounceMs
55
- ) => {
56
- const activeTimer = reloadTimers.get(key);
57
- if (activeTimer) clearTimeout(activeTimer);
58
-
59
- const timer = setTimeout(() => {
60
- reloadTimers.delete(key);
61
- void task();
62
- }, delay);
63
-
64
- reloadTimers.set(key, timer);
65
- };
66
- ```
67
-
68
- The same path is allowed to appear in both an upsert set and a delete set across consecutive events (rename, delete-then-write); the processing functions drain each set and the loader's `upsert*FromFile` / `remove*FromFile` are idempotent.
69
-
70
- ---
71
-
72
- ## Type-map regeneration queue
73
-
74
- ```typescript
75
- const typeMapQueue = { pending: false, running: false };
76
-
77
- const runTypeMapRegeneration = () => {
78
- typeMapQueue.running = true;
79
- typeMapQueue.pending = false;
80
- const startedAt = Date.now();
81
- setImmediate(() => {
82
- void (async () => {
83
- const [err] = await tryCatch(() => { generateTypeMapFile({ quiet: true }); });
84
- if (err) {
85
- console.log(`[HotReload] type map regeneration failed: ${String(err)}`, 'red');
86
- } else {
87
- console.log(`[HotReload] type map ready in ${Date.now() - startedAt}ms`, 'green');
88
- }
89
- typeMapQueue.running = false;
90
- if (typeMapQueue.pending) {
91
- runTypeMapRegeneration();
92
- }
93
- })();
94
- });
95
- };
96
-
97
- const requestTypeMapRegeneration = () => {
98
- if (typeMapQueue.running) {
99
- typeMapQueue.pending = true;
100
- return;
101
- }
102
- runTypeMapRegeneration();
103
- };
104
- ```
105
-
106
- Two invariants:
107
-
108
- 1. **Type-map regeneration is a DX artifact.** The runtime request path reads from in-memory `devApis`/`devSyncs`/`devFunctions`, so regeneration is purely for IDE IntelliSense + the Zod schemas on disk. There is no reason to await it on the reload critical path.
109
- 2. **Single-flight with one queued follow-up.** If a regeneration is already running, the next request flips `pending = true` and returns. When the running pass finishes it consults `pending` and re-runs. This collapses any number of saves during a long pass into exactly one follow-up.
110
-
111
- `setImmediate` is critical: it yields to the event loop so the Socket.io accept path keeps serving during a burst of saves. Without it, a single `generateTypeMapFile()` could hold the thread for hundreds of milliseconds during heavy type expansion. The same pattern is used for the initial type-map generation on boot (see "Boot-time behavior" below).
112
-
113
- `quiet: true` silences the per-API/per-sync logs from the emitter during hot reload — only the final "type map ready in Xms" line prints. The first run on boot also runs quiet to keep startup output readable.
114
-
115
- ---
116
-
117
- ## File-kind classifiers
118
-
119
- ```typescript
120
- const isGeneratedPath = (n: string): boolean =>
121
- n.includes('apiTypes.generated.ts') || n.includes('apiDocs.generated.json');
122
-
123
- const isRouteDependencyFile = (n: string): boolean => {
124
- // .ts / .tsx, inside srcDir, NOT inside an _api or _sync folder, NOT a generated artifact
125
- };
126
-
127
- const isSharedDependencyFile = (n: string): boolean => {
128
- // .ts / .tsx inside sharedDir OR any configured serverFunctionDirs root
129
- // (legacy singular serverFunctionsDir still honored when set)
130
- };
131
-
132
- const isTypeMapRelevantFile = (n: string): boolean => {
133
- // .ts / .tsx, in srcDir / sharedDir / or root config.ts, NOT generated, NOT _api/_sync
134
- };
135
-
136
- const isLocaleFile = (n: string): boolean =>
137
- n.includes(localesSegment) && n.endsWith('.json');
138
- ```
139
-
140
- These predicates classify each event into one of the buckets handled by the event handlers. Order matters in the handlers: route files go through the API/Sync path; shared/route-dependency files trigger a fan-out through the import dependency graph; locales trigger a translator reload; generated files are dropped entirely (otherwise the type-map regeneration would feed itself).
141
-
142
- ---
143
-
144
- ## Event handlers
145
-
146
- Three chokidar watchers all route into a small set of handlers:
147
-
148
- ```typescript
149
- watch(pathsConfig.srcDir, { ignoreInitial: true, awaitWriteFinish: { ... } })
150
- .on('add', handleAdd)
151
- .on('change', handleChange)
152
- .on('unlink', handleDelete);
153
-
154
- // One watcher per configured root in `pathsConfig.serverFunctionDirs`
155
- // (legacy singular `pathsConfig.serverFunctionsDir` is folded into the array at config load):
156
- for (const serverFunctionsDir of pathsConfig.serverFunctionDirs) {
157
- watch(serverFunctionsDir, { ignoreInitial: true })
158
- .on('add', handleFunctionChange)
159
- .on('change', handleFunctionChange)
160
- .on('unlink', handleFunctionChange);
161
- }
162
-
163
- watch(pathsConfig.sharedDir, { ignoreInitial: true })
164
- .on('add', handleFunctionChange)
165
- .on('change', handleFunctionChange)
166
- .on('unlink', handleFunctionChange);
167
- ```
168
-
169
- `awaitWriteFinish` is tuned via `projectConfig.dev`:
170
-
171
- ```typescript
172
- awaitWriteFinish: {
173
- stabilityThreshold: devConfig.watcherStabilityThresholdMs,
174
- pollInterval: devConfig.watcherPollIntervalMs,
175
- }
176
- ```
177
-
178
- This is what stops chokidar from firing on a partially-written file (e.g. a save-in-progress from VSCode).
179
-
180
- ### `handleAdd(path)`
181
-
182
- 1. Normalize the path.
183
- 2. `invalidateGraphForFile(normalizedPath)` — drop any stale dependency-graph nodes pointing at the old version of this file (see `importDependencyGraph.ts`).
184
- 3. Route filename validation via `getRouteFilenameValidationMessage`. If the filename is invalid AND `shouldInjectTemplate(path)` says it's an empty file, try `injectTemplate(path)` — this is how `touch _api/foo.ts` ends up with starter content. If a template was injected, return (the change event from writing the template will flow back through).
185
- 4. Empty-file template injection for a valid-named file:
186
- - Sync server file with an existing client: extract the `clientInput` type from the client file, inject the paired server template with that type pre-filled, schedule type-map regeneration in the background, update the client to import the type, then upsert both files.
187
- - Otherwise inject the standalone template.
188
- 5. If the path is inside the API marker folder: add to `pendingApiUpserts`, schedule the `api` reload. The schedule's task calls `processPendingApiChanges({ regenerateTypeMap: true })` — `true` because an add changes the route set, not just one route's types.
189
- 6. If inside the sync marker folder: mirror the same with `pendingSyncUpserts`.
190
- 7. Otherwise delegate to `handleChange(path)` so dependency-graph / type-map relevance logic runs for the new file.
191
-
192
- ### `handleChange(path)`
193
-
194
- The hot path for "I just saved this file". Sequence after `invalidateGraphForFile`:
195
-
196
- 1. If the filename is route-shaped but invalid, log and bail (after attempting template injection if the file is empty).
197
- 2. If `shouldInjectTemplate(path)` (the file became empty) — inject and return.
198
- 3. Drop generated paths.
199
- 4. Locale `.json` — schedule the `locales` task which calls `await getLocaleReloader()?.()` (the function `@luckystack/core`'s i18n module registers).
200
- 5. **Route dependency file (a `.ts`/`.tsx` inside `srcDir` that is NOT an API or sync):**
201
- - Schedule a `typemap` task that requests background regeneration.
202
- - Call `enqueueAffectedRoutesFromDependency(normalizedPath)` to fan out reloads through the import dependency graph (next section).
203
- 6. **Type-map-relevant file** that wasn't already a route dependency (e.g. `config.ts`, shared lib files): schedule a `typemap` regeneration only.
204
- 7. API/sync file change: schedule both `typemap` regeneration AND a reload pass that processes the upsert/delete sets.
205
-
206
- ### `handleDelete(path)`
207
-
208
- Same skeleton as `handleChange`. The two notable extras:
209
-
210
- - Generated paths and locale deletes are handled the same way as in `handleChange`.
211
- - Sync server file deletion: the watcher reads the just-deleted server file's `clientInput` type FROM THE GENERATED TYPE MAP (the server file is already gone on disk) via `extractClientInputFromGeneratedTypes(pagePath, syncName)`, then rewrites the client file via `updateClientFileForDeletedServer(clientPath, clientInputTypes)`. If extraction fails, a fallback string is written with a TODO comment. This is the only place the watcher reaches into the generated type-map to read a type.
212
-
213
- ### `handleFunctionChange(changedPath)`
214
-
215
- Triggered by both the server-functions watcher and the shared watcher.
216
-
217
- - Invalidate the dependency graph for the changed file.
218
- - Schedule the `functions` reload: re-run `initializeFunctions()` AND request a type-map regeneration (server functions are part of the generated `Functions` interface).
219
- - If the file is a shared-dependency file, ALSO call `enqueueAffectedRoutesFromDependency` so routes that import from `<sharedDir>` or any configured `<serverFunctionDirs>` root (legacy singular `<serverFunctionsDir>` still honored when set) reload.
220
-
221
- ---
222
-
223
- ## Dependency graph fan-out
224
-
225
- Defined in `importDependencyGraph.ts`. Two functions:
226
-
227
- ```typescript
228
- findDependentRouteFiles(changedPath: string): Set<string>
229
- invalidateGraphForFile(changedPath: string): void
230
- ```
231
-
232
- `findDependentRouteFiles` returns every `_api/`/`_sync/` route file that (transitively) imports `changedPath`. `enqueueAffectedRoutesFromDependency` walks the result and routes each entry into the right pending set:
233
-
234
- ```typescript
235
- for (const routePath of affectedRoutes) {
236
- if (routePath.includes(apiMarkerSlash)) {
237
- pendingApiDeletes.delete(routePath);
238
- pendingApiUpserts.add(routePath);
239
- queuedApiCount += 1;
240
- } else if (routePath.includes(syncMarkerSlash)) {
241
- pendingSyncDeletes.delete(routePath);
242
- pendingSyncUpserts.add(routePath);
243
- queuedSyncCount += 1;
244
- }
245
- }
246
- ```
247
-
248
- With dynamic `import()` + per-load `?v=` cachebust in the loader, there is nothing to invalidate on the main thread; the next upsert fetches a fresh module instance, and the import dependency graph itself is invalidated synchronously by `invalidateGraphForFile`.
249
-
250
- If no routes depend on the changed file, the function logs in yellow and returns without scheduling work — saving an unrelated `_components/Foo.tsx` doesn't fan out to every route.
251
-
252
- ---
253
-
254
- ## Pending change processors
255
-
256
- ```typescript
257
- const processPendingApiChanges = async ({ regenerateTypeMap = false }: { regenerateTypeMap?: boolean } = {}) => {
258
- const deletePaths = [...pendingApiDeletes];
259
- const upsertPaths = [...pendingApiUpserts];
260
- pendingApiDeletes.clear();
261
- pendingApiUpserts.clear();
262
-
263
- if (regenerateTypeMap) {
264
- requestTypeMapRegeneration();
265
- }
266
-
267
- for (const deletePath of deletePaths) {
268
- removeApiFromFile(deletePath);
269
- console.log(`[HotReload] API removed: ${deletePath}`, 'yellow');
270
- }
271
-
272
- for (const upsertPath of upsertPaths) {
273
- await upsertApiFromFile(upsertPath);
274
- console.log(`[HotReload] API reloaded: ${upsertPath}`, 'green');
275
- }
276
- };
277
- ```
278
-
279
- The sync variant mirrors this and adds the sync-server-delete-with-living-client repair step (see `handleDelete`). Both processors:
280
-
281
- 1. Snapshot the pending sets.
282
- 2. Clear the sets so concurrent file events can populate them again.
283
- 3. Optionally request a background type-map regeneration.
284
- 4. Run deletes before upserts so a delete followed by an immediate re-add resolves correctly.
285
- 5. Use the loader's `upsertApiFromFile` / `removeApiFromFile` (and `upsertSyncFromFile` / `removeSyncFromFile`), each of which invalidates the TS program cache and the runtime type resolver cache. See `loader-pipeline.md`.
286
-
287
- ---
288
-
289
- ## Template injection
290
-
291
- Helpers from `templateInjector.ts` and `templates/*.ts`:
292
-
293
- | Helper | What |
294
- |---|---|
295
- | `shouldInjectTemplate(path)` | File exists, is `.ts`, and is empty (`isEmptyFile`) AND is API- or sync-shaped. |
296
- | `injectTemplate(path)` | Writes the right template based on the filename: API, sync server, or sync client. Returns `true` if injected. |
297
- | `isSyncServerFile(normalizedPath)` | Filename matches `_server_v<n>.ts`. |
298
- | `getPairedSyncFile(normalizedPath)` | Returns the sibling `_client_v<n>.ts` (or `_server_v<n>.ts`) path. |
299
- | `getRouteFilenameValidationMessage(normalizedPath)` | Returns a human-readable error string if the filename is route-shaped but invalid; `null` otherwise. |
300
- | `extractClientInputFromFile(clientPath)` | Reads the client file with the TS Program and returns the `data` type literal text. |
301
- | `extractClientInputFromGeneratedTypes(pagePath, syncName)` | Reads `apiTypes.generated.ts` and returns the `clientInput` member for the sync. Used when the server file has just been deleted. |
302
- | `extractSyncPagePath(path)` / `extractSyncName(path)` | Filename -> generated-types lookup keys. |
303
- | `injectServerTemplateWithClientInput(serverPath, clientInput)` | Writes the sync server template with the `data` type pre-filled. |
304
- | `updateClientFileForPairedServer(clientPath)` | Rewrites the client to import `clientInput` / `serverOutput` from the generated types file. |
305
- | `updateClientFileForDeletedServer(clientPath, clientInput)` | Rewrites the client to redeclare `clientInput` inline (since the server is gone). |
306
- | `isEmptyFile(filePath)` | True if file size is 0 or content is whitespace-only. |
307
-
308
- The pairing flow (sync server added with an existing client) is the most interesting case — without it, the writer would have to manually copy the `data` type into both files. With it, the AI / IDE sees consistent types across server + client immediately after `touch foo_server_v1.ts`.
309
-
310
- ---
311
-
312
- ## Locale reload integration
313
-
314
- ```typescript
315
- if (isLocaleFile(normalizedPath)) {
316
- scheduleReload('locales', async () => {
317
- await getLocaleReloader()?.();
318
- });
319
- return;
320
- }
321
- ```
322
-
323
- `getLocaleReloader()` returns the function `@luckystack/core` registered via `registerLocaleReloader(...)` during boot. The locale module owns its own cache; devkit just signals "your files changed, reload yourself".
324
-
325
- If no locale reloader is registered, the optional chain is a no-op and the save is silently dropped.
326
-
327
- ---
328
-
329
- ## Boot-time behavior
330
-
331
- ```typescript
332
- setImmediate(() => {
333
- void (async () => {
334
- const [err] = await tryCatch(() => { generateTypeMapFile({ quiet: true }); });
335
- if (err) {
336
- console.log(`[HotReload] initial type map generation failed: ${String(err)}`, 'red');
337
- } else {
338
- console.log(`[HotReload] type map ready in background`, 'green');
339
- }
340
- })();
341
- });
342
- ```
343
-
344
- At the bottom of `setupWatchers()`. Generates the initial type map on the next event-loop tick so:
345
-
346
- 1. `server.listen()` happens before the heavy TypeScript Program build.
347
- 2. The runtime reads from the in-memory `devApis`/`devSyncs` maps (already populated by `initializeAll()` before `setupWatchers` runs); the on-disk type map is purely for IDE IntelliSense + Zod schema files.
348
- 3. Deferring drops boot time ~6–8 seconds on a 54-API project.
349
-
350
- The same `setImmediate` + `quiet` + tryCatch pattern is reused by `runTypeMapRegeneration` for every subsequent run.
351
-
352
- ---
353
-
354
- ## Failure modes
355
-
356
- | Symptom | Cause | What happens |
357
- |---|---|---|
358
- | `[HotReload] type map regeneration failed: ...` | `generateTypeMapFile()` threw (unresolved symbols, missing `tsconfig.server.json`, etc.) | Logged in red, server keeps running, runtime maps unchanged |
359
- | `[HotReload] <route validation message>` | Filename is route-shaped but invalid (no `_v<n>`, etc.) | File not loaded; route key absent |
360
- | `[HotReload] No API/Sync routes depend on: ...` | A shared/dep file changed but no route imports it | Type-map regeneration still scheduled; no upserts |
361
- | Server file deletion without existing client | `getPairedSyncFile` returns null or client doesn't exist | Sync entry removed, no client repair attempted |
362
- | Two saves in <`hotReloadDebounceMs`> ms | `scheduleReload` coalesces | One run reads the final pending sets |
363
- | Save during regeneration | `requestTypeMapRegeneration` flips `pending = true` | One follow-up run after the current one finishes |
364
- | Empty file written by `touch` | `shouldInjectTemplate` returns true | Template is injected; the resulting change event flows through normally |
365
- | Locale `.json` typo | `getLocaleReloader()` errors | No special handling; error surfaces from the registered reloader |
7
+ Production does not start these watchers. If the live registry appears stale, inspect watcher errors and route naming before relying on regenerated files as proof the running handler was replaced.