@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21

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 (94) hide show
  1. package/README.md +343 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-TsUIjp_x.d.ts +157 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +62 -43
  53. package/dist/ops/index.js +638 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +6 -3
  57. package/dist/route/index.js +97 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/route/types.d.ts +7 -1
  60. package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
  61. package/dist/router-4YoIy5fN.d.ts +172 -0
  62. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  63. package/dist/server/index.d.ts +185 -14
  64. package/dist/server/index.js +883 -78
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/surface/index.d.ts +115 -0
  67. package/dist/surface/index.js +1003 -0
  68. package/dist/surface/index.js.map +1 -0
  69. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  70. package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  71. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  72. package/dist/types-CxhPH4s_.d.ts +375 -0
  73. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  74. package/docs/file-upload.md +195 -333
  75. package/package.json +20 -9
  76. package/src/cache/README.md +330 -0
  77. package/src/codegen/README.md +634 -0
  78. package/src/config/README.md +333 -0
  79. package/src/contract/README.md +440 -0
  80. package/src/db/README.md +589 -0
  81. package/src/db/manager/README.md +500 -0
  82. package/src/db/schema/README.md +354 -0
  83. package/src/db/transaction/README.md +822 -0
  84. package/src/env/README.md +787 -0
  85. package/src/errors/README.md +429 -0
  86. package/src/event/README.md +789 -0
  87. package/src/job/README.md +613 -0
  88. package/src/logger/README.md +321 -0
  89. package/src/middleware/README.md +634 -0
  90. package/src/nextjs/README.md +688 -0
  91. package/src/route/README.md +763 -0
  92. package/src/security/README.md +100 -0
  93. package/src/server/README.md +704 -0
  94. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,634 @@
