How the codegen works

The build-time AST pass — what it reads, what it emits, and the dev-watch loop.

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}.ts that carries @Injectable or @Controller.
  • Lifetime@Controller and 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 *slug or :slug*[...slug] (named catch-all, where ctx.params.slug holds the rest of the path, e.g. a/b/c).
  • Cross-cutting@UseGuards / @UseInterceptors / @UseFilters class 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 / @Ctx decorator (+ 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.

Copyright © 2026