@spfn/core/codegen
Pluggable code-generation system with a single shared file watcher. The orchestrator
runs a list of generators once (build / manual) or continuously (watch). The one built-in
generator is @spfn/core:route-map, which produces the routeName → {method, path} map
the RPC proxy needs.
Import paths
// Single entry point — everything is exported from here:
import {
defineConfig, defineGenerator,
loadCodegenConfig, createGeneratorsFromConfig,
CodegenOrchestrator,
} from '@spfn/core/codegen';
import type {
CodegenConfig, GeneratorConfig,
Generator, GeneratorOptions, GeneratorTrigger,
OrchestratorOptions,
RouteMapGeneratorConfig,
} from '@spfn/core/codegen';
There is no @spfn/core/codegen/loader or other sub-path — everything ships from
@spfn/core/codegen.
Public API (complete)
Functions:
defineConfig(config: CodegenConfig): CodegenConfig— identity helper for.spfnrc.ts.defineGenerator<T>(config: T): T— identity helper that carries a generator config's type through to the array (soname/options are type-checked).loadCodegenConfig(cwd: string): CodegenConfig— reads.spfnrc.ts→.spfnrc.json→package.json(first hit wins). Returns{ generators: [] }if none found.createGeneratorsFromConfig(config, cwd): Promise<Generator[]>— resolves each config entry into a liveGenerator(loads packages /.tsfiles via jiti).
Class:
CodegenOrchestrator—new CodegenOrchestrator(options), thengenerateAll(trigger?),watch(),close().
Types:
CodegenConfig,GeneratorConfigGenerator,GeneratorOptions,GeneratorTriggerOrchestratorOptionsRouteMapGeneratorConfig(config shape for the built-in route-map generator)RouteContractMapping,ResourceRoutes,ClientGenerationOptions,GenerationStats(legacy client-generation types — exported but not used by any shipped generator)
Renamed:
defineCodegenConfig→defineConfig. The old name does not exist. Older docs also show anapi-client/createApi-emitting generator andcodegen.config.ts— those are removed. The only built-in generator today is@spfn/core:route-map, configured in.spfnrc.ts. Do not importdefineCodegenConfigor configure aname: 'api-client'generator.
Quick Start
1. Configure .spfnrc.ts
import { defineConfig, defineGenerator } from '@spfn/core/codegen';
export default defineConfig({
generators: [
defineGenerator({
name: '@spfn/core:route-map',
routerPath: './src/server/router.ts',
outputPath: './src/generated/route-map.ts', // optional (this is the default)
}),
],
});
2. Generate
spfn codegen run # run all generators once
spfn dev # watch mode (regenerates on file change)
This writes src/generated/route-map.ts, which the RPC proxy imports.
Configuration resolution
loadCodegenConfig(cwd) checks these in order and returns the first that exists:
.spfnrc.ts— loaded with jiti (supportsdefineConfig/defineGenerator+ TS types). The default export (or the module itself) is theCodegenConfig..spfnrc.json— the config is read from the top-levelcodegenkey:{ "codegen": { "generators": [...] } }.package.json— read fromspfn.codegen:{ "spfn": { "codegen": { ... } } }.
If none exist (or parsing fails), you get { generators: [] } and nothing runs.
Precedence is "first file found wins", not a deep merge. A
.spfnrc.tsfully shadows.spfnrc.jsonandpackage.json.
Generator config entries (GeneratorConfig)
A generators[] entry is one of three shapes:
| Shape | Example | Meaning |
|---|---|---|
| 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. |
| File generator | { path: './src/generators/x.ts' } |
A .ts/.js file whose default export is a () => Generator factory. .ts loaded via jiti. |
| Pre-built instance | defineGenerator({...}) result that already has a generate fn |
Pushed as-is (guards against accidentally calling a factory yourself). |
For package generators, name is split on : into package:generatorName. The loader
imports ${package}/codegen and looks for generators[generatorName], falling back to a
create<Name>Generator export. So @spfn/core:route-map → import @spfn/core/codegen,
call generators['route-map'](config).
Built-in: @spfn/core:route-map
Parses your router + route files and emits a routeName → {method, path} map. This map is
what the RPC proxy (createRpcProxy in @spfn/core/nextjs/server) uses to turn
api.getUser.call(...) into an HTTP request — it needs method and path without
importing server code into the client bundle.
RouteMapGeneratorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
'@spfn/core:route-map' |
yes | — | Generator identifier (literal). |
routerPath |
string |
yes | — | Router file, relative to project root. Throws at construction if missing. |
outputPath |
string |
no | './src/generated/route-map.ts' |
Where the map is written (parent dirs auto-created). |
additionalRouteDirs |
string[] |
no | [] |
Extra route dirs to watch (each becomes ${dir}/**/*.ts). |
What it parses
- Router file (
routerPath): relativeimport { ... } from './...'statements, and the route names listed inside the firstdefineRouter({ ... })call (top-level identifiers, comments stripped). - Route files: only the exact pattern
export const <name> = route.<get|post|put|patch|delete>('<path>')…(single/double/back quotes). The leadingroute.<method>('<path>')call is what's matched. - Only routes whose
namealso appears indefineRouter({...})end up in the output (declared-but-unregistered routes are dropped).
Generated output
// src/generated/route-map.ts — DO NOT EDIT
import type { HttpMethod } from '@spfn/core/route';
export interface RouteInfo
{
method: HttpMethod;
path: string;
}
export const routeMap: Record<string, RouteInfo> = {
getUser: { method: 'GET', path: '/users/:id' },
createUser: { method: 'POST', path: '/users' },
};
export type RouteMap = typeof routeMap;
export type RouteName = keyof RouteMap;
watchPatterns are [routerPath, 'src/server/routes/**/*.ts', ...additionalRouteDirs] and
runOn is ['watch', 'build', 'start', 'manual'] (runs in every mode).
CLI
Provided by the spfn CLI (@spfn/cli), not by @spfn/core itself:
spfn codegen init # scaffold a .spfnrc.ts
spfn codegen list # list resolved generators + their watch patterns (alias: ls)
spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual')
spfn dev # dev server + codegen in watch mode
spfn build # runs codegen once before building
spfn codegen run has no --name flag — it always runs every configured generator.
(The spfn init project scaffold writes a .spfnrc.ts preconfigured with the route-map
generator and adds a "codegen": "spfn codegen run" npm script.)
Programmatic usage
import {
CodegenOrchestrator,
loadCodegenConfig,
createGeneratorsFromConfig,
} from '@spfn/core/codegen';
const cwd = process.cwd();
const config = loadCodegenConfig(cwd);
const generators = await createGeneratorsFromConfig(config, cwd);
const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: true });
// Run once. Trigger defaults to 'manual'; only generators whose runOn includes it execute.
await orchestrator.generateAll(); // 'manual'
await orchestrator.generateAll('build'); // e.g. build-time
// Watch mode: runs an initial 'watch' pass, then returns a promise that stays pending
// (keeping the process alive) until close() is called.
await orchestrator.watch();
// ...later, to shut down: await orchestrator.close();
OrchestratorOptions
interface OrchestratorOptions
{
generators: Generator[];
cwd?: string; // default: process.cwd()
debug?: boolean; // default: false
}
Custom generators
A generator is a plain object implementing the Generator interface. Register it by
path (file) or by exporting it from a package's ./codegen entry.
File-based
// src/generators/admin-nav-generator.ts
import type { Generator, GeneratorOptions } from '@spfn/core/codegen';
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
// Default export MUST be a zero-arg factory returning a Generator.
export default function createAdminNavGenerator(): Generator
{
return {
name: 'admin-nav',
watchPatterns: ['src/app/admin/**/nav.config.tsx'],
runOn: ['watch', 'build'],
async generate(options: GeneratorOptions): Promise<void>
{
const out = join(options.cwd, 'src/lib/admin/nav-data.generated.tsx');
mkdirSync(dirname(out), { recursive: true });
// ...scan source, write `out`
},
};
}
// .spfnrc.ts
import { defineConfig } from '@spfn/core/codegen';
export default defineConfig({
generators: [
{ path: './src/generators/admin-nav-generator.ts' },
],
});
Package-based
// my-package/src/codegen/index.ts
import { createMyGenerator } from './my-generator';
// The loader looks up `generators[name]` by the part after the ':'.
export const generators = {
'my-generator': createMyGenerator, // factory: (config) => Generator
};
// my-package/package.json — must expose ./codegen
{ "exports": { "./codegen": { "import": "./dist/codegen/index.js", "types": "./dist/codegen/index.d.ts" } } }
// consumer .spfnrc.ts
defineGenerator({ name: 'my-package:my-generator', enabled: true, /* ...opts */ });
The factory receives the config object with name and enabled stripped out (everything
else is passed through as options).
Generator interface
type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
interface Generator
{
name: string;
watchPatterns: string[]; // globs; orchestrator watches their base dirs
runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
generate(options: GeneratorOptions): Promise<void>;
}
interface GeneratorOptions
{
cwd: string;
debug?: boolean;
trigger?: {
type: GeneratorTrigger;
changedFile?: { path: string; event: 'add' | 'change' | 'unlink' }; // watch only
};
[key: string]: any;
}
For incremental rebuilds, check options.trigger?.changedFile and fall back to full
regeneration when it's absent (build/manual passes don't set it).
Pitfalls & anti-patterns
defineCodegenConfigdoes not exist — usedefineConfig. Likewise there is noapi-clientbuilt-in generator, nocreateApi-emitting codegen, and nocodegen.config.ts. The current model is.spfnrc.ts+@spfn/core:route-map. Any doc showing those is stale..spfnrc.json/package.jsonneed the wrapper key. JSON config lives under"codegen"(in.spfnrc.json) or"spfn": { "codegen": ... }(inpackage.json). A top-level{ "generators": [...] }in.spfnrc.jsonis ignored. (A.spfnrc.tsdefault export, by contrast, is the config directly.)- Config precedence is first-found, not merged. A
.spfnrc.tscompletely shadows the JSON/package configs — they are not combined. - Run codegen before the client builds.
src/generated/route-map.tsis committed/built output the RPC proxy imports. If it's missing or stale,api.<route>.call()resolves the wrong (or no)method/path.spfn buildruns codegen first; if you build by other means, runspfn codegen runyourself. Treat the file as generated (it's markedDO NOT EDIT). - route-map only matches a specific route syntax. Routes must be
export const x = route.<method>('<literal path>'). Dynamic paths, methods other than get/post/put/patch/delete, or routes not listed indefineRouter({...})won't appear. - A route must be in
defineRouter({...})to be emitted. Definingexport const foo = route.get(...)but not registeringfooin the router drops it from the map. - Custom generators must default-export a factory.
createGeneratorsFromConfigcallsmodule.default()(a zero-arg function) for{ path }entries. Exporting the Generator object directly, or a factory that needs args, won't load. - Don't watch your own output. A generator whose
watchPatternsmatch itsoutputPathre-triggers itself. Write outputs outside the watched globs (the orchestrator serializes runs and queues one pending re-run, but a self-match still loops). generate()should not throw to signal "skip". The orchestrator catches errors per generator (one failure doesn't stop the others), but a throw is logged as a failure. Return early instead (route-map logs a warning and returns when the router file is absent).watch()never resolves on its own. It returns a promise that stays pending to keep the process alive; callclose()to resolve it and tear down the chokidar watcher.- Package generators need a
:inname.{ name: 'route-map' }(no colon) is rejected as an invalid name — it must be'@spfn/core:route-map'.
Complete example
// .spfnrc.ts
import { defineConfig, defineGenerator } from '@spfn/core/codegen';
export default defineConfig({
generators: [
// Built-in: route-map for the RPC proxy
defineGenerator({
name: '@spfn/core:route-map',
routerPath: './src/server/router.ts',
outputPath: './src/generated/route-map.ts',
}),
// A project-local custom generator
{ path: './src/generators/admin-nav-generator.ts' },
],
});
// src/server/router.ts
import { defineRouter } from '@spfn/core/route';
import { getUser, createUser } from './routes/users';
export const router = defineRouter({
getUser,
createUser,
});
// src/server/routes/users.ts
import { route } from '@spfn/core/route';
export const getUser = route.get('/users/:id')/* ...contract/handler */;
export const createUser = route.post('/users')/* ...contract/handler */;
// app/api/rpc/[routeName]/route.ts — RPC proxy consumes the generated map
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { routeMap } from '@/generated/route-map';
// routeMap (from src/generated/route-map.ts) lets the proxy resolve method + path
export const { GET, POST } = createRpcProxy({ routeMap });
spfn codegen run # writes src/generated/route-map.ts
Types reference
interface CodegenConfig { generators?: GeneratorConfig[]; }
type GeneratorConfig =
| { path: string } // file-based
| ({ name: string; enabled?: boolean } & Record<string, any>); // package-based
type GeneratorTrigger = 'watch' | 'manual' | 'build' | 'start';
interface Generator
{
name: string;
watchPatterns: string[];
runOn?: GeneratorTrigger[]; // default ['watch', 'manual', 'build']
generate(options: GeneratorOptions): Promise<void>;
}
interface RouteMapGeneratorConfig
{
name: '@spfn/core:route-map';
routerPath: string;
outputPath?: string; // default './src/generated/route-map.ts'
additionalRouteDirs?: string[];
}
Related
- @spfn/core/route —
route.*/defineRouter(the source parsed) - @spfn/core/nextjs — RPC proxy (
createRpcProxy) that consumesrouteMap - @spfn/core/env — environment configuration