1
+ # @spfn/core/codegen — Code generation (route-map & custom generators)
2
+
3
+ Pluggable code-generation system with a single shared file watcher. The orchestrator
4
+ runs a list of generators once (build / manual) or continuously (watch). The one built-in
5
+ generator is `@spfn/core:route-map`, which produces the `routeName → {method, path}` map
6
+ the RPC proxy needs.
7
+
8
+ ## Import paths
9
+
10
+ ```typescript
11
+ // Single entry point — everything is exported from here:
12
+ import {
13
+ defineConfig, defineGenerator,
14
+ loadCodegenConfig, createGeneratorsFromConfig,
15
+ CodegenOrchestrator,
16
+ } from '@spfn/core/codegen';
17
+
18
+ import type {
19
+ CodegenConfig, GeneratorConfig,
20
+ Generator, GeneratorOptions, GeneratorTrigger,
21
+ OrchestratorOptions,
22
+ RouteMapGeneratorConfig,
23
+ } from '@spfn/core/codegen';
24
+ ```
25
+
26
+ There is no `@spfn/core/codegen/loader` or other sub-path — everything ships from
27
+ `@spfn/core/codegen`.
28
+
29
+ ---
30
+
31
+ ## Public API (complete)
32
+
33
+ Functions:
34
+
35
+ - `defineConfig(config: CodegenConfig): CodegenConfig` — identity helper for `.spfnrc.ts`.
36
+ - `defineGenerator<T>(config: T): T` — identity helper that carries a generator config's
37
+ type through to the array (so `name`/options are type-checked).
38
+ - `loadCodegenConfig(cwd: string): CodegenConfig` — reads `.spfnrc.ts` → `.spfnrc.json` →
39
+ `package.json` (first hit wins). Returns `{ generators: [] }` if none found.
40
+ - `createGeneratorsFromConfig(config, cwd): Promise<Generator[]>` — resolves each config
41
+ entry into a live `Generator` (loads packages / `.ts` files via jiti).
42
+
43
+ Class:
44
+
45
+ - `CodegenOrchestrator` — `new CodegenOrchestrator(options)`, then `generateAll(trigger?)`,
46
+ `watch()`, `close()`.
47
+
48
+ Types:
49
+
50
+ - `CodegenConfig`, `GeneratorConfig`
51
+ - `Generator`, `GeneratorOptions`, `GeneratorTrigger`
52
+ - `OrchestratorOptions`
53
+ - `RouteMapGeneratorConfig` (config shape for the built-in route-map generator)
54
+ - `ContractGeneratorConfig` (config shape for the built-in contract generator)
55
+ - `ContractGeneratorError`, `ConditionalRegistrationError`, `assertUnconditionalRegistration`
56
+ - `RouteContractMapping`, `ResourceRoutes`, `ClientGenerationOptions`, `GenerationStats`
57
+ (legacy client-generation types — exported but not used by any shipped generator)
58
+
59
+ > **Renamed: `defineCodegenConfig` → `defineConfig`.** The old name does **not** exist.
60
+ > Older docs also show an `api-client` / `createApi`-emitting generator and
61
+ > `codegen.config.ts` — those are **removed**. The built-in generators today are
62
+ > `@spfn/core:route-map` and `@spfn/core:contract`, configured in `.spfnrc.ts`. Do not
63
+ > import `defineCodegenConfig` or configure a `name: 'api-client'` generator.
64
+
65
+ ---
66
+
67
+ ## Quick Start
68
+
69
+ ### 1. Configure `.spfnrc.ts`
70
+
71
+ ```typescript
72
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
73
+
74
+ export default defineConfig({
75
+ generators: [
76
+ defineGenerator({
77
+ name: '@spfn/core:route-map',
78
+ routerPath: './src/server/router.ts',
79
+ outputPath: './src/generated/route-map.ts', // optional (this is the default)
80
+ }),
81
+ ],
82
+ });
83
+ ```
84
+
85
+ ### 2. Generate
86
+
87
+ ```bash
88
+ spfn codegen run # run all generators once
89
+ spfn dev # watch mode (regenerates on file change)
90
+ ```
91
+
92
+ This writes `src/generated/route-map.ts`, which the RPC proxy imports.
93
+
94
+ ---
95
+
96
+ ## Configuration resolution
97
+
98
+ `loadCodegenConfig(cwd)` checks these in order and returns the **first** that exists:
99
+
100
+ 1. `.spfnrc.ts` — loaded with jiti (supports `defineConfig`/`defineGenerator` + TS types).
101
+ The default export (or the module itself) is the `CodegenConfig`.
102
+ 2. `.spfnrc.json` — the config is read from the top-level **`codegen`** key:
103
+ `{ "codegen": { "generators": [...] } }`.
104
+ 3. `package.json` — read from **`spfn.codegen`**: `{ "spfn": { "codegen": { ... } } }`.
105
+
106
+ If none exist (or parsing fails), you get `{ generators: [] }` and nothing runs.
107
+
108
+ > Precedence is "first file found wins", not a deep merge. A `.spfnrc.ts` fully shadows
109
+ > `.spfnrc.json` and `package.json`.
110
+
111
+ ### Generator config entries (`GeneratorConfig`)
112
+
113
+ A `generators[]` entry is one of three shapes:
114
+
115
+ | Shape | Example | Meaning |
116
+ |-------|---------|---------|
117
+ | Package generator | `{ name: 'pkg:gen', enabled?: true, ...opts }` | Loaded from `${pkg}/codegen`. `name` **must** contain `:`. `enabled: false` skips it. Extra keys are passed to the factory. |
118
+ | File generator | `{ path: './src/generators/x.ts' }` | A `.ts`/`.js` file whose default export is a `() => Generator` factory. `.ts` loaded via jiti. |
119
+ | Pre-built instance | `defineGenerator({...})` result that already has a `generate` fn | Pushed as-is (guards against accidentally calling a factory yourself). |
120
+
121
+ For package generators, `name` is split on `:` into `package:generatorName`. The loader
122
+ imports `${package}/codegen` and looks for `generators[generatorName]`, falling back to a
123
+ `create<Name>Generator` export. So `@spfn/core:route-map` → import `@spfn/core/codegen`,
124
+ call `generators['route-map'](config)`.
125
+
126
+ ---
127
+
128
+ ## Built-in: `@spfn/core:route-map`
129
+
130
+ Loads your router and emits a `routeName → {method, path}` map. This map is
131
+ what the **RPC proxy** (`createRpcProxy` in `@spfn/core/nextjs/server`) uses to turn
132
+ `api.getUser.call(...)` into an HTTP request — it needs `method` and `path` without
133
+ importing server code into the client bundle.
134
+
135
+ ### `RouteMapGeneratorConfig`
136
+
137
+ | Field | Type | Required | Default | Description |
138
+ |-------|------|----------|---------|-------------|
139
+ | `name` | `'@spfn/core:route-map'` | yes | — | Generator identifier (literal). |
140
+ | `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
141
+ | `outputPath` | `string` | no | `'./src/generated/route-map.ts'` | Where the map is written (parent dirs auto-created). |
142
+ | `additionalRouteDirs` | `string[]` | no | `[]` | **Deprecated and ignored** for collection; still added to `watchPatterns` as `${dir}/**/*.ts`. |
143
+
144
+ ### What it reads
145
+
146
+ The router module is loaded with jiti (`appRouter` → `default` → `router`) and walked the
147
+ way `registerRoutes` walks it. How the router is *written* does not matter: a one-line
148
+ `defineRouter({ createUser })`, an aliased key `create: createUser`, and a second
149
+ `defineRouter(` in the same file all produce the same map.
150
+
151
+ - A **nested router** recurses to any depth and its routes land in the map **flat, under
152
+ their own keys** — the parent's key names the grouping, not the route, exactly as the
153
+ server registers them.
154
+ - `method` and `path` come from the `RouteDef`, not from a pattern over the source. A
155
+ route not registered in the router is absent, because there is nothing registering it.
156
+ - **`.packages()` routers are carried**, at every depth, provided the router publishes a
157
+ route map (`defineRouter`, not `defineUnmappedRouter`). A package ships a client that
158
+ names its own routes — `authApi.login`, `monitorApi.getStats` — and the app's
159
+ `createRpcProxy` resolves a name in the one map it was constructed with, so the app
160
+ merges nothing by hand: `{ ...routeMap, ...authRouteMap }` is a no-op today. They are
161
+ written after the app's own routes, under a comment line naming where they came from,
162
+ and the header says how many there are and which mount each came through.
163
+ - A name that is not a JS identifier (`'get-user'`) is emitted quoted, and `__proto__` is
164
+ emitted as a computed key, so the generated file parses and the map keeps every route as
165
+ an own property.
166
+ - Imports are resolved by Node **and by the `compilerOptions.paths` of the nearest
167
+ `tsconfig.json` that declares them**, searched from the router file's own directory up to
168
+ the project root, `extends` chains included — so the `@/*` alias every spfn app has
169
+ resolves while the router loads, whether it lives in the root config (a Next.js app) or in
170
+ `src/server/tsconfig.json` (what `spfn init` scaffolds, and the config `spfn build`
171
+ compiles the server with). The search stops at the project root: a tsconfig above it
172
+ belongs to whatever contains the project. A config that declares no `paths` is passed over
173
+ rather than ending the search. `baseUrl` is honoured, so the scaffold's `"baseUrl": "../.."`
174
+ resolves against the project root as tsc resolves it. The failure names the specifier that
175
+ did not resolve.
176
+ - `NODE_ENV` is pinned to `production` when the shell left it unset, so the same router
177
+ does not load two ways depending on how codegen was invoked. A value the shell *did* set
178
+ is left alone.
179
+
180
+ ### When it refuses
181
+
182
+ Unlike the old source parser, which dropped what it could not read, the generator throws
183
+ — logged always, and fatal on the `build` trigger and on `spfn codegen run`:
184
+
185
+ - The module will not load. An import that did not resolve names the specifier; anything
186
+ else names the cause and says a route module must be importable without side effects. It
187
+ never falls back to parsing.
188
+ - No router is found under `appRouter`, `default` or `router` (the message lists all three).
189
+ - The router file registers a route **conditionally** (`...(flag ? { admin } : {})`), which
190
+ would put the flag's value into the map and leave `api.admin.call()` 404ing wherever the
191
+ flag differs. This is the same guard `@spfn/core:contract` uses, and it reads **the router
192
+ the generator was pointed at** — the export the loader found it under, plus the routers
193
+ that export mounts by name from the same file, since a nested router's routes land in the
194
+ same flat map. A second `defineRouter(` the app router never reaches is not scanned: it
195
+ registers nothing in production. The source is read with TypeScript's parser, so a JSDoc
196
+ spelling `defineRouter({ ... })` in prose, a comment holding an old router and a brace
197
+ inside a string are not router source. Only a spread whose expression **reads a
198
+ condition** is refused — a ternary, `&&`, `||`, `??`. A call is allowed
199
+ (`...metadataRoutes(config, resource)`): loading yields exactly the routes it returns.
200
+ Past the guard, by construction: a conditional spread inside an imported module, a router
201
+ built by a factory, a condition hoisted to a variable before the spread, and a
202
+ `.packages()` list assembled conditionally. `NODE_ENV` pinning stands in for the guard on
203
+ those, but only when the shell left it unset: `spfn build` pins `production`, so what ships
204
+ is right, while `spfn dev` sets `development` and the watcher regenerates under it. The
205
+ generated header names the `NODE_ENV` the map was written under, so that difference is a
206
+ line in the diff rather than a route that vanishes at build.
207
+ - Two routes reach the same name (the message names both places).
208
+ - An **app route and a package route** reach the same name, where that package publishes a
209
+ route map. Both register at runtime, and both want the single entry that name has in this
210
+ map — so one of the two paths becomes unreachable while the generated types still
211
+ describe the app's route. **Two package routers** reaching one name is refused too, and
212
+ names both mounts: the generator writes the order now, so the ambiguity is its to refuse.
213
+ The same route reached through two mounts is not two routes — a package exporting one set
214
+ of routes under a router and a sub-router an app mounts beside it hands both mounts the
215
+ same route object, and it is collected once. No first-party package ships that shape today;
216
+ the rule is keyed on object identity so that an app mounting both still builds when one
217
+ does, and the header line of the second mount says which mount got there first.
218
+ A collision with a router that publishes **no** map is not refused — one declared with
219
+ `defineUnmappedRouter`, whose routes are reached by the URL something was handed rather
220
+ than by name: the ops surface (`createOpsRouter`) and the tracking routes of
221
+ `@spfn/notification`. Nothing of theirs is written here, so an app route named after one
222
+ of their routes keeps its own path and must not fail the build.
223
+ - A route's `method` is not an `HttpMethod`. `registerRoutes` lowercases the method before
224
+ registering, so `method: 'get'` serves fine and would emit a map that fails `tsc`.
225
+
226
+ A **missing router file** is still only a warning: the generator returns and writes nothing.
227
+
228
+ **Warned about and left out, not refused**: a route with no method or path (reachable by
229
+ registering a route before `.handler()` was called), and a router entry that is neither a
230
+ route nor a router. `registerRoutes` warns and skips both, so a map without them still names
231
+ every route the server answers — which is all a refusal would have been protecting.
232
+
233
+ A package route with no method or path is skipped the same way, and for the same reason.
234
+ What is refused is only what would write a file that does not compile or that answers to
235
+ the wrong path: a method that is not an `HttpMethod`, and the two collisions above.
236
+
237
+ ### Generated output
238
+
239
+ ```typescript
240
+ // src/generated/route-map.ts — DO NOT EDIT
241
+ import type { HttpMethod } from '@spfn/core/route';
242
+
243
+ export interface RouteInfo
244
+ {
245
+ method: HttpMethod;
246
+ path: string;
247
+ }
248
+
249
+ export const routeMap: Record<string, RouteInfo> = {
250
+ getUser: { method: 'GET', path: '/users/:id' },
251
+ createUser: { method: 'POST', path: '/users' },
252
+ // From the package routers this app mounts with .packages(), not its own:
253
+ login: { method: 'POST', path: '/_auth/login' },
254
+ };
255
+
256
+ export type RouteMap = typeof routeMap;
257
+ export type RouteName = keyof RouteMap;
258
+ ```
259
+
260
+ `watchPatterns` are `[routerPath, 'src/server/routes/**/*.ts', ...additionalRouteDirs]` and
261
+ `runOn` is `['watch', 'manual', 'build']` — every trigger the CLI fires, so it runs in every mode.
262
+
263
+ ---
264
+
265
+ ## Built-in: `@spfn/core:contract`
266
+
267
+ Writes `contracts/current.json` — every route carrying `.contract()`, and with `eventRouterPath`
268
+ every SSE event carrying `.contract()` — and on a **build** compares
269
+ it against the newest released snapshot, refusing changes that would break a client already in the
270
+ field. Full behaviour lives in [`../contract/README.md`](../contract/README.md); this section is the
271
+ generator's own surface.
272
+
273
+ ### `ContractGeneratorConfig`
274
+
275
+ | Field | Type | Required | Default | Description |
276
+ |-------|------|----------|---------|-------------|
277
+ | `name` | `'@spfn/core:contract'` | yes | — | Generator identifier (literal). |
278
+ | `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
279
+ | `routerExport` | `string` | no | `appRouter` → `default` → `router` | Export holding the `defineRouter()` result. |
280
+ | `eventRouterPath` | `string` | no | — | Event router file, relative to project root. Loaded like the router (same `NODE_ENV` pin, same tsconfig aliases) and watched; its contracted events become the document's `events` section. Unset → no section. |
281
+ | `eventRouterExport` | `string` | no | `eventRouter` → `default` | Export holding the `defineEventRouter()` result. |
282
+ | `outputDir` | `string` | no | `'./contracts'` | Holds `current.json`, `released/`, `usage/`. |
283
+ | `additionalRouteDirs` | `string[]` | no | `[]` | Extra route dirs to watch. |
284
+
285
+ ### How it differs from route-map
286
+
287
+ | | `route-map` | `contract` |
288
+ |---|---|---|
289
+ | Reads the router by | loading the module and walking `RouteDef`s | the same |
290
+ | Covers | every registered route | only routes carrying `.contract()` |
291
+ | `runOn` | `watch`, `manual`, `build` | `watch`, `build`, `manual` |
292
+ | Can fail a build | **yes** — any refusal, on every trigger the CLI fails on | **yes**; a broken contract only on the `build` trigger |
293
+
294
+ Loading rather than parsing is what makes the contract correct: real routes build schemas from
295
+ imported values (`EmailSchema`, `FileSchema()`, constants) that a source parser cannot resolve. It
296
+ costs a module import and no infrastructure — `@spfn/auth`'s 43 routes load in ~0.6s with neither
297
+ `DATABASE_URL` nor `CACHE_URL` set.
298
+
299
+ ### When it refuses
300
+
301
+ - The router file is missing, or the module will not load (it names the module and the cause —
302
+ it never skips quietly).
303
+ - No router export is found under the configured or default names.
304
+ - `eventRouterPath` is set and the file is missing, or holds no event router (an object with
305
+ `events` and `eventNames`) under the configured or default names — the message names the
306
+ candidates it tried.
307
+ - The event router contracts events without a router-level `.contract()`, or a contracted event
308
+ has no payload schema, no `since`, or the reserved key `connected` / `ping`.
309
+ - Two contracted routes share a name, or a contracted route has no method, path, `since` or
310
+ `response`.
311
+ - The router the generator was pointed at contains a conditional spread
312
+ (`...(flag ? { route } : {})`), which would make the contract describe whichever way the
313
+ generator happened to run. Same guard as `route-map` — see its bullet for exactly what is
314
+ scanned and what is allowed. The event router file gets the same scan for
315
+ `defineEventRouter({ ...(flag ? { event } : {}) })`.
316
+ - On the `build` trigger only: the contract breaks the newest released snapshot.
317
+
318
+ `spfn dev` generates but never refuses — being unable to hold a half-finished route mid-edit would
319
+ make the feature unusable.
320
+
321
+ ---
322
+
323
+ ## CLI
324
+
325
+ Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
326
+
327
+ ```bash
328
+ spfn codegen init # scaffold a .spfnrc.ts
329
+ spfn codegen list # list resolved generators + their watch patterns (alias: ls)
330
+ spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual'; a failure exits 1)
331
+ spfn dev # dev server + codegen in watch mode
332
+ spfn build # runs codegen once before building (trigger 'build'; a failure exits 1)
333
+
334
+ spfn contract check # regenerate the contract, compare against the newest released snapshot
335
+ spfn contract release X # write contracts/released/X.json
336
+ spfn contract list # released snapshots (alias: ls)
337
+ ```
338
+
339
+ `spfn codegen run` has **no** `--name` flag — it always runs every configured generator.
340
+ (The `spfn init` project scaffold writes a `.spfnrc.ts` preconfigured with the route-map
341
+ generator and adds a `"codegen": "spfn codegen run"` npm script.)
342
+
343
+ `spfn codegen run` **exits 1 when a generator refuses**, and stops at the one that refused
344
+ — the run that just failed to regenerate a map must not look like the run that did. `spfn
345
+ dev` is unaffected: watch mode logs the failure and keeps going, because being unable to
346
+ hold a half-finished route mid-edit would make the feature unusable.
347
+
348
+ ---
349
+
350
+ ## Programmatic usage
351
+
352
+ ```typescript
353
+ import {
354
+ CodegenOrchestrator,
355
+ loadCodegenConfig,
356
+ createGeneratorsFromConfig,
357
+ } from '@spfn/core/codegen';
358
+
359
+ const cwd = process.cwd();
360
+ const config = loadCodegenConfig(cwd);
361
+ const generators = await createGeneratorsFromConfig(config, cwd);
362
+
363
+ const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: true });
364
+
365
+ // Run once. Trigger defaults to 'manual'; only generators whose runOn includes it execute.
366
+ await orchestrator.generateAll(); // 'manual'
367
+ await orchestrator.generateAll('build'); // what `spfn build` dispatches
368
+
369
+ // Watch mode: runs an initial 'watch' pass, then returns a promise that stays pending
370
+ // (keeping the process alive) until close() is called.
371
+ await orchestrator.watch();
372
+ // ...later, to shut down: await orchestrator.close();
373
+ ```
374
+
375
+ ### `OrchestratorOptions`
376
+
377
+ ```typescript
378
+ interface OrchestratorOptions
379
+ {
380
+ generators: Generator[];
381
+ cwd?: string; // default: process.cwd()
382
+ debug?: boolean; // default: false
383
+ throwOnError?: boolean; // default: false
384
+ }
385
+ ```
386
+
387
+ `throwOnError` decides what a generator failure does. Off — the default — it is logged and the
388
+ run continues, which is what keeps watch mode alive through a half-edited file. `spfn build`
389
+ turns it on: a generator that refuses at build time (a broken route contract, a router that will
390
+ not load) has to reach the exit code, or the refusal scrolls past and the build ships anyway.
391
+
392
+ ---
393
+
394
+ ## Custom generators
395
+
396
+ A generator is a plain object implementing the `Generator` interface. Register it by
397
+ `path` (file) or by exporting it from a package's `./codegen` entry.
398
+
399
+ ### File-based
400
+
401
+ ```typescript
402
+ // src/generators/admin-nav-generator.ts
403
+ import type { Generator, GeneratorOptions } from '@spfn/core/codegen';
404
+ import { writeFileSync, mkdirSync } from 'fs';
405
+ import { join, dirname } from 'path';
406
+
407
+ // Default export MUST be a zero-arg factory returning a Generator.
408
+ export default function createAdminNavGenerator(): Generator
409
+ {
410
+ return {
411
+ name: 'admin-nav',
412
+ watchPatterns: ['src/app/admin/**/nav.config.tsx'],
413
+ runOn: ['watch', 'manual', 'build'],
414
+
415
+ async generate(options: GeneratorOptions): Promise<void>
416
+ {
417
+ const out = join(options.cwd, 'src/lib/admin/nav-data.generated.tsx');
418
+ mkdirSync(dirname(out), { recursive: true });
419
+ // ...scan source, write `out`
420
+ },
421
+ };
422
+ }
423
+ ```
424
+
425
+ ```typescript
426
+ // .spfnrc.ts
427
+ import { defineConfig } from '@spfn/core/codegen';
428
+
429
+ export default defineConfig({
430
+ generators: [
431
+ { path: './src/generators/admin-nav-generator.ts' },
432
+ ],
433
+ });
434
+ ```
435
+
436
+ ### Package-based
437
+
438
+ ```typescript
439
+ // my-package/src/codegen/index.ts
440
+ import { createMyGenerator } from './my-generator';
441
+
442
+ // The loader looks up `generators[name]` by the part after the ':'.
443
+ export const generators = {
444
+ 'my-generator': createMyGenerator, // factory: (config) => Generator
445
+ };
446
+ ```
447
+
448
+ ```jsonc
449
+ // my-package/package.json — must expose ./codegen
450
+ { "exports": { "./codegen": { "import": "./dist/codegen/index.js", "types": "./dist/codegen/index.d.ts" } } }
451
+ ```
452
+
453
+ ```typescript
454
+ // consumer .spfnrc.ts
455
+ defineGenerator({ name: 'my-package:my-generator', enabled: true, /* ...opts */ });
456
+ ```
457
+
458
+ The factory receives the config object with `name` and `enabled` stripped out (everything
459
+ else is passed through as options).
460
+
461
+ ### `Generator` interface
462
+
463
+ ```typescript
464
+ type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
465
+
466
+ interface Generator
467
+ {
468
+ name: string;
469
+ watchPatterns: string[]; // globs; orchestrator watches their base dirs
470
+ runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
471
+ generate(options: GeneratorOptions): Promise<void>;
472
+ }
473
+
474
+ interface GeneratorOptions
475
+ {
476
+ cwd: string;
477
+ debug?: boolean;
478
+ trigger?: {
479
+ type: GeneratorTrigger;
480
+ changedFile?: { path: string; event: 'add' | 'change' | 'unlink' }; // watch only
481
+ };
482
+ [key: string]: any;
483
+ }
484
+ ```
485
+
486
+ For incremental rebuilds, check `options.trigger?.changedFile` and fall back to full
487
+ regeneration when it's absent (build/manual passes don't set it).
488
+
489
+ ---
490
+
491
+ ## Pitfalls & anti-patterns
492
+
493
+ - **`defineCodegenConfig` does not exist — use `defineConfig`.** Likewise there is no
494
+ `api-client` built-in generator, no `createApi`-emitting codegen, and no
495
+ `codegen.config.ts`. The current model is `.spfnrc.ts` + `@spfn/core:route-map`. Any doc
496
+ showing those is stale.
497
+ - **`.spfnrc.json` / `package.json` need the wrapper key.** JSON config lives under
498
+ `"codegen"` (in `.spfnrc.json`) or `"spfn": { "codegen": ... }` (in `package.json`). A
499
+ top-level `{ "generators": [...] }` in `.spfnrc.json` is ignored. (A `.spfnrc.ts` default
500
+ export, by contrast, *is* the config directly.)
501
+ - **Config precedence is first-found, not merged.** A `.spfnrc.ts` completely shadows the
502
+ JSON/package configs — they are not combined.
503
+ - **Run codegen before the client builds.** `src/generated/route-map.ts` is committed/built
504
+ output the RPC proxy imports. If it's missing or stale, `api.<route>.call()` resolves the
505
+ wrong (or no) `method`/`path`. `spfn build` runs codegen first; if you build by other
506
+ means, run `spfn codegen run` yourself. Treat the file as generated (it's marked
507
+ `DO NOT EDIT`). **Upgrading a mounted package goes stale the same way and is quieter about
508
+ it**: the map carries that package's routes, but the upgrade changes no app source, so no
509
+ watcher fires and nothing else says the committed map is a version behind — re-run codegen
510
+ after an upgrade as you would after adding a route.
511
+ - **A route must be in `defineRouter({...})` to be emitted.** Defining `export const foo =
512
+ route.get(...)` but not registering `foo` in the router drops it from the map — the
513
+ generator walks the router, so an unregistered route does not exist as far as it is
514
+ concerned. How the registration is *spelled* no longer matters.
515
+ - **route-map loads your router, so route modules must import cleanly.** Reading a
516
+ required environment value at module scope now fails `spfn build` and `spfn codegen run`,
517
+ not just `spfn dev`. Move that read inside the handler. Top-level `await` in a route or
518
+ router module does not load either — jiti transforms to CJS — the same limit
519
+ `@spfn/core:contract` has always had.
520
+ - **A path alias resolves only if a tsconfig between the router and the project root maps
521
+ it.** Both loading generators read `compilerOptions.paths` from the nearest such
522
+ `tsconfig.json` (following `extends`) and hand it to jiti, so `@/server/routes/users`
523
+ resolves from the root config or from `src/server/tsconfig.json`. A mapping that lives
524
+ *above* the project root — a monorepo's solution-style config — is not read; a `paths`
525
+ entry whose `*` is not a trailing `/*` is skipped (jiti matches a prefix, not a template);
526
+ and a `baseUrl` with no `paths` resolves nothing (an alias map cannot say "try this
527
+ directory for every bare specifier"). Several targets for one pattern are supported: the
528
+ first that exists on disk is used, as tsc and tsup use it. Each skip is warned about, an
529
+ `extends` target TypeScript could not read is warned about instead of silently dropping
530
+ its `paths`, and an import that then fails to resolve names the specifier.
531
+ - **Custom generators must default-export a factory.** `createGeneratorsFromConfig` calls
532
+ `module.default()` (a zero-arg function) for `{ path }` entries. Exporting the Generator
533
+ object directly, or a factory that needs args, won't load.
534
+ - **Don't watch your own output.** A generator whose `watchPatterns` match its `outputPath`
535
+ re-triggers itself. Write outputs outside the watched globs (the orchestrator serializes
536
+ runs and queues one pending re-run, but a self-match still loops).
537
+ - **`generate()` should not throw to signal "skip".** The orchestrator catches errors per
538
+ generator (one failure doesn't stop the others), but a throw is logged as a failure.
539
+ Return early instead (route-map logs a warning and returns when the router file is
540
+ absent).
541
+ - **`watch()` never resolves on its own.** It returns a promise that stays pending to keep
542
+ the process alive; call `close()` to resolve it and tear down the chokidar watcher.
543
+ - **Package generators need a `:` in `name`.** `{ name: 'route-map' }` (no colon) is
544
+ rejected as an invalid name — it must be `'@spfn/core:route-map'`.
545
+
546
+ ---
547
+
548
+ ## Complete example
549
+
550
+ ```typescript
551
+ // .spfnrc.ts
552
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
553
+
554
+ export default defineConfig({
555
+ generators: [
556
+ // Built-in: route-map for the RPC proxy
557
+ defineGenerator({
558
+ name: '@spfn/core:route-map',
559
+ routerPath: './src/server/router.ts',
560
+ outputPath: './src/generated/route-map.ts',
561
+ }),
562
+ // A project-local custom generator
563
+ { path: './src/generators/admin-nav-generator.ts' },
564
+ ],
565
+ });
566
+ ```
567
+
568
+ ```typescript
569
+ // src/server/router.ts
570
+ import { defineRouter } from '@spfn/core/route';
571
+ import { getUser, createUser } from './routes/users';
572
+
573
+ export const router = defineRouter({
574
+ getUser,
575
+ createUser,
576
+ });
577
+ ```
578
+
579
+ ```typescript
580
+ // src/server/routes/users.ts
581
+ import { route } from '@spfn/core/route';
582
+
583
+ export const getUser = route.get('/users/:id')/* ...contract/handler */;
584
+ export const createUser = route.post('/users')/* ...contract/handler */;
585
+ ```
586
+
587
+ ```typescript
588
+ // app/api/rpc/[routeName]/route.ts — RPC proxy consumes the generated map
589
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
590
+ import { routeMap } from '@/generated/route-map';
591
+
592
+ // routeMap (from src/generated/route-map.ts) lets the proxy resolve method + path
593
+ export const { GET, POST } = createRpcProxy({ routeMap });
594
+ ```
595
+
596
+ ```bash
597
+ spfn codegen run # writes src/generated/route-map.ts
598
+ ```
599
+
600
+ ---
601
+
602
+ ## Types reference
603
+
604
+ ```typescript
605
+ interface CodegenConfig { generators?: GeneratorConfig[]; }
606
+
607
+ type GeneratorConfig =
608
+ | { path: string } // file-based
609
+ | ({ name: string; enabled?: boolean } & Record<string, any>); // package-based
610
+
611
+ type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
612
+
613
+ interface Generator
614
+ {
615
+ name: string;
616
+ watchPatterns: string[];
617
+ runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
618
+ generate(options: GeneratorOptions): Promise<void>;
619
+ }
620
+
621
+ interface RouteMapGeneratorConfig
622
+ {
623
+ name: '@spfn/core:route-map';
624
+ routerPath: string;
625
+ outputPath?: string; // default './src/generated/route-map.ts'
626
+ additionalRouteDirs?: string[]; // deprecated: watched, never collected from
627
+ }
628
+ ```
629
+
630
+ ## Related
631
+
632
+ - [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the router loaded and walked)
633
+ - [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
634
+ - [@spfn/core/env](../env/README.md) — environment configuration