@luckystack/devkit 0.4.1 → 0.5.1

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.
@@ -1,324 +1,324 @@
1
- # Loader Pipeline (`initializeAll`, `initializeApis`, `initializeSyncs`, `initializeFunctions`, upsert/remove)
2
-
3
- > Dev-only. `@luckystack/devkit` is published as a `devDependency`. Nothing in this document runs in a production bundle — the prod runtime maps loader in `@luckystack/server` reads from in-memory `devApis`/`devSyncs`/`devFunctions` only when `NODE_ENV !== 'production'`, and from the on-disk generated maps otherwise.
4
-
5
- The loader pipeline is the dev-time mirror of the production route registry. It walks the configured `srcDir` (from `getProjectConfig().paths`) and each `serverFunctionDirs` root (legacy singular `serverFunctionsDir` still honored when set), evaluates each route file via dynamic `import()`, and populates three in-memory maps:
6
-
7
- | Map | Shape | Mirror of |
8
- |---|---|---|
9
- | `devApis` | `Record<string, { main, auth, rateLimit, httpMethod, schema, inputType, inputTypeFilePath }>` keyed by `api/<page-location\|'system'>/<name>/v<n>` | every `_api/<name>_v<n>.ts` |
10
- | `devSyncs` | `Record<string, { main, auth, inputType, inputTypeFilePath } \| Function>` keyed by `sync/<page-location>/<name>/v<n>_<server\|client>` | every `_sync/<name>_(server\|client)_v<n>.ts` |
11
- | `devFunctions` | nested `Record<string, unknown>` mirror of the on-disk tree, leaves are merged named + default exports per file | every `<root>/**/*.ts` under each `serverFunctionDirs` root (legacy singular `serverFunctionsDir` still honored when set) |
12
-
13
- All three maps are mutated in place. Consumers (the dev server boot path, the prod runtime maps loader's dev branch) hold a stable reference to the exported objects and read them on every request.
14
-
15
- ---
16
-
17
- ## `initializeAll()`
18
-
19
- ```typescript
20
- export const initializeAll = async () => {
21
- assertValidRouteNaming({
22
- srcDir: getSrcDir(),
23
- context: 'starting dev server (npm run server)',
24
- });
25
-
26
- await Promise.all([initializeApis(), initializeSyncs(), initializeFunctions()]);
27
- };
28
- ```
29
-
30
- Order matters:
31
-
32
- 1. **Route naming validation first.** `assertValidRouteNaming` walks the file tree once and throws if any `_api/` or `_sync/` file doesn't match the version regex (e.g. `_v1.ts`, `_server_v2.ts`). Aborting before any file evaluates means a typo can't produce half-loaded state.
33
- 2. **Three initializers in parallel** via `Promise.all`. They share the TypeScript program cache (see `ts-program-cache.md`) — running them sequentially would not be faster, since the cache only gets built on first access from inside the extractors.
34
-
35
- After `initializeAll()` returns, the dev server should call `setupWatchers()` exactly once (see `hot-reload.md`) so subsequent file changes incrementally mutate the same maps.
36
-
37
- ---
38
-
39
- ## `initializeApis()`
40
-
41
- ```typescript
42
- export const initializeApis = async () => {
43
- for (const key of Object.keys(devApis)) delete devApis[key];
44
- //? No invalidateProgramCache() here — cachedProgram starts as null on
45
- //? module-load (tsProgram.ts), so the first getServerProgram() call
46
- //? builds it from scratch. With initializeApis + initializeSyncs running
47
- //? in parallel via Promise.all, invalidating here forced a redundant
48
- //? double-build (~3-4s waste). Hot-reload paths (upsertApiFromFile,
49
- //? removeApiFromFile etc.) DO invalidate — that's where it's needed.
50
- clearRuntimeTypeResolverCache();
51
- const srcFolder = fs.readdirSync(getSrcDir());
52
-
53
- for (const file of srcFolder) {
54
- await scanApiFolder(file);
55
- }
56
- };
57
- ```
58
-
59
- Flow:
60
-
61
- 1. Empty `devApis` by deleting every key. The exported reference stays stable.
62
- 2. **Deliberately skip `invalidateProgramCache()`.** Module-load init in `tsProgram.ts` starts with `cachedProgram = null`, so the first `getServerProgram()` call from inside the extractors will build it from scratch. Calling `invalidateProgramCache()` here used to force a redundant ~3-4s double-build because `initializeApis` and `initializeSyncs` run concurrently and would each invalidate after the other had warmed the cache. The hot-reload upsert/remove paths DO invalidate, because there the cache really is stale.
63
- 3. Clear the runtime type resolver cache (`clearRuntimeTypeResolverCache()`) so any string-based identifier resolution starts fresh.
64
- 4. Recurse the project's `srcDir` via `scanApiFolder`. The walker descends through every directory looking for one whose lowercase name ends in `api` (the resolved `apiMarker` from `getRoutingRules()` — default `_api`).
65
- 5. For each `<name>_v<n>.ts` inside that folder, parse the version with `apiVersionRegex`, import the file, extract `main` / `auth` / `rateLimit` / `httpMethod` / `schema`, derive the inline `inputType` text via `getInputTypeFromFile()`, and store the entry under `api/<pageLocation>/<name>/v<n>`. `pageLocation` is `''` when the API lives at `src/_api/` and is mapped to `'system'` by `mapApiPageLocation()`; otherwise it's the slash-joined path segments above the marker.
66
-
67
- `auth` is normalized at load time:
68
-
69
- ```typescript
70
- auth: {
71
- login: auth.login || false,
72
- additional: auth.additional || [],
73
- }
74
- ```
75
-
76
- Missing `main` (or non-function `main`) silently skips the file — the entry is removed and the route key is absent from `devApis`. Filename parse failures log in red to `[loader][api]` and continue.
77
-
78
- ---
79
-
80
- ## `initializeSyncs()`
81
-
82
- Same shape as `initializeApis()`, keyed on folders whose lowercase name ends in `sync` (the resolved `syncMarker`). Each `_sync/<name>_(server|client)_v<n>.ts` is matched by `syncVersionRegex`, yielding the `kind` (`server` or `client`) and version. The route base key is:
83
-
84
- - `sync/<pageLocation>/<syncName>/v<n>` (when `pageLocation` is non-empty)
85
- - `sync/<syncName>/v<n>` (root-level sync)
86
-
87
- Final entry key appends `_server` or `_client`. Storage differs by kind:
88
-
89
- - **Server kind** stores a record:
90
- ```typescript
91
- devSyncs[`${routeBaseKey}_server`] = {
92
- main: resolvedSyncModule.main,
93
- auth: resolvedSyncModule.auth || {},
94
- inputType, // derived via getSyncClientDataType()
95
- inputTypeFilePath: filePath,
96
- };
97
- ```
98
- - **Client kind** stores the bare `main` callback (the request dispatcher calls it directly with `{ clientOutput, serverOutput }`):
99
- ```typescript
100
- devSyncs[`${routeBaseKey}_client`] = resolvedSyncModule.main;
101
- ```
102
-
103
- Like `initializeApis()`, the boot path skips `invalidateProgramCache()` and only clears the runtime type resolver cache.
104
-
105
- ---
106
-
107
- ## `initializeFunctions()`
108
-
109
- ```typescript
110
- export const initializeFunctions = async () => {
111
- for (const key of Object.keys(devFunctions)) delete devFunctions[key];
112
-
113
- for (const serverFunctionsDir of getServerFunctionDirs()) {
114
- if (fs.existsSync(serverFunctionsDir)) {
115
- await scanFunctionsFolder(serverFunctionsDir);
116
- }
117
- }
118
- };
119
- ```
120
-
121
- > `getServerFunctionDirs()` returns the array from `projectConfig.paths.serverFunctionDirs`. The legacy singular `serverFunctionsDir` form is still honored — when set it is merged into the array at config load.
122
-
123
- Walks every configured root recursively. Per `.ts` file:
124
-
125
- 1. Import via `importFile()` (see below).
126
- 2. Run `resolveFunctionModule(module, fileName)`:
127
- - Module with only named exports -> the named exports object (default merged in only if it's the sole export).
128
- - Module with no named exports but a default export -> `{ [fileName]: defaultExport }`. This lets `import functionName from './function'` show up at `devFunctions[folder]?.[functionName]?.[functionName]`.
129
- - Module that is itself a function -> returned as-is so it can be merged.
130
- 3. Walk into the `devFunctions` tree along the `basePath` segments, creating nested `Record<string, unknown>` subtrees on demand. Each level is structurally a record but typed as `unknown` after one level of indexing — `resolveFunctionModule` re-narrows before descent.
131
- 4. If a previous scan left a node at the same leaf path, `Object.assign(resolvedFunctionModule, existingAtFileName)` merges them. This is what lets two files in the same folder contribute to the same logical bag of helpers.
132
-
133
- Failure modes are logged with `[loader][function]` and the file is skipped.
134
-
135
- ---
136
-
137
- ## Per-file import strategy
138
-
139
- ```typescript
140
- const importFile = async (absolutePath: string) => {
141
- const url = `${pathToFileURL(absolutePath).href}?v=${Date.now()}`;
142
- return import(url);
143
- };
144
- ```
145
-
146
- - `pathToFileURL` converts Windows-style paths and special characters into a proper `file://` URL.
147
- - The `?v=<timestamp>` query is a cachebust that forces the ESM loader to return a fresh evaluation each call. Without it, `import()` is memoized and a saved file would not re-evaluate.
148
- - Switching from CommonJS `require()` to dynamic `import()` also stops module evaluation from blocking the event loop. With CJS, evaluating a single ~50 ms file held the Socket.io thread; with ESM the evaluation yields and Socket.io stays responsive during a save burst.
149
-
150
- The `tryCatch` wrapper from `@luckystack/core` captures evaluation errors:
151
-
152
- ```typescript
153
- const [err, module] = await tryCatch(async () => importFile(routeMeta.absolutePath));
154
- if (err) {
155
- console.log(`[loader][api] failed to import ${routeMeta.routeKey} from ${routeMeta.absolutePath}:`, err, 'red');
156
- return;
157
- }
158
- ```
159
-
160
- A failed import leaves the existing `devApis` / `devSyncs` entry untouched (no partial replacement).
161
-
162
- ---
163
-
164
- ## Route key shape
165
-
166
- API key:
167
-
168
- ```
169
- api/<pageLocation|'system'>/<apiName>/v<n>
170
- ```
171
-
172
- `pageLocation` is the slash-joined path segments BEFORE the `_api` marker. `system` is used when the API sits at the project root (e.g. `src/_api/healthCheck_v1.ts` -> `api/system/healthCheck/v1`).
173
-
174
- Sync key:
175
-
176
- ```
177
- sync/<pageLocation>/<syncName>/v<n>_<server|client>
178
- ```
179
-
180
- If the sync lives at the project root (no page above the `_sync` marker), the key drops the page segment: `sync/<syncName>/v<n>_<server|client>`.
181
-
182
- These shapes match the route keys produced by the production runtime maps loader in `@luckystack/server`. Switching between dev and prod is a matter of which map the request dispatcher reads from.
183
-
184
- ---
185
-
186
- ## Filename validation regexes
187
-
188
- ```typescript
189
- export const API_VERSION_TOKEN_REGEX = /_v(\d+)$/;
190
- export const SYNC_VERSION_TOKEN_REGEX = /_(server|client)_v(\d+)$/;
191
- ```
192
-
193
- Re-exported from `routeConventions.ts` for consumers (e.g. the docs UI) that need to parse filenames.
194
-
195
- `assertValidRouteNaming({ srcDir, context })` walks the tree and throws an `Error` whose message includes:
196
-
197
- - The `context` string passed in (e.g. `'starting dev server (npm run server)'` or `'generating API/sync type maps'`).
198
- - Every offending file path with the expected pattern.
199
-
200
- `assertNoDuplicateNormalizedRouteKeys({ srcDir, context })` is called only by the type-map generator. It catches the case where two differently-cased files (`getSettings_v1.ts` vs `getsettings_v1.ts`) normalize to the same route key on case-insensitive filesystems.
201
-
202
- ---
203
-
204
- ## Routing rules registry
205
-
206
- ```typescript
207
- export interface RoutingRules {
208
- apiMarker: string; // default '_api'
209
- syncMarker: string; // default '_sync'
210
- apiVersionRegex: RegExp; // default API_VERSION_TOKEN_REGEX
211
- syncVersionRegex: RegExp;// default SYNC_VERSION_TOKEN_REGEX
212
- }
213
-
214
- export const registerRoutingRules = (rules: Partial<RoutingRules>): void;
215
- export const getRoutingRules = (): RoutingRules;
216
- ```
217
-
218
- Defaults are exported from `routingRules.ts` and consumed by every walker (`scanApiFolder`, `scanSyncFolder`, `resolveApiRouteMetaFromPath`, `resolveSyncRouteMetaFromPath`, the watcher) plus the filename predicates:
219
-
220
- ```typescript
221
- export const apiMarkerSegment = (): string;
222
- export const syncMarkerSegment = (): string;
223
- export const isApiFileName = (name: string): boolean;
224
- export const isSyncFileName = (name: string): boolean;
225
- export const isSyncServerFileName = (name: string): boolean;
226
- export const isSyncClientFileName = (name: string): boolean;
227
- ```
228
-
229
- A consumer registering custom markers (`{ apiMarker: 'api', syncMarker: 'live' }`) sees them flow through to the watcher segment computation in `setupWatchers()` (see `hot-reload.md`) and the loader scans, so non-default layouts work without forking the package.
230
-
231
- ---
232
-
233
- ## Hot-reload single-file paths
234
-
235
- All four hot-reload functions follow the same shape:
236
-
237
- ```typescript
238
- export const upsertApiFromFile = async (filePath: string): Promise<void> => {
239
- const routeMeta = resolveApiRouteMetaFromPath(filePath);
240
- if (!routeMeta) {
241
- // log + return — filename rejection
242
- return;
243
- }
244
-
245
- invalidateProgramCache();
246
- clearRuntimeTypeResolverCache();
247
-
248
- const [err, module] = await tryCatch(async () => importFile(routeMeta.absolutePath));
249
- if (err) { /* log + return */ return; }
250
-
251
- const resolvedModule = module?.default ? { ...module.default, ...module } : module;
252
- const { auth = {}, main, rateLimit, httpMethod, schema } = resolvedModule;
253
-
254
- if (!main || typeof main !== 'function') {
255
- delete devApis[routeMeta.routeKey];
256
- return;
257
- }
258
-
259
- const inputType = getInputTypeFromFile(routeMeta.absolutePath);
260
-
261
- devApis[routeMeta.routeKey] = { main, auth: { ... }, rateLimit, httpMethod, schema, inputType, inputTypeFilePath: routeMeta.absolutePath };
262
- };
263
- ```
264
-
265
- Key differences from the boot initializers:
266
-
267
- | Step | Boot (`initializeApis`/`initializeSyncs`) | Hot reload (`upsert*`/`remove*`) |
268
- |---|---|---|
269
- | `invalidateProgramCache()` | Skipped — cache is null on module load | Always called — file content changed |
270
- | `clearRuntimeTypeResolverCache()` | Called | Called |
271
- | Resolve filename via marker walk | Recursive tree scan | `resolveApiRouteMetaFromPath` / `resolveSyncRouteMetaFromPath` on the single path |
272
- | Missing `main` | Silently skip | Explicit `delete devApis[key]` to keep table in sync with file state |
273
-
274
- `removeApiFromFile` / `removeSyncFromFile` follow the same skeleton minus the import: they invalidate caches and `delete` the matching entry. If the file path doesn't normalize to a route key (wrong marker segment, wrong extension), the call is a no-op — the watcher fan-out from `enqueueAffectedRoutesFromDependency` may enqueue paths that aren't routes, and silently dropping them is the right behavior.
275
-
276
- Sync kind handling in `upsertSyncFromFile`:
277
-
278
- - `kind === 'server'`: store `{ main, auth, inputType, inputTypeFilePath }` if `main` is a function, otherwise delete the entry.
279
- - `kind === 'client'`: store the bare `main` callback if it's a function, otherwise delete the entry.
280
-
281
- ---
282
-
283
- ## Consumer contract
284
-
285
- Dev server boot:
286
-
287
- ```typescript
288
- import { initializeAll, setupWatchers } from '@luckystack/devkit';
289
-
290
- await initializeAll();
291
- setupWatchers();
292
- // then start HTTP server / Socket.io
293
- ```
294
-
295
- Production runtime maps loader (`packages/server/src/runtimeMapsLoader.ts`):
296
-
297
- ```typescript
298
- if (process.env.NODE_ENV !== 'production') {
299
- const devkit = await import('@luckystack/devkit');
300
- return {
301
- apis: devkit.devApis,
302
- syncs: devkit.devSyncs,
303
- functions: devkit.devFunctions,
304
- };
305
- }
306
-
307
- // prod: read from generated maps on disk
308
- ```
309
-
310
- The dev loader is never invoked under `NODE_ENV=production`; the generated `apiTypes.generated.ts` / `apiInputSchemas.generated.ts` (see `type-map-generation.md`) drive the prod path instead.
311
-
312
- ---
313
-
314
- ## Failure modes (summary)
315
-
316
- | Symptom | Cause | What happens |
317
- |---|---|---|
318
- | `[loader][api] invalid filename: ...` | filename doesn't match `apiVersionRegex` | file skipped, route key absent from `devApis` |
319
- | `[loader][sync] invalid filename: ...` | filename doesn't match `syncVersionRegex` | file skipped, route key absent from `devSyncs` |
320
- | `[loader][api] failed to import ...` | runtime error during `import()` | previous entry left untouched, error logged |
321
- | `[loader][function] failed to import ...` | runtime error in a server-functions file | previous nested node left untouched |
322
- | `assertValidRouteNaming` throw at startup | any `_api/`/`_sync/` file fails the regex | dev server boot aborts before any file evaluates |
323
- | Missing `main` after a save | consumer removed the export | `delete devApis[key]` (or `devSyncs[key]`) keeps the table consistent with disk state |
324
- | Hot reload picks up a typo, then the next save fixes it | `upsertApiFromFile` runs twice | second run replaces the entry; no orphans |
1
+ # Loader Pipeline (`initializeAll`, `initializeApis`, `initializeSyncs`, `initializeFunctions`, upsert/remove)
2
+
3
+ > Dev-only. `@luckystack/devkit` is published as a `devDependency`. Nothing in this document runs in a production bundle — the prod runtime maps loader in `@luckystack/server` reads from in-memory `devApis`/`devSyncs`/`devFunctions` only when `NODE_ENV !== 'production'`, and from the on-disk generated maps otherwise.
4
+
5
+ The loader pipeline is the dev-time mirror of the production route registry. It walks the configured `srcDir` (from `getProjectConfig().paths`) and each `serverFunctionDirs` root (legacy singular `serverFunctionsDir` still honored when set), evaluates each route file via dynamic `import()`, and populates three in-memory maps:
6
+
7
+ | Map | Shape | Mirror of |
8
+ |---|---|---|
9
+ | `devApis` | `Record<string, { main, auth, rateLimit, httpMethod, schema, inputType, inputTypeFilePath }>` keyed by `api/<page-location\|'system'>/<name>/v<n>` | every `_api/<name>_v<n>.ts` |
10
+ | `devSyncs` | `Record<string, { main, auth, inputType, inputTypeFilePath } \| Function>` keyed by `sync/<page-location>/<name>/v<n>_<server\|client>` | every `_sync/<name>_(server\|client)_v<n>.ts` |
11
+ | `devFunctions` | nested `Record<string, unknown>` mirror of the on-disk tree, leaves are merged named + default exports per file | every `<root>/**/*.ts` under each `serverFunctionDirs` root (legacy singular `serverFunctionsDir` still honored when set) |
12
+
13
+ All three maps are mutated in place. Consumers (the dev server boot path, the prod runtime maps loader's dev branch) hold a stable reference to the exported objects and read them on every request.
14
+
15
+ ---
16
+
17
+ ## `initializeAll()`
18
+
19
+ ```typescript
20
+ export const initializeAll = async () => {
21
+ assertValidRouteNaming({
22
+ srcDir: getSrcDir(),
23
+ context: 'starting dev server (npm run server)',
24
+ });
25
+
26
+ await Promise.all([initializeApis(), initializeSyncs(), initializeFunctions()]);
27
+ };
28
+ ```
29
+
30
+ Order matters:
31
+
32
+ 1. **Route naming validation first.** `assertValidRouteNaming` walks the file tree once and throws if any `_api/` or `_sync/` file doesn't match the version regex (e.g. `_v1.ts`, `_server_v2.ts`). Aborting before any file evaluates means a typo can't produce half-loaded state.
33
+ 2. **Three initializers in parallel** via `Promise.all`. They share the TypeScript program cache (see `ts-program-cache.md`) — running them sequentially would not be faster, since the cache only gets built on first access from inside the extractors.
34
+
35
+ After `initializeAll()` returns, the dev server should call `setupWatchers()` exactly once (see `hot-reload.md`) so subsequent file changes incrementally mutate the same maps.
36
+
37
+ ---
38
+
39
+ ## `initializeApis()`
40
+
41
+ ```typescript
42
+ export const initializeApis = async () => {
43
+ for (const key of Object.keys(devApis)) delete devApis[key];
44
+ //? No invalidateProgramCache() here — cachedProgram starts as null on
45
+ //? module-load (tsProgram.ts), so the first getServerProgram() call
46
+ //? builds it from scratch. With initializeApis + initializeSyncs running
47
+ //? in parallel via Promise.all, invalidating here forced a redundant
48
+ //? double-build (~3-4s waste). Hot-reload paths (upsertApiFromFile,
49
+ //? removeApiFromFile etc.) DO invalidate — that's where it's needed.
50
+ clearRuntimeTypeResolverCache();
51
+ const srcFolder = fs.readdirSync(getSrcDir());
52
+
53
+ for (const file of srcFolder) {
54
+ await scanApiFolder(file);
55
+ }
56
+ };
57
+ ```
58
+
59
+ Flow:
60
+
61
+ 1. Empty `devApis` by deleting every key. The exported reference stays stable.
62
+ 2. **Deliberately skip `invalidateProgramCache()`.** Module-load init in `tsProgram.ts` starts with `cachedProgram = null`, so the first `getServerProgram()` call from inside the extractors will build it from scratch. Calling `invalidateProgramCache()` here used to force a redundant ~3-4s double-build because `initializeApis` and `initializeSyncs` run concurrently and would each invalidate after the other had warmed the cache. The hot-reload upsert/remove paths DO invalidate, because there the cache really is stale.
63
+ 3. Clear the runtime type resolver cache (`clearRuntimeTypeResolverCache()`) so any string-based identifier resolution starts fresh.
64
+ 4. Recurse the project's `srcDir` via `scanApiFolder`. The walker descends through every directory looking for one whose lowercase name ends in `api` (the resolved `apiMarker` from `getRoutingRules()` — default `_api`).
65
+ 5. For each `<name>_v<n>.ts` inside that folder, parse the version with `apiVersionRegex`, import the file, extract `main` / `auth` / `rateLimit` / `httpMethod` / `schema`, derive the inline `inputType` text via `getInputTypeFromFile()`, and store the entry under `api/<pageLocation>/<name>/v<n>`. `pageLocation` is `''` when the API lives at `src/_api/` and is mapped to `'system'` by `mapApiPageLocation()`; otherwise it's the slash-joined path segments above the marker.
66
+
67
+ `auth` is normalized at load time:
68
+
69
+ ```typescript
70
+ auth: {
71
+ login: auth.login || false,
72
+ additional: auth.additional || [],
73
+ }
74
+ ```
75
+
76
+ Missing `main` (or non-function `main`) silently skips the file — the entry is removed and the route key is absent from `devApis`. Filename parse failures log in red to `[loader][api]` and continue.
77
+
78
+ ---
79
+
80
+ ## `initializeSyncs()`
81
+
82
+ Same shape as `initializeApis()`, keyed on folders whose lowercase name ends in `sync` (the resolved `syncMarker`). Each `_sync/<name>_(server|client)_v<n>.ts` is matched by `syncVersionRegex`, yielding the `kind` (`server` or `client`) and version. The route base key is:
83
+
84
+ - `sync/<pageLocation>/<syncName>/v<n>` (when `pageLocation` is non-empty)
85
+ - `sync/<syncName>/v<n>` (root-level sync)
86
+
87
+ Final entry key appends `_server` or `_client`. Storage differs by kind:
88
+
89
+ - **Server kind** stores a record:
90
+ ```typescript
91
+ devSyncs[`${routeBaseKey}_server`] = {
92
+ main: resolvedSyncModule.main,
93
+ auth: resolvedSyncModule.auth || {},
94
+ inputType, // derived via getSyncClientDataType()
95
+ inputTypeFilePath: filePath,
96
+ };
97
+ ```
98
+ - **Client kind** stores the bare `main` callback (the request dispatcher calls it directly with `{ clientOutput, serverOutput }`):
99
+ ```typescript
100
+ devSyncs[`${routeBaseKey}_client`] = resolvedSyncModule.main;
101
+ ```
102
+
103
+ Like `initializeApis()`, the boot path skips `invalidateProgramCache()` and only clears the runtime type resolver cache.
104
+
105
+ ---
106
+
107
+ ## `initializeFunctions()`
108
+
109
+ ```typescript
110
+ export const initializeFunctions = async () => {
111
+ for (const key of Object.keys(devFunctions)) delete devFunctions[key];
112
+
113
+ for (const serverFunctionsDir of getServerFunctionDirs()) {
114
+ if (fs.existsSync(serverFunctionsDir)) {
115
+ await scanFunctionsFolder(serverFunctionsDir);
116
+ }
117
+ }
118
+ };
119
+ ```
120
+
121
+ > `getServerFunctionDirs()` returns the array from `projectConfig.paths.serverFunctionDirs`. The legacy singular `serverFunctionsDir` form is still honored — when set it is merged into the array at config load.
122
+
123
+ Walks every configured root recursively. Per `.ts` file:
124
+
125
+ 1. Import via `importFile()` (see below).
126
+ 2. Run `resolveFunctionModule(module, fileName)`:
127
+ - Module with only named exports -> the named exports object (default merged in only if it's the sole export).
128
+ - Module with no named exports but a default export -> `{ [fileName]: defaultExport }`. This lets `import functionName from './function'` show up at `devFunctions[folder]?.[functionName]?.[functionName]`.
129
+ - Module that is itself a function -> returned as-is so it can be merged.
130
+ 3. Walk into the `devFunctions` tree along the `basePath` segments, creating nested `Record<string, unknown>` subtrees on demand. Each level is structurally a record but typed as `unknown` after one level of indexing — `resolveFunctionModule` re-narrows before descent.
131
+ 4. If a previous scan left a node at the same leaf path, `Object.assign(resolvedFunctionModule, existingAtFileName)` merges them. This is what lets two files in the same folder contribute to the same logical bag of helpers.
132
+
133
+ Failure modes are logged with `[loader][function]` and the file is skipped.
134
+
135
+ ---
136
+
137
+ ## Per-file import strategy
138
+
139
+ ```typescript
140
+ const importFile = async (absolutePath: string) => {
141
+ const url = `${pathToFileURL(absolutePath).href}?v=${Date.now()}`;
142
+ return import(url);
143
+ };
144
+ ```
145
+
146
+ - `pathToFileURL` converts Windows-style paths and special characters into a proper `file://` URL.
147
+ - The `?v=<timestamp>` query is a cachebust that forces the ESM loader to return a fresh evaluation each call. Without it, `import()` is memoized and a saved file would not re-evaluate.
148
+ - Switching from CommonJS `require()` to dynamic `import()` also stops module evaluation from blocking the event loop. With CJS, evaluating a single ~50 ms file held the Socket.io thread; with ESM the evaluation yields and Socket.io stays responsive during a save burst.
149
+
150
+ The `tryCatch` wrapper from `@luckystack/core` captures evaluation errors:
151
+
152
+ ```typescript
153
+ const [err, module] = await tryCatch(async () => importFile(routeMeta.absolutePath));
154
+ if (err) {
155
+ console.log(`[loader][api] failed to import ${routeMeta.routeKey} from ${routeMeta.absolutePath}:`, err, 'red');
156
+ return;
157
+ }
158
+ ```
159
+
160
+ A failed import leaves the existing `devApis` / `devSyncs` entry untouched (no partial replacement).
161
+
162
+ ---
163
+
164
+ ## Route key shape
165
+
166
+ API key:
167
+
168
+ ```
169
+ api/<pageLocation|'system'>/<apiName>/v<n>
170
+ ```
171
+
172
+ `pageLocation` is the slash-joined path segments BEFORE the `_api` marker. `system` is used when the API sits at the project root (e.g. `src/_api/healthCheck_v1.ts` -> `api/system/healthCheck/v1`).
173
+
174
+ Sync key:
175
+
176
+ ```
177
+ sync/<pageLocation>/<syncName>/v<n>_<server|client>
178
+ ```
179
+
180
+ If the sync lives at the project root (no page above the `_sync` marker), the key drops the page segment: `sync/<syncName>/v<n>_<server|client>`.
181
+
182
+ These shapes match the route keys produced by the production runtime maps loader in `@luckystack/server`. Switching between dev and prod is a matter of which map the request dispatcher reads from.
183
+
184
+ ---
185
+
186
+ ## Filename validation regexes
187
+
188
+ ```typescript
189
+ export const API_VERSION_TOKEN_REGEX = /_v(\d+)$/;
190
+ export const SYNC_VERSION_TOKEN_REGEX = /_(server|client)_v(\d+)$/;
191
+ ```
192
+
193
+ Re-exported from `routeConventions.ts` for consumers (e.g. the docs UI) that need to parse filenames.
194
+
195
+ `assertValidRouteNaming({ srcDir, context })` walks the tree and throws an `Error` whose message includes:
196
+
197
+ - The `context` string passed in (e.g. `'starting dev server (npm run server)'` or `'generating API/sync type maps'`).
198
+ - Every offending file path with the expected pattern.
199
+
200
+ `assertNoDuplicateNormalizedRouteKeys({ srcDir, context })` is called only by the type-map generator. It catches the case where two differently-cased files (`getSettings_v1.ts` vs `getsettings_v1.ts`) normalize to the same route key on case-insensitive filesystems.
201
+
202
+ ---
203
+
204
+ ## Routing rules registry
205
+
206
+ ```typescript
207
+ export interface RoutingRules {
208
+ apiMarker: string; // default '_api'
209
+ syncMarker: string; // default '_sync'
210
+ apiVersionRegex: RegExp; // default API_VERSION_TOKEN_REGEX
211
+ syncVersionRegex: RegExp;// default SYNC_VERSION_TOKEN_REGEX
212
+ }
213
+
214
+ export const registerRoutingRules = (rules: Partial<RoutingRules>): void;
215
+ export const getRoutingRules = (): RoutingRules;
216
+ ```
217
+
218
+ Defaults are exported from `routingRules.ts` and consumed by every walker (`scanApiFolder`, `scanSyncFolder`, `resolveApiRouteMetaFromPath`, `resolveSyncRouteMetaFromPath`, the watcher) plus the filename predicates:
219
+
220
+ ```typescript
221
+ export const apiMarkerSegment = (): string;
222
+ export const syncMarkerSegment = (): string;
223
+ export const isApiFileName = (name: string): boolean;
224
+ export const isSyncFileName = (name: string): boolean;
225
+ export const isSyncServerFileName = (name: string): boolean;
226
+ export const isSyncClientFileName = (name: string): boolean;
227
+ ```
228
+
229
+ A consumer registering custom markers (`{ apiMarker: 'api', syncMarker: 'live' }`) sees them flow through to the watcher segment computation in `setupWatchers()` (see `hot-reload.md`) and the loader scans, so non-default layouts work without forking the package.
230
+
231
+ ---
232
+
233
+ ## Hot-reload single-file paths
234
+
235
+ All four hot-reload functions follow the same shape:
236
+
237
+ ```typescript
238
+ export const upsertApiFromFile = async (filePath: string): Promise<void> => {
239
+ const routeMeta = resolveApiRouteMetaFromPath(filePath);
240
+ if (!routeMeta) {
241
+ // log + return — filename rejection
242
+ return;
243
+ }
244
+
245
+ invalidateProgramCache();
246
+ clearRuntimeTypeResolverCache();
247
+
248
+ const [err, module] = await tryCatch(async () => importFile(routeMeta.absolutePath));
249
+ if (err) { /* log + return */ return; }
250
+
251
+ const resolvedModule = module?.default ? { ...module.default, ...module } : module;
252
+ const { auth = {}, main, rateLimit, httpMethod, schema } = resolvedModule;
253
+
254
+ if (!main || typeof main !== 'function') {
255
+ delete devApis[routeMeta.routeKey];
256
+ return;
257
+ }
258
+
259
+ const inputType = getInputTypeFromFile(routeMeta.absolutePath);
260
+
261
+ devApis[routeMeta.routeKey] = { main, auth: { ... }, rateLimit, httpMethod, schema, inputType, inputTypeFilePath: routeMeta.absolutePath };
262
+ };
263
+ ```
264
+
265
+ Key differences from the boot initializers:
266
+
267
+ | Step | Boot (`initializeApis`/`initializeSyncs`) | Hot reload (`upsert*`/`remove*`) |
268
+ |---|---|---|
269
+ | `invalidateProgramCache()` | Skipped — cache is null on module load | Always called — file content changed |
270
+ | `clearRuntimeTypeResolverCache()` | Called | Called |
271
+ | Resolve filename via marker walk | Recursive tree scan | `resolveApiRouteMetaFromPath` / `resolveSyncRouteMetaFromPath` on the single path |
272
+ | Missing `main` | Silently skip | Explicit `delete devApis[key]` to keep table in sync with file state |
273
+
274
+ `removeApiFromFile` / `removeSyncFromFile` follow the same skeleton minus the import: they invalidate caches and `delete` the matching entry. If the file path doesn't normalize to a route key (wrong marker segment, wrong extension), the call is a no-op — the watcher fan-out from `enqueueAffectedRoutesFromDependency` may enqueue paths that aren't routes, and silently dropping them is the right behavior.
275
+
276
+ Sync kind handling in `upsertSyncFromFile`:
277
+
278
+ - `kind === 'server'`: store `{ main, auth, inputType, inputTypeFilePath }` if `main` is a function, otherwise delete the entry.
279
+ - `kind === 'client'`: store the bare `main` callback if it's a function, otherwise delete the entry.
280
+
281
+ ---
282
+
283
+ ## Consumer contract
284
+
285
+ Dev server boot:
286
+
287
+ ```typescript
288
+ import { initializeAll, setupWatchers } from '@luckystack/devkit';
289
+
290
+ await initializeAll();
291
+ setupWatchers();
292
+ // then start HTTP server / Socket.io
293
+ ```
294
+
295
+ Production runtime maps loader (`packages/server/src/runtimeMapsLoader.ts`):
296
+
297
+ ```typescript
298
+ if (process.env.NODE_ENV !== 'production') {
299
+ const devkit = await import('@luckystack/devkit');
300
+ return {
301
+ apis: devkit.devApis,
302
+ syncs: devkit.devSyncs,
303
+ functions: devkit.devFunctions,
304
+ };
305
+ }
306
+
307
+ // prod: read from generated maps on disk
308
+ ```
309
+
310
+ The dev loader is never invoked under `NODE_ENV=production`; the generated `apiTypes.generated.ts` / `apiInputSchemas.generated.ts` (see `type-map-generation.md`) drive the prod path instead.
311
+
312
+ ---
313
+
314
+ ## Failure modes (summary)
315
+
316
+ | Symptom | Cause | What happens |
317
+ |---|---|---|
318
+ | `[loader][api] invalid filename: ...` | filename doesn't match `apiVersionRegex` | file skipped, route key absent from `devApis` |
319
+ | `[loader][sync] invalid filename: ...` | filename doesn't match `syncVersionRegex` | file skipped, route key absent from `devSyncs` |
320
+ | `[loader][api] failed to import ...` | runtime error during `import()` | previous entry left untouched, error logged |
321
+ | `[loader][function] failed to import ...` | runtime error in a server-functions file | previous nested node left untouched |
322
+ | `assertValidRouteNaming` throw at startup | any `_api/`/`_sync/` file fails the regex | dev server boot aborts before any file evaluates |
323
+ | Missing `main` after a save | consumer removed the export | `delete devApis[key]` (or `devSyncs[key]`) keeps the table consistent with disk state |
324
+ | Hot reload picks up a typo, then the next save fixes it | `upsertApiFromFile` runs twice | second run replaces the entry; no orphans |