@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

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.
@@ -0,0 +1,516 @@
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
+ Parses your router + route files 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 | `[]` | Extra route dirs to watch (each becomes `${dir}/**/*.ts`). |
143
+
144
+ ### What it parses
145
+
146
+ - **Router file** (`routerPath`): relative `import { ... } from './...'` statements, and the
147
+ route names listed inside the **first** `defineRouter({ ... })` call (top-level
148
+ identifiers, comments stripped).
149
+ - **Route files**: only the exact pattern
150
+ `export const <name> = route.<get|post|put|patch|delete>('<path>')…` (single/double/back
151
+ quotes). The leading `route.<method>('<path>')` call is what's matched.
152
+ - Only routes whose `name` also appears in `defineRouter({...})` end up in the output
153
+ (declared-but-unregistered routes are dropped).
154
+
155
+ ### Generated output
156
+
157
+ ```typescript
158
+ // src/generated/route-map.ts — DO NOT EDIT
159
+ import type { HttpMethod } from '@spfn/core/route';
160
+
161
+ export interface RouteInfo
162
+ {
163
+ method: HttpMethod;
164
+ path: string;
165
+ }
166
+
167
+ export const routeMap: Record<string, RouteInfo> = {
168
+ getUser: { method: 'GET', path: '/users/:id' },
169
+ createUser: { method: 'POST', path: '/users' },
170
+ };
171
+
172
+ export type RouteMap = typeof routeMap;
173
+ export type RouteName = keyof RouteMap;
174
+ ```
175
+
176
+ `watchPatterns` are `[routerPath, 'src/server/routes/**/*.ts', ...additionalRouteDirs]` and
177
+ `runOn` is `['watch', 'manual', 'build']` — every trigger the CLI fires, so it runs in every mode.
178
+
179
+ ---
180
+
181
+ ## Built-in: `@spfn/core:contract`
182
+
183
+ Writes `contracts/current.json` — every route carrying `.contract()` — and on a **build** compares
184
+ it against the newest released snapshot, refusing changes that would break a client already in the
185
+ field. Full behaviour lives in [`../contract/README.md`](../contract/README.md); this section is the
186
+ generator's own surface.
187
+
188
+ ### `ContractGeneratorConfig`
189
+
190
+ | Field | Type | Required | Default | Description |
191
+ |-------|------|----------|---------|-------------|
192
+ | `name` | `'@spfn/core:contract'` | yes | — | Generator identifier (literal). |
193
+ | `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
194
+ | `routerExport` | `string` | no | `appRouter` → `default` → `router` | Export holding the `defineRouter()` result. |
195
+ | `outputDir` | `string` | no | `'./contracts'` | Holds `current.json`, `released/`, `usage/`. |
196
+ | `additionalRouteDirs` | `string[]` | no | `[]` | Extra route dirs to watch. |
197
+
198
+ ### How it differs from route-map
199
+
200
+ | | `route-map` | `contract` |
201
+ |---|---|---|
202
+ | Reads the router by | parsing the source | **loading the module** and walking `RouteDef`s |
203
+ | Covers | every registered route | only routes carrying `.contract()` |
204
+ | `runOn` | `watch`, `manual`, `build` | `watch`, `build`, `manual` |
205
+ | Can fail a build | no | **yes**, on the `build` trigger only |
206
+
207
+ Loading rather than parsing is what makes the contract correct: real routes build schemas from
208
+ imported values (`EmailSchema`, `FileSchema()`, constants) that a source parser cannot resolve. It
209
+ costs a module import and no infrastructure — `@spfn/auth`'s 43 routes load in ~0.6s with neither
210
+ `DATABASE_URL` nor `CACHE_URL` set.
211
+
212
+ ### When it refuses
213
+
214
+ - The router file is missing, or the module will not load (it names the module and the cause —
215
+ it never skips quietly).
216
+ - No router export is found under the configured or default names.
217
+ - Two contracted routes share a name, or a contracted route has no method, path, `since` or
218
+ `response`.
219
+ - `defineRouter({...})` contains a computed spread (`...(flag ? { route } : {})`), which would make
220
+ the contract describe whichever way the generator happened to run.
221
+ - On the `build` trigger only: the contract breaks the newest released snapshot.
222
+
223
+ `spfn dev` generates but never refuses — being unable to hold a half-finished route mid-edit would
224
+ make the feature unusable.
225
+
226
+ ---
227
+
228
+ ## CLI
229
+
230
+ Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
231
+
232
+ ```bash
233
+ spfn codegen init # scaffold a .spfnrc.ts
234
+ spfn codegen list # list resolved generators + their watch patterns (alias: ls)
235
+ spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual')
236
+ spfn dev # dev server + codegen in watch mode
237
+ spfn build # runs codegen once before building (trigger 'build'; a failure exits 1)
238
+
239
+ spfn contract check # regenerate the contract, compare against the newest released snapshot
240
+ spfn contract release X # write contracts/released/X.json
241
+ spfn contract list # released snapshots (alias: ls)
242
+ ```
243
+
244
+ `spfn codegen run` has **no** `--name` flag — it always runs every configured generator.
245
+ (The `spfn init` project scaffold writes a `.spfnrc.ts` preconfigured with the route-map
246
+ generator and adds a `"codegen": "spfn codegen run"` npm script.)
247
+
248
+ ---
249
+
250
+ ## Programmatic usage
251
+
252
+ ```typescript
253
+ import {
254
+ CodegenOrchestrator,
255
+ loadCodegenConfig,
256
+ createGeneratorsFromConfig,
257
+ } from '@spfn/core/codegen';
258
+
259
+ const cwd = process.cwd();
260
+ const config = loadCodegenConfig(cwd);
261
+ const generators = await createGeneratorsFromConfig(config, cwd);
262
+
263
+ const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: true });
264
+
265
+ // Run once. Trigger defaults to 'manual'; only generators whose runOn includes it execute.
266
+ await orchestrator.generateAll(); // 'manual'
267
+ await orchestrator.generateAll('build'); // what `spfn build` dispatches
268
+
269
+ // Watch mode: runs an initial 'watch' pass, then returns a promise that stays pending
270
+ // (keeping the process alive) until close() is called.
271
+ await orchestrator.watch();
272
+ // ...later, to shut down: await orchestrator.close();
273
+ ```
274
+
275
+ ### `OrchestratorOptions`
276
+
277
+ ```typescript
278
+ interface OrchestratorOptions
279
+ {
280
+ generators: Generator[];
281
+ cwd?: string; // default: process.cwd()
282
+ debug?: boolean; // default: false
283
+ throwOnError?: boolean; // default: false
284
+ }
285
+ ```
286
+
287
+ `throwOnError` decides what a generator failure does. Off — the default — it is logged and the
288
+ run continues, which is what keeps watch mode alive through a half-edited file. `spfn build`
289
+ turns it on: a generator that refuses at build time (a broken route contract, a router that will
290
+ not load) has to reach the exit code, or the refusal scrolls past and the build ships anyway.
291
+
292
+ ---
293
+
294
+ ## Custom generators
295
+
296
+ A generator is a plain object implementing the `Generator` interface. Register it by
297
+ `path` (file) or by exporting it from a package's `./codegen` entry.
298
+
299
+ ### File-based
300
+
301
+ ```typescript
302
+ // src/generators/admin-nav-generator.ts
303
+ import type { Generator, GeneratorOptions } from '@spfn/core/codegen';
304
+ import { writeFileSync, mkdirSync } from 'fs';
305
+ import { join, dirname } from 'path';
306
+
307
+ // Default export MUST be a zero-arg factory returning a Generator.
308
+ export default function createAdminNavGenerator(): Generator
309
+ {
310
+ return {
311
+ name: 'admin-nav',
312
+ watchPatterns: ['src/app/admin/**/nav.config.tsx'],
313
+ runOn: ['watch', 'manual', 'build'],
314
+
315
+ async generate(options: GeneratorOptions): Promise<void>
316
+ {
317
+ const out = join(options.cwd, 'src/lib/admin/nav-data.generated.tsx');
318
+ mkdirSync(dirname(out), { recursive: true });
319
+ // ...scan source, write `out`
320
+ },
321
+ };
322
+ }
323
+ ```
324
+
325
+ ```typescript
326
+ // .spfnrc.ts
327
+ import { defineConfig } from '@spfn/core/codegen';
328
+
329
+ export default defineConfig({
330
+ generators: [
331
+ { path: './src/generators/admin-nav-generator.ts' },
332
+ ],
333
+ });
334
+ ```
335
+
336
+ ### Package-based
337
+
338
+ ```typescript
339
+ // my-package/src/codegen/index.ts
340
+ import { createMyGenerator } from './my-generator';
341
+
342
+ // The loader looks up `generators[name]` by the part after the ':'.
343
+ export const generators = {
344
+ 'my-generator': createMyGenerator, // factory: (config) => Generator
345
+ };
346
+ ```
347
+
348
+ ```jsonc
349
+ // my-package/package.json — must expose ./codegen
350
+ { "exports": { "./codegen": { "import": "./dist/codegen/index.js", "types": "./dist/codegen/index.d.ts" } } }
351
+ ```
352
+
353
+ ```typescript
354
+ // consumer .spfnrc.ts
355
+ defineGenerator({ name: 'my-package:my-generator', enabled: true, /* ...opts */ });
356
+ ```
357
+
358
+ The factory receives the config object with `name` and `enabled` stripped out (everything
359
+ else is passed through as options).
360
+
361
+ ### `Generator` interface
362
+
363
+ ```typescript
364
+ type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
365
+
366
+ interface Generator
367
+ {
368
+ name: string;
369
+ watchPatterns: string[]; // globs; orchestrator watches their base dirs
370
+ runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
371
+ generate(options: GeneratorOptions): Promise<void>;
372
+ }
373
+
374
+ interface GeneratorOptions
375
+ {
376
+ cwd: string;
377
+ debug?: boolean;
378
+ trigger?: {
379
+ type: GeneratorTrigger;
380
+ changedFile?: { path: string; event: 'add' | 'change' | 'unlink' }; // watch only
381
+ };
382
+ [key: string]: any;
383
+ }
384
+ ```
385
+
386
+ For incremental rebuilds, check `options.trigger?.changedFile` and fall back to full
387
+ regeneration when it's absent (build/manual passes don't set it).
388
+
389
+ ---
390
+
391
+ ## Pitfalls & anti-patterns
392
+
393
+ - **`defineCodegenConfig` does not exist — use `defineConfig`.** Likewise there is no
394
+ `api-client` built-in generator, no `createApi`-emitting codegen, and no
395
+ `codegen.config.ts`. The current model is `.spfnrc.ts` + `@spfn/core:route-map`. Any doc
396
+ showing those is stale.
397
+ - **`.spfnrc.json` / `package.json` need the wrapper key.** JSON config lives under
398
+ `"codegen"` (in `.spfnrc.json`) or `"spfn": { "codegen": ... }` (in `package.json`). A
399
+ top-level `{ "generators": [...] }` in `.spfnrc.json` is ignored. (A `.spfnrc.ts` default
400
+ export, by contrast, *is* the config directly.)
401
+ - **Config precedence is first-found, not merged.** A `.spfnrc.ts` completely shadows the
402
+ JSON/package configs — they are not combined.
403
+ - **Run codegen before the client builds.** `src/generated/route-map.ts` is committed/built
404
+ output the RPC proxy imports. If it's missing or stale, `api.<route>.call()` resolves the
405
+ wrong (or no) `method`/`path`. `spfn build` runs codegen first; if you build by other
406
+ means, run `spfn codegen run` yourself. Treat the file as generated (it's marked
407
+ `DO NOT EDIT`).
408
+ - **route-map only matches a specific route syntax.** Routes must be
409
+ `export const x = route.<method>('<literal path>')`. Dynamic paths, methods other than
410
+ get/post/put/patch/delete, or routes not listed in `defineRouter({...})` won't appear.
411
+ - **A route must be in `defineRouter({...})` to be emitted.** Defining `export const foo =
412
+ route.get(...)` but not registering `foo` in the router drops it from the map.
413
+ - **Custom generators must default-export a factory.** `createGeneratorsFromConfig` calls
414
+ `module.default()` (a zero-arg function) for `{ path }` entries. Exporting the Generator
415
+ object directly, or a factory that needs args, won't load.
416
+ - **Don't watch your own output.** A generator whose `watchPatterns` match its `outputPath`
417
+ re-triggers itself. Write outputs outside the watched globs (the orchestrator serializes
418
+ runs and queues one pending re-run, but a self-match still loops).
419
+ - **`generate()` should not throw to signal "skip".** The orchestrator catches errors per
420
+ generator (one failure doesn't stop the others), but a throw is logged as a failure.
421
+ Return early instead (route-map logs a warning and returns when the router file is
422
+ absent).
423
+ - **`watch()` never resolves on its own.** It returns a promise that stays pending to keep
424
+ the process alive; call `close()` to resolve it and tear down the chokidar watcher.
425
+ - **Package generators need a `:` in `name`.** `{ name: 'route-map' }` (no colon) is
426
+ rejected as an invalid name — it must be `'@spfn/core:route-map'`.
427
+
428
+ ---
429
+
430
+ ## Complete example
431
+
432
+ ```typescript
433
+ // .spfnrc.ts
434
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
435
+
436
+ export default defineConfig({
437
+ generators: [
438
+ // Built-in: route-map for the RPC proxy
439
+ defineGenerator({
440
+ name: '@spfn/core:route-map',
441
+ routerPath: './src/server/router.ts',
442
+ outputPath: './src/generated/route-map.ts',
443
+ }),
444
+ // A project-local custom generator
445
+ { path: './src/generators/admin-nav-generator.ts' },
446
+ ],
447
+ });
448
+ ```
449
+
450
+ ```typescript
451
+ // src/server/router.ts
452
+ import { defineRouter } from '@spfn/core/route';
453
+ import { getUser, createUser } from './routes/users';
454
+
455
+ export const router = defineRouter({
456
+ getUser,
457
+ createUser,
458
+ });
459
+ ```
460
+
461
+ ```typescript
462
+ // src/server/routes/users.ts
463
+ import { route } from '@spfn/core/route';
464
+
465
+ export const getUser = route.get('/users/:id')/* ...contract/handler */;
466
+ export const createUser = route.post('/users')/* ...contract/handler */;
467
+ ```
468
+
469
+ ```typescript
470
+ // app/api/rpc/[routeName]/route.ts — RPC proxy consumes the generated map
471
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
472
+ import { routeMap } from '@/generated/route-map';
473
+
474
+ // routeMap (from src/generated/route-map.ts) lets the proxy resolve method + path
475
+ export const { GET, POST } = createRpcProxy({ routeMap });
476
+ ```
477
+
478
+ ```bash
479
+ spfn codegen run # writes src/generated/route-map.ts
480
+ ```
481
+
482
+ ---
483
+
484
+ ## Types reference
485
+
486
+ ```typescript
487
+ interface CodegenConfig { generators?: GeneratorConfig[]; }
488
+
489
+ type GeneratorConfig =
490
+ | { path: string } // file-based
491
+ | ({ name: string; enabled?: boolean } & Record<string, any>); // package-based
492
+
493
+ type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
494
+
495
+ interface Generator
496
+ {
497
+ name: string;
498
+ watchPatterns: string[];
499
+ runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
500
+ generate(options: GeneratorOptions): Promise<void>;
501
+ }
502
+
503
+ interface RouteMapGeneratorConfig
504
+ {
505
+ name: '@spfn/core:route-map';
506
+ routerPath: string;
507
+ outputPath?: string; // default './src/generated/route-map.ts'
508
+ additionalRouteDirs?: string[];
509
+ }
510
+ ```
511
+
512
+ ## Related
513
+
514
+ - [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the source parsed)
515
+ - [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
516
+ - [@spfn/core/env](../env/README.md) — environment configuration