@luckystack/devkit 0.4.0 → 0.5.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.
- package/CLAUDE.md +125 -125
- package/LICENSE +21 -21
- package/README.md +44 -44
- package/dist/{chunk-BZTCJ7JY.js → chunk-EU2RFSLO.js} +1 -1
- package/dist/chunk-EU2RFSLO.js.map +1 -0
- package/dist/cli/validateDeploy.js +1 -1
- package/dist/cli/validateDeploy.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/supervisor.js +0 -0
- package/dist/supervisor.js.map +1 -1
- package/dist/templates/api.template.ts +54 -54
- package/dist/templates/page_dashboard.template.tsx +35 -35
- package/dist/templates/page_plain.template.tsx +32 -32
- package/dist/templates/sync_client.template.ts +40 -40
- package/dist/templates/sync_client_paired.template.ts +38 -38
- package/dist/templates/sync_client_standalone.template.ts +36 -36
- package/dist/templates/sync_server.template.ts +64 -64
- package/docs/cli.md +245 -245
- package/docs/hot-reload.md +365 -365
- package/docs/loader-pipeline.md +324 -324
- package/docs/runtime-type-resolver.md +258 -258
- package/docs/supervisor.md +292 -292
- package/docs/ts-program-cache.md +199 -199
- package/docs/type-map-generation.md +392 -392
- package/package.json +7 -2
- package/dist/chunk-BZTCJ7JY.js.map +0 -1
package/docs/hot-reload.md
CHANGED
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
# Hot Reload (`setupWatchers`, template injection, dependency graph)
|
|
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.
|
|
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.
|
|
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 |
|
|
1
|
+
# Hot Reload (`setupWatchers`, template injection, dependency graph)
|
|
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.
|
|
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.
|
|
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 |
|