@moostjs/vite
v0.6.31
Published
Vite Dev plugin for moostjs
Maintainers
Readme
@moostjs/vite
Vite dev plugin for Moost. Enables hot module replacement for Moost HTTP applications during development, automatic adapter detection, and production build configuration.
Supports two modes:
- Backend mode (default) — Moost owns the server, Vite provides HMR and TypeScript transforms
- Middleware mode — Vite serves the frontend (Vue, React, etc.), Moost handles API routes as middleware
Installation
npm install @moostjs/vite --save-devBackend Mode (default)
For pure API servers where Moost handles all HTTP requests. Vite is used as a dev tool for HMR and decorator transforms.
// vite.config.ts
import { defineConfig } from 'vite'
import { moostVite } from '@moostjs/vite'
export default defineConfig({
plugins: [
moostVite({
entry: './src/main.ts',
}),
],
})// src/main.ts
import { Moost, Param } from 'moost'
import { MoostHttp, Get } from '@moostjs/event-http'
class App extends Moost {
@Get('hello/:name')
hello(@Param('name') name: string) {
return { message: `Hello ${name}!` }
}
}
const app = new App()
const http = new MoostHttp()
app.adapter(http).listen(3000)
app.init()Run with vite dev. The plugin patches MoostHttp.listen() so Moost doesn't bind a port — Vite's dev server handles HTTP instead. Controller changes trigger HMR with automatic DI cleanup.
Middleware Mode
For fullstack apps where Vite serves the frontend and Moost handles API routes. Unmatched requests fall through to Vite's default handler (static assets, Vue/React pages, HMR client).
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { moostVite } from '@moostjs/vite'
export default defineConfig({
plugins: [
vue(),
moostVite({
entry: './src/api/main.ts',
middleware: true,
}),
],
})// src/api/main.ts
import { Moost, Param } from 'moost'
import { MoostHttp, Get } from '@moostjs/event-http'
class ApiController extends Moost {
@Get('api/hello/:name')
hello(@Param('name') name: string) {
return { message: `Hello ${name}!` }
}
}
const app = new ApiController()
const http = new MoostHttp()
app.adapter(http).listen(3000)
app.init()Requests matching Moost routes (e.g. /api/hello/world) are handled by Moost with the full pipeline (interceptors, DI, validation). Everything else (/, /about, static assets) falls through to Vite.
The optional prefix option adds a fast-path check — requests not matching the prefix skip Moost entirely:
moostVite({
entry: './src/api/main.ts',
middleware: true,
prefix: '/api',
})Note that the production server generated by vite build defaults the prefix to '/api' when the option is omitted — set prefix explicitly so dev (where all requests pass through Moost without it) and prod route the same way.
SSR Mode
Add ssrEntry to enable server-side rendering. The plugin handles everything automatically — SSR rendering in dev, multi-environment builds for production:
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts',
})In dev, the plugin adds an SSR fallback middleware that renders pages on the server using your entry-server.ts. In production, vite build produces three bundles in a single pass:
- client — optimized browser assets (
dist/client/) - ssr — server-side render function (
dist/server/ssr/) - server — production Node.js server (
dist/server/server.js)
Render Contract
Your SSR entry exports a render(url) that returns HTML plus optional metadata. Only html is required — every other field is optional, so { html } (or { html, state }) keeps working unchanged:
// entry-server.ts
import type { TSSRRenderResult } from '@moostjs/vite/server'
export async function render(url: string): Promise<TSSRRenderResult> {
// ...render your app for `url`...
return {
html, // → substituted into `<!--ssr-outlet-->`
state, // → wrapped as <script>window.__SSR_STATE__=…</script> at `<!--ssr-state-->`
head, // → per-page <head> tags injected at `<!--ssr-head-->`
status, // → HTTP status code (default 200) — e.g. 404 for an unknown slug
headers, // → extra response headers — e.g. cache-control, or location with status: 301
}
}The three markers (ssrOutlet, ssrState, ssrHead) are plain string replacements — a marker missing from index.html is simply skipped. Put <!--ssr-head--> inside <head> to get crawler-visible per-page <title> / <meta> / canonical / Open Graph / JSON-LD tags. head is exactly the string a head manager emits (e.g. unhead's renderSSRHead(head).headTags).
status and headers give the render control over the response: return status: 404 for a real not-found (crawlers treat soft-404s worse), set cache-control, or redirect with status: 301 + headers: { location: '/new-url' }. Headers are applied after the default Content-Type: text/html, so a render may override it.
SPA Mode
Omit ssrEntry and Vite serves the app as a standard SPA. The production build still generates a server that serves static files and API routes — it just skips server-side rendering.
Custom Server Entry
By default, vite build auto-generates a minimal production server. If you need custom middleware (compression, auth, logging), provide your own server file:
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts',
serverEntry: './server.ts',
})// server.ts
import { createSSRServer } from '@moostjs/vite/server'
const app = await createSSRServer()
// app.use(compression())
await app.listen()createSSRServer handles dev/prod branching — in dev it creates a Vite dev server, in prod it serves built assets with sirv.
SSR Bundle Size
By default, vite build in middleware mode sets ssr.noExternal: true — every dependency is bundled into the SSR output. This is the safe default for two reasons:
- Symbol-identity slot keys — packages like
@wooksjs/event-httpuseSymbol()for internal slot keys. If the same package is reachable via both an externalized path (Node ESM at runtime) and a bundled path (rolldown inlines it transitively), each module instance creates fresh Symbols and slot lookups miss. Bundling everything yields a single instance per package. - pnpm strict resolution — externalized transitive deps may not be hoist-accessible from the consumer's top-level
node_modules.
For a real frontend with Vue/Vue Router/etc., the resulting dist/server/ can exceed a megabyte. To externalize stable upstream libraries and shrink the bundle, set ssr.external (or the equivalent ssrExternal plugin option):
// vite.config.ts
export default defineConfig({
ssr: {
external: ['vue', 'vue-router', '@vue/server-renderer'],
},
plugins: [
vue(),
moostVite({
entry: '/src/main.ts',
middleware: true,
prefix: '/api',
ssrEntry: '/src/entry-server.ts',
}),
],
})Safe to externalize: publicly-published, semver-stable libraries that ship a single canonical build (Vue, Vue Router, @vue/server-renderer, VueUse, etc.). Node's ESM resolver loads them once and identity is shared between server.js and the SSR entry.
Don't externalize: workspace packages (workspace:*), anything that uses Symbol() as a public slot key (Moost, wooks, atscript), anything not reliably hoisted by pnpm.
You can also opt out of bundle-everything by setting ssr.noExternal to an explicit list — the plugin honors it, appending /^@moostjs\/vite($|\/)/ (so its own define: substitutions still land) plus, unless you externalize the runtime yourself, the moost/wooks runtime patterns described below:
ssr: {
noExternal: [/^@aooth\//, /^@atscript\//],
external: ['vue', 'vue-router'],
}When you use an explicit noExternal list, the plugin automatically keeps the moost/wooks runtime (moost, @moostjs/*, @wooksjs/*, wooks) bundled alongside your listed packages, so there is always a single runtime instance. This avoids a subtle production-only failure: if any noExternal package imports @wooksjs/* (e.g. @aooth/* and other .as-shipping libs do), it would otherwise pull in a second copy while externalized moost uses the first — splitting the event context so useRequest() / useHeaders() / useAuthorization() read undefined. If you would rather externalize the runtime instead, list it under ssr.external (e.g. ['@wooksjs/event-http', '@wooksjs/event-core', 'wooks', ...]) and add those packages as direct dependencies — the plugin detects an externalized runtime and leaves your all-external setup intact.
SSR Local Fetch
When ssrFetch is enabled (default: true), the plugin patches globalThis.fetch so that local paths are routed in-process through Moost instead of making a real HTTP request. This is useful for SSR where server-side code fetches from its own API:
// During SSR rendering:
const res = await fetch('/api/hello/world')
// → Routed in-process to Moost, no TCP round-tripIf no Moost route matches, the call falls back to the original fetch. External URLs (e.g. https://api.example.com) always go through real HTTP.
Disable with ssrFetch: false when running behind Nitro or another framework that manages fetch routing itself.
Hot Module Replacement
Both modes support full HMR for Moost controllers:
- File change detected → Vite invalidates the module graph
- Moost DI container is cleaned up (stale instances ejected, dependants cascade)
- The SSR entry is re-imported → Moost re-initializes with updated controllers
- New requests use the updated code — no server restart needed
The plugin injects a __VITE_ID decorator on @Injectable and @Controller classes to track which file each class belongs to, enabling precise cleanup during hot reloads.
Options
| Option | Type | Default | Description |
|---|---|---|---|
| entry | string | — | Application entry file (required) |
| port | number | 3000 | Dev server port (backend mode only) |
| host | string | 'localhost' | Dev server host (backend mode only) |
| outDir | string | 'dist' | Build output directory (backend mode only) |
| format | 'cjs' \| 'esm' | 'esm' | Output module format (backend mode only) |
| sourcemap | boolean | true | Generate source maps (both modes; in middleware mode applies to the dist/server SSR build) |
| externals | boolean \| object | true | Configure external dependencies (backend mode only) |
| onEject | function | — | Hook to control DI instance ejection during HMR |
| ssrFetch | boolean | true | Enable local fetch interception for SSR |
| middleware | boolean | false | Run Moost as Connect middleware (Vite serves frontend) |
| prefix | string | '/api' (prod server) | URL prefix filter for middleware mode (e.g. '/api'); the production server defaults to '/api' when omitted |
| ssrEntry | string | — | Vue/React SSR entry module (e.g. '/src/entry-server.ts') |
| ssrOutlet | string | '<!--ssr-outlet-->' | HTML placeholder for SSR-rendered content |
| ssrState | string | '<!--ssr-state-->' | HTML placeholder for SSR state transfer script |
| ssrHead | string | '<!--ssr-head-->' | HTML placeholder for SSR-rendered <head> tags (place inside <head>) |
| serverEntry | string | — | Custom production server entry file (e.g. './server.ts') |
| ssrExternal | string[] | — | Packages to keep external in the SSR build (middleware mode, vite build only). Concatenated with cfg.ssr.external. See SSR Bundle Size. |
Options marked "backend mode only" are ignored when middleware: true — the user's vite.config.ts controls build/server configuration in middleware mode.
License
MIT
