Vision Server Overview
Meta-framework with built-in observability
Vision Server
Vision Server is a Bun-first, Elysia-based meta-framework with automatic observability, pub/sub, and end-to-end type safety (Eden-ready).
Vision Server wraps Elysia — the underlying instance is still an Elysia app,
so every Elysia plugin (@elysia/cors, @elysia/jwt, @elysiajs/swagger, …)
works, and the type chain flows into the Eden Treaty client.
Why Vision Server?
- vs NestJS — orders of magnitude faster, zero DI ceremony, built-in observability
- vs Encore.ts — 100% open source, no vendor lock-in
- vs plain Elysia — adds tracing, logs, API Explorer, pub/sub, cron, rate limiting
- Type-safe end-to-end —
treaty<AppType>on the client, response shapes inferred from server schemas
Quick Start
bun add @getvision/server elysia zodimport { createVision, createModule, defineEvents } from '@getvision/server'
import { z } from 'zod'
const usersModule = createModule({ prefix: '/users' })
.use(
defineEvents({
'user/created': {
schema: z.object({ userId: z.string(), email: z.string().email() }),
handler: async (event) => {
console.log('[welcome email] →', event.email)
},
},
})
)
.get('/', ({ span }) => {
const users = span('db.select', { 'db.table': 'users' }, () => [
{ id: '1', name: 'Alice' },
])
return { users }
})
.post(
'/',
async ({ body, emit }) => {
const id = crypto.randomUUID()
await emit('user/created', { userId: id, email: body.email })
return { id, ...body }
},
{
body: z.object({ name: z.string(), email: z.string().email() }),
}
)
const app = createVision({
service: { name: 'My API', version: '1.0.0' },
pubsub: { devMode: true },
}).use(usersModule)
app.listen(3000)
/** Eden Treaty client type — export for the frontend. */
export type AppType = typeof appThat's it! You get:
- ✅ Vision Dashboard on port 9500
- ✅ Automatic request tracing
- ✅ Type-safe validation (Zod / Valibot / TypeBox via Standard Schema)
- ✅ Service & event catalog
- ✅ Eden Treaty type export for your frontend
The Module Pattern
Vision composes apps from small, focused modules. A module is an Elysia plugin with Vision's per-request context (span, emit, addContext, traceId) already typed in.
const ordersModule = createModule({ prefix: '/orders' })
.use(defineEvents({ 'order/placed': { schema, handler } }))
.post('/', async ({ body, emit }) => {
await emit('order/placed', { orderId: '...', total: body.total })
return { ok: true }
}, { body: OrderBody })Compose at the root via .use() — order doesn't matter and each module contributes its own routes, events, and crons:
const app = createVision({ service: { name: 'Shop' } })
.use(usersModule)
.use(ordersModule)
.use(productsModule)Core Features
🚀 Zero configuration
Everything works out of the box:
- Vision Dashboard starts automatically on port 9500
- Tracing hooks are pre-wired (
onRequest,onAfterResponse,onError) - Service & route catalog auto-registered (once on
listen, or lazily on first request for.handle(req)deployments like Next.js)
🔥 span / emit / addContext in the handler
Every handler receives Vision's context alongside Elysia's body/query/params:
.post('/', async ({ body, span, emit, addContext }) => {
addContext({ 'user.email': body.email })
const user = span('db.insert', { 'db.table': 'users' }, () => {
return db.users.create(body)
})
await emit('user/created', { userId: user.id, email: user.email })
return user
})✅ Validation with any Standard Schema library
Use Zod, Valibot, or Elysia's built-in TypeBox (t) — all interchangeable:
import { t } from '@getvision/server'
import { z } from 'zod'
import * as v from 'valibot'
// Zod
.post('/', handler, { body: z.object({ name: z.string() }) })
// Valibot
.post('/', handler, { body: v.object({ name: v.string() }) })
// Elysia / TypeBox — re-exported from @getvision/server
.post('/', handler, { body: t.Object({ name: t.String() }) })Invalid request? Elysia returns a 422 with validation details automatically.
📊 Built-in pub/sub & cron
Powered by BullMQ (in-memory during development, Redis in production):
const notificationsModule = createModule()
.use(
defineEvents({
'user/created': {
schema: z.object({ userId: z.string(), email: z.string() }),
handler: async (event) => {
await sendWelcomeEmail(event.email)
},
},
})
)Schedule work with cron:
import { defineCrons } from '@getvision/server'
const cleanupModule = createModule().use(
defineCrons({
'nightly-cleanup': {
schedule: '0 0 * * *',
handler: async () => {
console.log('running cleanup')
},
},
})
)🎯 Elysia-native
Vision's underlying instance is Elysia — every plugin and feature works unmodified:
import { cors } from '@elysia/cors'
import { swagger } from '@elysiajs/swagger'
const app = createVision({ service: { name: 'My API' } })
.use(cors())
.use(swagger())
.use(usersModule)
.get('/health', () => ({ status: 'ok' }))🔒 Per-endpoint rate limiting
import { rateLimit } from '@getvision/server'
.post('/', handler, {
body: SignupBody,
beforeHandle: [rateLimit({ requests: 5, window: '15m' })],
})🧩 Eden Treaty — typed RPC on the client
import { treaty } from '@elysia/eden'
import type { AppType } from './server'
const api = treaty<AppType>('http://localhost:3000')
const { data } = await api.users.get()
// ^? { users: { id: string; name: string }[] }Change a schema on the server → the client gets a compile error. No codegen.
Configuration
const app = createVision({
service: {
name: 'My API',
version: '1.0.0',
description: 'Optional description',
integrations: {
database: 'postgresql://localhost/mydb',
redis: 'redis://localhost:6379',
},
drizzle: {
autoStart: true, // auto-start Drizzle Studio in dev
port: 4983,
},
},
vision: {
enabled: true, // enable/disable dashboard
port: 9500, // dashboard port
maxTraces: 1000,
maxLogs: 10000,
logging: true,
// exporters: [...] // forward traces to OTLP backends — see below
},
pubsub: {
devMode: true, // in-memory queue — no Redis required
// redis: { host: 'localhost', port: 6379 }
},
})Exporting Traces (OTLP)
Forward every completed trace to any OpenTelemetry-compatible backend — BetterStack, Honeycomb, Grafana Tempo, Datadog, an OTel Collector — by adding an OtlpTraceExporter to vision.exporters. Export runs alongside the local Dashboard, so traces appear in both.
import { createVision, OtlpTraceExporter } from '@getvision/server'
createVision({
service: { name: 'my-api' },
vision: {
exporters: [
new OtlpTraceExporter({
endpoint: 'https://<host>/v1/traces', // OTLP/HTTP traces endpoint
headers: { Authorization: 'Bearer <token>' }, // backend auth
serviceName: 'my-api',
}),
],
},
})The exporter speaks OTLP/JSON over HTTP, so the destination is purely a matter of endpoint + headers — switching backends is a config change, not a code change. Traces are buffered and flushed in batches, and a failing exporter is isolated so it never affects request handling.
Each HTTP request becomes a synthetic root span (SERVER) with your c.span(...) calls as nested INTERNAL spans, and the trace's logs attached as span events.
For a custom sink, implement the TraceExporter interface (export(trace) plus an optional shutdown()) and add it to vision.exporters — OtlpTraceExporter is just the built-in implementation.
Next Steps
- Modules — build and compose modules
- Rate Limiting — configure per-endpoint limits
- Features — explore tracing, logs, API Explorer