09 Nitro Rolldown
In this section, we'll introduce rolldown to bundle the nitro server, along with presets and the renderer interface. The full code is available at 9-nitro-rolldown.
What's different from 8-unbuild?
In 8-unbuild, the nitro server dynamically imports the renderer at runtime:
// 8-unbuild: nitro dynamically imports renderer
const renderer = await import(nitro.options.renderer!).then(m => m.default)In 9-nitro-rolldown, we use rolldown to bundle the entire nitro server (including the renderer) into a single file, and then execute it:
// 9-nitro-rolldown: execute the bundled server
execSync(`node ${join(nitro.options.output!.serverDir!, 'index.js')}`)Key Concepts
1. Renderer Interface
The renderer is an interface that nitro provides for frameworks like Nuxt to handle HTTP requests. It's defined using defineRenderHandler:
// packages/nitro/src/runtime/internal/render.ts
export type RenderHandler = (
event: H3Event,
) => Partial<RenderResponse> | Promise<Partial<RenderResponse>>
export interface RenderResponse {
body: any
statusCode: number
statusMessage: string
headers: Record<string, string>
}
export function defineRenderHandler(handler: RenderHandler) {
return eventHandler(async event => {
const response = await handler(event)
return response.body
})
}Nuxt implements this interface in its renderer:
// packages/nuxt/src/core/runtime/nitro/renderer.ts
export default defineRenderHandler(async (event: H3Event) => {
const { req, res } = event.node
// Handle client.js request
if (req.url === '/entry.client.js') {
const code = readFileSync(join(buildDir, 'entry.client.js'), 'utf-8')
res.setHeader('Content-Type', 'application/javascript')
res.end(code)
return { statusCode: 200, statusMessage: 'OK', headers: {} }
}
// SSR rendering
const renderer = await getRenderer()
const rendered = await renderer.renderToString({ url: req.url })
const body = renderHTML(rendered)
return { body }
})The renderer path is passed to nitro when creating it:
// packages/nuxt/src/core/nuxt.ts
const nitro = await createNitro({
renderer: join(distDir, 'core/runtime/nitro/renderer.js'),
})2. Presets
Presets define the deployment target configuration for nitro. Each preset specifies:
- entry: The entry point for the server runtime
- output.serverDir: Where to output the bundled server
// packages/nitro/src/types/nitro.ts
export interface NitroPreset extends NitroConfig {
name: string
}
export interface NitroOptions {
entry: string
renderer?: string
output?: {
serverDir?: string
}
}The nitro-dev preset is defined as:
// packages/nitro/src/presets/nitro/nitro-dev.ts
export const nitroDev = defineNitroPreset({
entry: join(import.meta.dirname, './runtime/nitro-dev.mjs'),
output: {
serverDir: join('.nitro', 'dev'),
},
name: 'nitro-dev',
})When createNitro is called, it resolves the preset and merges it with the user config:
// packages/nitro/src/nitro.ts
const _loadUserConfig = async (configOverrides: NitroConfig = {}) => {
const presetOverride = configOverrides.preset || 'nitro-dev'
const preset = resolvePreset(presetOverride)
const loadedConfig = await loadConfig<NitroConfig>({
overrides: configOverrides, // user config (e.g., renderer)
defaults: preset, // preset defaults (e.g., entry, output)
})
return loadedConfig.config as NitroOptions
}3. Preset Runtime (Entry Point)
The preset's entry point is what gets bundled and executed. For nitro-dev:
// packages/nitro/src/presets/nitro/runtime/nitro-dev.ts
import { createServer } from 'node:http'
import { toNodeListener } from 'h3'
import { useNitroApp } from 'nitro/runtime'
const app = useNitroApp().h3App
const server = createServer(toNodeListener(app))
server.listen(3030, () => {
console.log('Server is running on http://localhost:3030')
})This entry imports useNitroApp() which creates the h3 app with all handlers registered.
4. How Handlers are Registered
The nitro app (useNitroApp) registers handlers from a virtual module:
// packages/nitro/src/runtime/internal/app.ts
import { handlers } from '#nitro-internal-virtual/server-handlers'
function createNitroApp(): NitroApp {
const h3App = createApp()
const router = createRouter()
handlers.forEach(({ route, handler }) => {
router.use(route, handler)
})
h3App.use(router)
return { h3App }
}The virtual module #nitro-internal-virtual/server-handlers is generated by rolldown at build time:
// packages/nitro/src/rolldown/config.ts
config.plugins.push(
virtual({
'#nitro-internal-virtual/server-handlers': () => {
return `
import renderer from '${nitro.options.renderer}'
export const handlers = [
{route: '/**', handler: renderer}
]
`
},
}),
)This is how the renderer (from nuxt) gets connected to the nitro server.
Build Flow
1. nuxt calls createNitro({ renderer: '...' })
↓
2. nitro resolves 'nitro-dev' preset
- entry: presets/nitro/runtime/nitro-dev.mjs
- output.serverDir: .nitro/dev
↓
3. nuxt calls build(nitro)
↓
4. rolldown bundles:
- Entry: nitro-dev.mjs
- Virtual module generates handlers with renderer
- Output: .nitro/dev/index.js
↓
5. createDevServer(nitro).listen() executes:
node .nitro/dev/index.jsRolldown Configuration
// packages/nitro/src/rolldown/config.ts
export const getRolldownConfig = (nitro: Nitro) => {
const env = unenv.env(unenv.node)
const config = defineConfig({
input: nitro.options.entry, // preset's entry
external: ['node:http', 'node:fs', 'node:path', ...env.external],
plugins: [],
})
// Resolve external modules to file:// URLs
config.plugins.push({
name: 'nitro-externals',
async resolveId(id) {
if (['vue-bundle-renderer/runtime', 'vue/server-renderer'].includes(id)) {
const resolved = await resolvePath(id, { url: import.meta.url })
return { id: normalizeid(resolved), external: true }
}
},
})
// Generate virtual server-handlers module
config.plugins.push(
virtual({
'#nitro-internal-virtual/server-handlers': () => `
import renderer from '${nitro.options.renderer}'
export const handlers = [{route: '/**', handler: renderer}]
`,
}),
)
return config
}Dev Server
Unlike 8-unbuild which starts an h3 server directly, 9-nitro-rolldown executes the bundled file:
// packages/nitro/src/dev/server.ts
export const createDevServer = (nitro: Nitro) => {
const listen = async () => {
execSync(`node ${join(nitro.options.output!.serverDir!, 'index.js')}`, {
stdio: 'inherit',
})
}
return { listen }
}Summary
The key architectural concepts introduced in 9-nitro-rolldown:
- Renderer Interface: Nitro provides
defineRenderHandleras the interface for frameworks to implement request handling - Presets: Define deployment targets with entry points and output configurations
- Virtual Modules: Rolldown generates
#nitro-internal-virtual/server-handlersto connect the renderer to the nitro app - Bundled Server: The entire server is bundled into a single file and executed
This architecture allows nitro to support different deployment targets (Node.js, Cloudflare Workers, etc.) by simply changing the preset, while keeping the renderer interface consistent for frameworks like Nuxt.
