How the codegen works
A Nuxt module runs the codegen
(src/codegen.ts) at build
time. It opens your feature files with ts-morph and reads them as a syntax
tree — it never executes the decorators.
What it reads
- Provider classes — every class in
*.{controller,service,repo,guard,filter,interceptor}.tsthat carries@Injectableor@Controller. - Lifetime —
@Controllerand cross-cutting providers default to SCOPED;@Injectable({ scope })overrides; plain@Injectable()is SINGLETON. - The dependency graph — read syntactically from the constructor parameter type annotations (no type-checker, so it works regardless of your tsconfig).
- Routes —
@Controller(base)+@Get/@Post(path). Paths are joined by segment, so a leading slash is optional —@Controller('cats')+@Get(':id')(NestJS style) and@Controller('/cats')+@Get('/:id')produce the identical route. A bare@Controller()defaults the base to the controller's feature folder relative to the features dir (features/projects/…→/projects,features/admin/users/…→/admin/users); an explicit path overrides it. Path segments map to Nitro's file-based router::id→[id](dynamic),*→[...](unnamed catch-all), and*slugor:slug*→[...slug](named catch-all, wherectx.params.slugholds the rest of the path, e.g.a/b/c). - Cross-cutting —
@UseGuards/@UseInterceptors/@UseFiltersclass refs, merged with the right precedence and mapped to DI tokens. - Validation & injection — the method-level
@ValidateBody(schema), and each handler parameter's@Body/@Param/@Query/@Ctxdecorator (+ its schema), turned into a per-argument binding list.
What it emits
Into a gitignored .roost/:
.roost/
api/** route delegates (one per verb) → scanned by Nitro
di.generated.ts awilix container.register({...})
di.plugin.ts registers providers + global chains at startup
manifest.json the full effective route table (surfacing + test snapshot)
A route delegate is a one-liner — guards/filters/interceptors are passed as tokens (resolved from the scope at runtime), so the file imports only the runtime + any body schema:
// .roost/api/projects.post.ts — for `create(@Body(createProjectSchema) dto)`
import { route } from 'nuxt-roost/runtime'
import { createProjectSchema } from '../../server/features/projects/dto'
export default route('projectsController', 'create', {
guards: ['memberGuard'],
bindings: [{ kind: 'body', schema: createProjectSchema }], // validated body → handler arg 0
})
(A ctx-style method with @ValidateBody(schema) emits body: schema instead of bindings, and
the runtime validates ctx.body in place rather than binding an argument.)
Dev watch
In dev, the module watches your server dir and regenerates on change. It writes
only the files whose content actually changed (and prunes stale ones), so Nitro's own watcher
only reloads what truly moved — add a @Get and the route is live without a restart.
The current watcher does a debounced full regen with write-on-change. True incremental regeneration (re-read only the changed file, patch the manifest) is an open contribution.
Why a build-time AST, not runtime reflection
Reading constructor types at build time and emitting explicit factories means the runtime ships zero reflection and zero ts-morph. The generated route files keep Nitro's file-based router as the source of truth, so the build/tree-shaking/edge story is exactly normal Nitro.