@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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