@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)ContractGeneratorConfig(config shape for the built-in contract generator)StatesGeneratorConfig(config shape for the built-in states generator)ContractGeneratorError,StatesGeneratorError,ConditionalRegistrationError,assertUnconditionalRegistrationRouteContractMapping,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 built-in generators today are@spfn/core:route-map,@spfn/core:contractand@spfn/core:states, 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
Loads your router 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 | [] |
Deprecated and ignored for collection; still added to watchPatterns as ${dir}/**/*.ts. |
What it reads
The router module is loaded with jiti (appRouter → default → router) and walked the
way registerRoutes walks it. How the router is written does not matter: a one-line
defineRouter({ createUser }), an aliased key create: createUser, and a second
defineRouter( in the same file all produce the same map.
- A nested router recurses to any depth and its routes land in the map flat, under their own keys — the parent's key names the grouping, not the route, exactly as the server registers them.
methodandpathcome from theRouteDef, not from a pattern over the source. A route not registered in the router is absent, because there is nothing registering it..packages()routers are carried, at every depth, provided the router publishes a route map (defineRouter, notdefineUnmappedRouter). A package ships a client that names its own routes —authApi.login,monitorApi.getStats— and the app'screateRpcProxyresolves a name in the one map it was constructed with, so the app merges nothing by hand:{ ...routeMap, ...authRouteMap }is a no-op today. They are written after the app's own routes, under a comment line naming where they came from, and the header says how many there are and which mount each came through.- A name that is not a JS identifier (
'get-user') is emitted quoted, and__proto__is emitted as a computed key, so the generated file parses and the map keeps every route as an own property. - Imports are resolved by Node and by the
compilerOptions.pathsof the nearesttsconfig.jsonthat declares them, searched from the router file's own directory up to the project root,extendschains included — so the@/*alias every spfn app has resolves while the router loads, whether it lives in the root config (a Next.js app) or insrc/server/tsconfig.json(whatspfn initscaffolds, and the configspfn buildcompiles the server with). The search stops at the project root: a tsconfig above it belongs to whatever contains the project. A config that declares nopathsis passed over rather than ending the search.baseUrlis honoured, so the scaffold's"baseUrl": "../.."resolves against the project root as tsc resolves it. The failure names the specifier that did not resolve. NODE_ENVis pinned toproductionwhen the shell left it unset, so the same router does not load two ways depending on how codegen was invoked. A value the shell did set is left alone.
When it refuses
Unlike the old source parser, which dropped what it could not read, the generator throws
— logged always, and fatal on the build trigger and on spfn codegen run:
- The module will not load. An import that did not resolve names the specifier; anything else names the cause and says a route module must be importable without side effects. It never falls back to parsing.
- No router is found under
appRouter,defaultorrouter(the message lists all three). - The router file registers a route conditionally (
...(flag ? { admin } : {})), which would put the flag's value into the map and leaveapi.admin.call()404ing wherever the flag differs. This is the same guard@spfn/core:contractuses, and it reads the router the generator was pointed at — the export the loader found it under, plus the routers that export mounts by name from the same file, since a nested router's routes land in the same flat map. A seconddefineRouter(the app router never reaches is not scanned: it registers nothing in production. The source is read with TypeScript's parser, so a JSDoc spellingdefineRouter({ ... })in prose, a comment holding an old router and a brace inside a string are not router source. Only a spread whose expression reads a condition is refused — a ternary,&&,||,??. A call is allowed (...metadataRoutes(config, resource)): loading yields exactly the routes it returns. Past the guard, by construction: a conditional spread inside an imported module, a router built by a factory, a condition hoisted to a variable before the spread, and a.packages()list assembled conditionally.NODE_ENVpinning stands in for the guard on those, but only when the shell left it unset:spfn buildpinsproduction, so what ships is right, whilespfn devsetsdevelopmentand the watcher regenerates under it. The generated header names theNODE_ENVthe map was written under, so that difference is a line in the diff rather than a route that vanishes at build. - Two routes reach the same name (the message names both places).
- An app route and a package route reach the same name, where that package publishes a
route map. Both register at runtime, and both want the single entry that name has in this
map — so one of the two paths becomes unreachable while the generated types still
describe the app's route. Two package routers reaching one name is refused too, and
names both mounts: the generator writes the order now, so the ambiguity is its to refuse.
The same route reached through two mounts is not two routes — a package exporting one set
of routes under a router and a sub-router an app mounts beside it hands both mounts the
same route object, and it is collected once. No first-party package ships that shape today;
the rule is keyed on object identity so that an app mounting both still builds when one
does, and the header line of the second mount says which mount got there first.
A collision with a router that publishes no map is not refused — one declared with
defineUnmappedRouter, whose routes are reached by the URL something was handed rather than by name: the ops surface (createOpsRouter) and the tracking routes of@spfn/notification. Nothing of theirs is written here, so an app route named after one of their routes keeps its own path and must not fail the build. - A route's
methodis not anHttpMethod.registerRouteslowercases the method before registering, somethod: 'get'serves fine and would emit a map that failstsc.
A missing router file is still only a warning: the generator returns and writes nothing.
Warned about and left out, not refused: a route with no method or path (reachable by
registering a route before .handler() was called), and a router entry that is neither a
route nor a router. registerRoutes warns and skips both, so a map without them still names
every route the server answers — which is all a refusal would have been protecting.
A package route with no method or path is skipped the same way, and for the same reason.
What is refused is only what would write a file that does not compile or that answers to
the wrong path: a method that is not an HttpMethod, and the two collisions above.
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' },
// From the package routers this app mounts with .packages(), not its own:
login: { method: 'POST', path: '/_auth/login' },
};
export type RouteMap = typeof routeMap;
export type RouteName = keyof RouteMap;
watchPatterns are [routerPath, 'src/server/routes/**/*.ts', ...additionalRouteDirs] and
runOn is ['watch', 'manual', 'build'] — every trigger the CLI fires, so it runs in every mode.
Built-in: @spfn/core:contract
Writes contracts/current.json — every route carrying .contract(), and with eventRouterPath
every SSE event carrying .contract() — and on a build compares
it against the newest released snapshot, refusing changes that would break a client already in the
field. Full behaviour lives in ../contract/README.md; this section is the
generator's own surface.
ContractGeneratorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
'@spfn/core:contract' |
yes | — | Generator identifier (literal). |
routerPath |
string |
yes | — | Router file, relative to project root. Throws at construction if missing. |
routerExport |
string |
no | appRouter → default → router |
Export holding the defineRouter() result. |
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. |
eventRouterExport |
string |
no | eventRouter → default |
Export holding the defineEventRouter() result. |
outputDir |
string |
no | './contracts' |
Holds current.json, released/, usage/. |
additionalRouteDirs |
string[] |
no | [] |
Extra route dirs to watch. |
How it differs from route-map
route-map |
contract |
|
|---|---|---|
| Reads the router by | loading the module and walking RouteDefs |
the same |
| Covers | every registered route | only routes carrying .contract() |
runOn |
watch, manual, build |
watch, build, manual |
| Can fail a build | yes — any refusal, on every trigger the CLI fails on | yes; a broken contract only on the build trigger |
Loading rather than parsing is what makes the contract correct: real routes build schemas from
imported values (EmailSchema, FileSchema(), constants) that a source parser cannot resolve. It
costs a module import and no infrastructure — @spfn/auth's 43 routes load in ~0.6s with neither
DATABASE_URL nor CACHE_URL set.
When it refuses
- The router file is missing, or the module will not load (it names the module and the cause — it never skips quietly).
- No router export is found under the configured or default names.
eventRouterPathis set and the file is missing, or holds no event router (an object witheventsandeventNames) under the configured or default names — the message names the candidates it tried.- The event router contracts events without a router-level
.contract(), or a contracted event has no payload schema, nosince, or the reserved keyconnected/ping. - Two contracted routes share a name, or a contracted route has no method, path,
sinceorresponse. - The router the generator was pointed at contains a conditional spread
(
...(flag ? { route } : {})), which would make the contract describe whichever way the generator happened to run. Same guard asroute-map— see its bullet for exactly what is scanned and what is allowed. The event router file gets the same scan fordefineEventRouter({ ...(flag ? { event } : {}) }). - On the
buildtrigger only: the contract breaks the newest released snapshot.
spfn dev generates but never refuses — being unable to hold a half-finished route mid-edit would
make the feature unusable.
Built-in: @spfn/core:states
Loads your router and emits the ids and the manifest of every state it mounts —
src/generated/states.ts, a file with no import that a browser root and a server component
can both execute. createStateClient<typeof states> is a type import, erased at build, and
importing the router itself is not an option (each state's read() closes over repositories
and the database), so this file is the only runtime record of which states the app declared.
Compare its stateManifest with the manifest of GET /state: an id the server's manifest
lacks is a deploy skew between the Next.js server and the API, reported at the root instead
of surfacing as state.<id> being undefined inside a screen. See
../state/README.md → "Declared ids at runtime".
StatesGeneratorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
'@spfn/core:states' |
yes | — | Generator identifier (literal). |
routerPath |
string |
yes | — | Router file, relative to project root — the one route-map reads. Throws at construction if missing. |
outputPath |
string |
no | './src/generated/states.ts' |
Where the file is written (parent dirs auto-created). |
What it reads
The router module is loaded exactly as route-map loads it (appRouter → default →
router, tsconfig aliases, NODE_ENV pinned), then walked:
- The marker.
defineStateRoutersets a non-enumerableRouter._stateManifeston the router it derives — the very object the snapshot route answers asmanifest— so the ids written here and the manifest the server serves come from one value. Each state router contributes its entries in declaration order;keylists the key fields in schema order. - Mounted routers at every depth. Every nested router under
routesand every entry of_packageRouters, recursively — approutesfirst, then_packageRoutersin mount order. A router reached by two trails (.packages()copies a mounted router's own package routers up) is collected once: the walk is keyed on the router object. - Chained routers.
.use(),.packages()and.contractVersion()carry the marker onto the router they rebuild, sostates.router.use([audit])is still found. - An id that is not a JS identifier is emitted quoted, through the same key rule
route-mapuses.
When it refuses
Throws StatesGeneratorError (or the loader's and the guard's errors) — logged on watch,
fatal on build and on spfn codegen run:
- The module will not load, or no router is found under the three export names.
- The router registers conditionally — same
ConditionalRegistrationErrorguard asroute-map. - Two state routers declare the same id. The message names both trails.
- No state router is mounted anywhere in the walk. The generator is configured but nothing
it reads mounts a
defineStateRouter(...).router; the message names the router path. Mount the state router or drop the generator.
A mounted state router that declares no state — defineStateRouter({}) — is not refused: it
is an app that has declared none yet. The file is written with stateIds = [] as const and an
empty stateManifest, which matches the empty manifest the server serves.
A missing router file is only a warning: the generator returns and writes nothing.
Generated output
// src/generated/states.ts — DO NOT EDIT (header names the NODE_ENV it was written under)
export const stateIds = ['consent', 'member', 'workspaces'] as const;
export type StateId = (typeof stateIds)[number];
export const stateManifest: Readonly<Record<StateId, { readonly key: readonly string[]; readonly write: boolean }>> = {
consent: { key: [], write: true },
member: { key: ['workspaceId'], write: false },
workspaces: { key: [], write: false },
};
watchPatterns are [routerPath, 'src/server/states.ts', 'src/server/states/**/*.ts', 'src/server/routes/**/*.ts'] and runOn is ['watch', 'manual', 'build'].
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'; a failure exits 1)
spfn dev # dev server + codegen in watch mode
spfn build # runs codegen once before building (trigger 'build'; a failure exits 1)
spfn contract check # regenerate the contract, compare against the newest released snapshot
spfn contract release X # write contracts/released/X.json
spfn contract list # released snapshots (alias: ls)
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.)
spfn codegen run exits 1 when a generator refuses, and stops at the one that refused
— the run that just failed to regenerate a map must not look like the run that did. spfn dev is unaffected: watch mode logs the failure and keeps going, because being unable to
hold a half-finished route mid-edit would make the feature unusable.
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'); // what `spfn build` dispatches
// 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
throwOnError?: boolean; // default: false
}
throwOnError decides what a generator failure does. Off — the default — it is logged and the
run continues, which is what keeps watch mode alive through a half-edited file. spfn build
turns it on: a generator that refuses at build time (a broken route contract, a router that will
not load) has to reach the exit code, or the refusal scrolls past and the build ships anyway.
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', 'manual', '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). Upgrading a mounted package goes stale the same way and is quieter about it: the map carries that package's routes, but the upgrade changes no app source, so no watcher fires and nothing else says the committed map is a version behind — re-run codegen after an upgrade as you would after adding a route. - A route must be in
defineRouter({...})to be emitted. Definingexport const foo = route.get(...)but not registeringfooin the router drops it from the map — the generator walks the router, so an unregistered route does not exist as far as it is concerned. How the registration is spelled no longer matters. - route-map loads your router, so route modules must import cleanly. Reading a
required environment value at module scope now fails
spfn buildandspfn codegen run, not justspfn dev. Move that read inside the handler. Top-levelawaitin a route or router module does not load either — jiti transforms to CJS — the same limit@spfn/core:contracthas always had. - A path alias resolves only if a tsconfig between the router and the project root maps
it. Both loading generators read
compilerOptions.pathsfrom the nearest suchtsconfig.json(followingextends) and hand it to jiti, so@/server/routes/usersresolves from the root config or fromsrc/server/tsconfig.json. A mapping that lives above the project root — a monorepo's solution-style config — is not read; apathsentry whose*is not a trailing/*is skipped (jiti matches a prefix, not a template); and abaseUrlwith nopathsresolves nothing (an alias map cannot say "try this directory for every bare specifier"). Several targets for one pattern are supported: the first that exists on disk is used, as tsc and tsup use it. Each skip is warned about, anextendstarget TypeScript could not read is warned about instead of silently dropping itspaths, and an import that then fails to resolve names the specifier. - 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[]; // deprecated: watched, never collected from
}
interface StatesGeneratorConfig
{
name: '@spfn/core:states';
routerPath: string;
outputPath?: string; // default './src/generated/states.ts'
}
Related
- @spfn/core/route —
route.*/defineRouter(the router loaded and walked) - @spfn/core/nextjs — RPC proxy (
createRpcProxy) that consumesrouteMap - @spfn/core/state —
defineStateRouter, whose manifest@spfn/core:stateswrites - @spfn/core/env — environment configuration