@xrystal/dto
v26.6.28-v1
Published
Framework-agnostic Data Transfer Object library for HTTP, WebSocket, and Socket.IO responses, logs, and queue messages.
Maintainers
Readme
@xrystal/dto
Framework-agnostic Data Transfer Object library for Node.js, Bun, and TypeScript — HTTP, WebSocket, and Socket.IO.
One predictable JSON contract. Three clear entry points.
Getting started (5 minutes)
Install
npm install @xrystal/dto
# optional peers
npm install express elysiaThree concepts
| # | What | When |
|---|------|------|
| 1 | new DTO() | App setup — middleware, plugins, custom codes |
| 2 | res.builder / ctx.builder | Inside a handler — send API response to client |
| 3 | DTO.Response / DTO.Log / DTO.Queue | No request context — cron, logger, queue, tests |
Rule of thumb: client response → builder. Plain JSON without transport → static DTO.*.
1. Bootstrap — create DTO once, wire one adapter
Step A — app entry (single instance):
import { DTO } from '@xrystal/dto'
export const runtime = new DTO({
withDefaultHeaders: true,
// optional: codes, plugins, socket: { event: 'api:response' }
})Create new DTO() once at startup. Handlers reuse the same runtime; do not create a new DTO per request.
Step B — register the adapter for your server (once, at startup):
Each method returns middleware or a plugin — the framework calls it per request/connection. You do not call runtime.express(req, res) inside handlers.
| Stack | Wire once (bootstrap) | In handler |
|-------|------------------------|------------|
| Express | app.use(runtime.express()) | res.builder.as.ok(...).send() |
| Node http | server.on('request', chain(runtime.node(), handler)) | res.builder…send() |
| Elysia | app.use(runtime.elysia()) | builder.as.ok(...).send() |
| Fetch / Workers | no global wire — runtime.forRequest(request) per call | builder…send() → Response |
| WebSocket | wss.on('connection', ws => runtime.attachWebSocket(ws)) | ws.builder…send() |
| Socket.IO | io.use(runtime.socketIo()) or attachSocketIo on connect | socket.builder…send() |
Express
import express from 'express'
import { runtime } from './dto.js'
const app = express()
app.use(runtime.express()) // ← once: attaches req.ctx + res.builder
app.get('/users/:id', (req, res) =>
res.builder.as.ok({ id: req.params.id }, 'User fetched').send(),
)Elysia
import { Elysia } from 'elysia'
import { runtime } from './dto.js'
new Elysia()
.use(runtime.elysia()) // ← once: derive injects builder, requestId, startTime
.get('/users/:id', ({ builder, requestId, params }) =>
builder.as.ok({ id: params.id }, 'User fetched', { meta: { requestId } }).send(),
)Socket.IO
import { Server } from 'socket.io'
import { runtime } from './dto.js'
const io = new Server(httpServer)
// Option 1 — middleware on every connection
io.use(runtime.socketIo())
io.on('connection', (socket) => {
socket.on('getUser', ({ id }) =>
socket.builder!.as.ok({ id }, 'User fetched').send(),
)
})
// Option 2 — attach manually in the connection handler
io.on('connection', (socket) => {
runtime.attachSocketIo(socket)
// socket.builder is now available
})WebSocket (raw ws)
import { WebSocketServer } from 'ws'
import { runtime } from './dto.js'
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', (ws) => {
runtime.attachWebSocket(ws) // or wss.use pattern via runtime.websocket() middleware
ws.on('message', () => ws.builder!.as.ok({ pong: true }).send())
})HTTP + realtime in one app: wire both transports at bootstrap — e.g. app.use(runtime.express()) and io.use(runtime.socketIo()).
With xrystal-core
In a standalone app you pick the adapter explicitly (runtime.express(), runtime.elysia(), …).
In xrystal-core, framework selection lives in core via dtoMiddleware(config) — it reads your config and wires the correct adapter (express, elysia, socketIo, …). DTO does not pick the framework; core does.
Standalone apps call the adapter explicitly:
runtime.express() // not runtime.middleware('express')
runtime.elysia()
runtime.socketIo()2. Respond in a handler
Every chain ends with a verb — .send() (go to client) or .build() (schema only):
app.get('/users/:id', (req, res) => {
return res.builder.as.ok(
{ id: req.params.id },
'User fetched',
{ meta: { requestId: req.ctx.requestId } },
).send()
})Sent JSON (fixed order: status → message → payload? → code):
{
"status": true,
"message": "User fetched",
"payload": { "data": { "id": "42" }, "meta": { "requestId": "…" } },
"code": 0
}| Verb | What it does |
|------|----------------|
| .send() | Assembles + pushes via send sink (HTTP write, WebSocket emit, …) |
| .build() | Assembles schema only — no network side-effect |
Presets (.as.ok, .as.notFound, …) configure the body and return a terminal — you still call .send() or .build().
3. Static shape DTOs (no request)
import { DTO, NodeEnvEnum } from '@xrystal/dto'
// Log line
DTO.Log.level('info')
.service('api')
.env(NodeEnvEnum.PROD)
.message('Server ready')
.build()
// Queue message
DTO.Queue.source('api')
.eventType('user.created')
.payload({ id: 1 })
.build()
// Response JSON only (no HTTP)
DTO.Response.status(true).message('OK').data({ id: 1 }).build()Every shape DTO shares the same chain rules:
- Fixed field order
.schema({ … })for partial input — last call winspayloadin.schema()replaces the entire payload
Fetch API — Workers, Hono, Bun.serve
import { DTO } from '@xrystal/dto'
const runtime = new DTO({ withDefaultHeaders: true })
export async function GET(request: Request) {
const { builder, requestId } = runtime.forRequest(request)
return builder.as.ok({ ok: true }, 'Healthy', { meta: { requestId } }).send()
// → Web Response
}When to use what
| Situation | Use |
|-----------|-----|
| Express / Node / Elysia / Fetch handler | builder…send() |
| WebSocket / Socket.IO | socket.builder…send() |
| Schema only (log, queue, test, transform) | builder…build() or DTO.*.build() |
| Cron, worker, logger, queue publish | DTO.Log, DTO.Queue, DTO.Response |
| Custom HTTP status + headers | builder.httpStatus(429).header(...).schema({...}).send() |
Plugins and custom codes on new DTO({ … }) apply only to builder — not to static DTO.Log / DTO.Queue.
Adapters & send sinks
Each runtime has an adapter (wires builder per request) and a send sink (writes FinalResult to the transport on .send()).
| Piece | Role |
|-------|------|
| Adapter | Framework middleware / derive — creates requestId, attaches builder, applies default headers |
| Send sink | { dispatch(result) } object — one line per runtime, delegates to send/transport.ts |
Handler chain Core Transport
───────────── ───── ─────────
builder.as.ok() → .build() → FinalResult
→ .send() → dispatchResult() → sink.dispatch()
→ send/transport.ts
→ res.json / Response / ws.send / …Core dispatch lives in dto/send/transport.ts. Adapters attach a send sink to each builder via createBuilderRuntime(). The sink is only invoked on .send() — .build() never touches it.
Runtime map
| Runtime | Adapter | Sink | Attaches | .send() output |
|---------|---------|------|----------|------------------|
| Express | ExpressAdapter | expressSink(res) | req.ctx, res.builder | writes res.status().json() |
| Node http | NodeAdapter | nodeSink(res) | req.ctx, res.builder | writes res.end(JSON) + Set-Cookie |
| Fetch API | FetchAdapter | fetchSink | builder, requestId per Request | returns Response |
| Elysia | ElysiaAdapter | elysiaSink(set) | builder, requestId, startTime on ctx | mutates set, returns body |
| WebSocket | WebSocketAdapter | websocketSink(socket) | socket.ctx, socket.builder | socket.send(wire envelope JSON) |
| Socket.IO | SocketIoAdapter | socketIoSink(socket, event) | socket.ctx, socket.builder | socket.emit(event, wire envelope) |
WebSocket and Socket.IO send the full transport envelope (statusCode, headers, cookies, body) so .httpStatus() and .header() are not lost on realtime transports.
new DTO() shortcuts
Register at bootstrap (once). Handlers only use builder.
const runtime = new DTO({ withDefaultHeaders: true, socket: { event: 'api:response' } })
// HTTP — returns middleware / plugin; pass to app.use()
runtime.express() // → (req, res, next) => void
runtime.elysia() // → Elysia plugin (derive builder per request)
runtime.node() // → Node http middleware
runtime.fetch() // → (request: Request) => { builder, requestId, … }
runtime.forRequest(request) // → one-shot Fetch context
// Realtime — returns connection middleware or attach helper
runtime.websocket() // → (socket, next?) => void
runtime.attachWebSocket(ws) // → attach builder to one socket
runtime.socketIo() // → (socket, next?) => void (Socket.IO middleware)
runtime.attachSocketIo(socket) // → attach builder to one socketWhen to import adapters directly
Most apps only need new DTO().elysia() or new DTO().express() (etc.). Import adapters / sinks when you:
- Integrate a custom framework —
createBuilderRuntime(expressSink(res), options, context) - Unit-test transport without full middleware
- Build an internal wrapper (e.g. Fastify plugin)
import { createBuilderRuntime, expressSink, ContextFactory } from '@xrystal/dto'
// Custom wiring example
const context = ContextFactory.create(req)
const { builder } = createBuilderRuntime(expressSink(res), options, context)Subpath imports (tree-shake friendly):
| Import | Contents |
|--------|----------|
| @xrystal/dto/express | ExpressAdapter, expressSink, expressMiddleware |
| @xrystal/dto/node | NodeAdapter, nodeSink, nodeMiddleware |
| @xrystal/dto/fetch | FetchAdapter, fetchSink, createFetchDerive |
| @xrystal/dto/elysia | ElysiaAdapter, elysiaSink, createElysiaDerive |
| @xrystal/dto/websocket | WebSocketAdapter, websocketSink |
| @xrystal/dto/socketio | SocketIoAdapter, socketIoSink |
Core vs DTO — is one adapter enough?
The library has two jobs; the layout reflects that:
| Job | Layer | Adapter required? |
|-----|-------|-------------------|
| Build schema | shapes/ + shared.ts | No — DTO.Log.build(), DTO.Response.build(), tests |
| Push to client | send/ + adapters/ | Yes — .send() must write to a transport |
┌─────────────────────────────────────────────┐
│ new DTO() — single entry point │
│ runtime.express() / .fetch() / … │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ adapters/ — runtime wiring only │
│ middleware → res.builder + send sink │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ send/ — Builder, presets, transport │
│ .build() schema | .send() writes client │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ shapes/ — ResponseDTO, LogDTO, QueueDTO │
│ field order, .schema() override rules │
└─────────────────────────────────────────────┘Is wiring one adapter enough?
| Scenario | What to do |
|----------|------------|
| Normal HTTP app | Pick your stack: runtime.express(), runtime.elysia(), runtime.fetch(), … |
| JSON only (log, queue, test) | DTO.Log / DTO.Queue / builder.build() — no adapter |
| Custom framework (Fastify, etc.) | createBuilderRuntime(expressSink(res), …) or your own sink |
| WS + HTTP in the same app | Wire once per transport: e.g. .elysia() + .socketIo() |
Summary: If you send handler responses to a client, you need an adapter — pick the one that matches your server. The adapter attaches the sink to the builder at request start; the sink runs only when .send() is called. .build() returns pure schema and never touches transport.
| Layer | Do you import it? | Role |
|-------|-------------------|------|
| shapes | Rarely (@xrystal/dto/log) | Pure JSON schema |
| send | Rarely (@xrystal/dto/response) | Builder, transport, presets |
| adapters | Custom integration / subpath | Attaches builder to each request |
| DTO | Always | Config + runtime.elysia() / runtime.express() shortcuts |
Request lifecycle (Express)
1. Request arrives
2. ExpressAdapter middleware:
ContextFactory → requestId
createBuilderRuntime(expressSink(res), options, ctx)
res.builder = builder
3. Handler:
res.builder.as.ok(data).send()
→ .build() → FinalResult
→ .send() → transport.ts → expressSink → res.json()Schema is not assembled at step 2 — only when you call .build() or .send().
Architecture
src/dto/
├── shared.ts BaseDTO, field rules, builder config
├── factories.ts ContextFactory, MetadataFactory
├── shapes/ ← job 1: schema
│ ├── response.ts
│ ├── log.ts
│ └── queue.ts
├── send/ ← job 2: build + push
│ ├── builder.ts
│ ├── presets.ts
│ ├── transport.ts
│ └── plugins.ts
├── adapters/ runtime wiring
│ ├── http.ts Express, Node, Fetch + *Sink helpers
│ ├── elysia-adapter.ts Elysia plugin + elysiaSink (isolated for tree-shaking)
│ ├── realtime.ts WebSocket, Socket.IO
│ ├── bind.ts DTO.express() shortcuts
│ └── express.ts … @xrystal/dto/express subpath shims
└── runtime/
├── builder-runtime.ts createBuilderRuntime (adapter-safe import)
└── index.ts DTO class, HooksSubpath exports (@xrystal/dto/express, /response, …) unchanged — shims map to the folders above.
Package exports
| Import | Contents |
|--------|----------|
| @xrystal/dto | Everything (including all adapters & send sinks) |
| @xrystal/dto/dto | DTO, createDTO, runtime helpers |
| @xrystal/dto/response | ResponseDTO, Builder, PresetTerminal, transport helpers |
| @xrystal/dto/log | LogDTO |
| @xrystal/dto/queue | QueueDTO |
| @xrystal/dto/express | Express adapter |
| @xrystal/dto/node | Node http adapter |
| @xrystal/dto/fetch | Fetch API adapter |
| @xrystal/dto/elysia | Elysia adapter |
| @xrystal/dto/websocket | WebSocket adapter |
| @xrystal/dto/socketio | Socket.IO adapter |
Response schema
| Field | Meaning |
|-------|---------|
| status | Business success/failure (boolean) — independent from HTTP status |
| message | Human-readable summary |
| payload | Optional wrapper (data, meta, pagination, …) |
| code | App code — default 0 SUCCESS, 1 CLIENT, 2 SERVER, 3 VALIDATION |
Transport (HTTP status, headers, cookies) lives on builder, not on DTO.Response.
Handler API
Presets (recommended)
Presets return a terminal — always end with .send() or .build():
return res.builder.as.ok(data, 'Done').send()
return res.builder.as.fail('Not found').send()
return res.builder.as.notFound('User missing').send()
return res.builder.as.tooManyRequests('Slow down').send()
// Headers before preset
return res.builder.header('X-Trace', traceId).as.ok(data).send()Manual chain
return res.builder
.httpStatus(429)
.header('Retry-After', '60')
.schema({
status: false,
message: 'Slow down',
code: 4,
payload: { data: { retryAfter: 60 } },
})
.send()Schema only (no dispatch):
const result = res.builder.schema({ payload: { data: user } }).build()
await auditLog.write(result.body)| Chain rule | Detail |
|------------|--------|
| Last call wins | Later .schema() or .status() overrides earlier values |
| .schema() is body-only | HTTP status and headers stay on the chain |
| payload in .schema() | Replaces entire payload — no merge |
| Preset terminal | .as.*() exposes only .build() and .send() |
Shape DTOs
Response (DTO.Response)
Body only — status → message → payload? → code.
DTO.Response.status(true).message('OK').data({ id: 1 }).build()
DTO.Response.schema({
status: false,
message: 'Not found',
code: 1,
}).build()Inside a handler, res.builder.dto exposes the same ResponseDTO instance.
Log (DTO.Log)
Order: level → service → message? → payload? → code? → timestamp → id? → env
Required: level, service, env.
DTO.Log.level('info')
.service('users-api')
.env(NodeEnvEnum.PROD)
.message('User fetched')
.build()Queue (DTO.Queue)
Order: source → eventType → timestamp → correlationId → userId? → extraData?
Required: source, eventType. Timestamp and correlationId auto-generate.
DTO.Queue.source('billing')
.eventType('invoice.paid')
.payload({ invoiceId: 9 })
.build()Runtime config
import { DTO, definePlugin } from '@xrystal/dto'
const audit = definePlugin({
name: 'audit',
afterDispatch(result, ctx) {
console.log(ctx.context?.requestId, result.statusCode)
},
})
const runtime = new DTO({
withDefaultHeaders: true,
codes: { RATE_LIMITED: 4 },
plugins: [audit],
socket: { event: 'api:response' },
}).use(otherPlugin) // immutable — returns new DTO
app.use(runtime.express())
io.use(runtime.socketIo())| Option | Affects |
|--------|---------|
| plugins | builder pipeline only (beforeDispatch runs on .send(), not .build()) |
| codes | builder presets and .code() |
| withDefaultHeaders | HTTP / Fetch middleware |
| socket.event | Socket.IO emit name |
Runtimes
runtime.express()
runtime.node()
runtime.elysia()
runtime.fetch() / runtime.forRequest(request)
runtime.websocket() / runtime.attachWebSocket(ws)
runtime.socketIo() / runtime.attachSocketIo(socket)Errors in handlers
try {
const user = await fetchUser(id)
return res.builder.as.ok(user, 'User fetched').send()
} catch (err) {
return res.builder.fromError(err).send()
}Built-in: ClientError, ServerError, ValidationError.
Full-stack sketch
import express from 'express'
import { DTO, NodeEnvEnum } from '@xrystal/dto'
const runtime = new DTO({ withDefaultHeaders: true })
const app = express()
app.use(runtime.express())
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id)
if (!user) return res.builder.as.notFound('User not found').send()
console.log(
DTO.Log.level('info')
.service('users-api')
.env(NodeEnvEnum.PROD)
.payload({ userId: user.id })
.build(),
)
return res.builder.as.ok(user, 'User fetched', {
meta: { requestId: req.ctx.requestId },
}).send()
})HTTP presets reference
| Preset | HTTP | Code |
|--------|------|------|
| .ok() | 200 | SUCCESS |
| .created() | 201 | SUCCESS |
| .fail() | 400 | CLIENT |
| .notFound() | 404 | CLIENT |
| .unprocessable() | 422 | VALIDATION |
| .tooManyRequests() | 429 | CLIENT |
| .error() | 500 | SERVER |
See source dto/send/presets.ts for the full list.
Breaking / API changes
- Removed
runtime.middleware()— DTO no longer resolves framework from a string. Pick an adapter explicitly (runtime.express(),runtime.elysia(),runtime.socketIo(), …) or usedtoMiddleware(config)from xrystal-core for config-driven wiring. Removed exports:normalizeRuntimeKind,RuntimeKind. - Explicit terminal verbs — every handler chain must end with
.send()(dispatch to client) or.build()(schema only). No automatic dispatch. - Preset terminal —
.as.ok(),.as.fail(), … returnPresetTerminalwith only.build()and.send(). No further chaining after presets (put headers before preset:builder.header('X').as.ok(data).send()). - WebSocket / Socket.IO wire format — emits full transport envelope (
statusCode,headers,cookies,body) instead of bare body JSON. Update clients if they parsed the old shape.
New
- Fetch API adapter —
runtime.fetch(),runtime.forRequest(request),@xrystal/dto/fetch. Returns WebResponsefrom.send(). Works with Workers, Hono, Bun.serve, Next.js route handlers. - Core transport layer —
dto/send/transport.tscentralises headers, cookies, JSON body, FetchResponse, and realtime wire envelope. - Send sinks —
expressSink,fetchSink, … replace dispatcher classes; attached per request, invoked only on.send().
Fixes & consistency
- Elysia —
ElysiaAdapter.plugin()+elysiaSink(set)(same pattern as Express/Node/Fetch). - Node http —
Set-Cookiesupport added (was Express-only viares.cookie). - Dispatch hooks —
beforeDispatch/afterDispatchplugins run on.send()only, not on.build().
Package surface
| Runtimes | 6 — Express, Node, Fetch, Elysia, WebSocket, Socket.IO |
| Tests | 79 passing |
| Subpaths | /express, /node, /fetch, /elysia, /websocket, /socketio, /response, /log, /queue, /dto |
Migration cheat sheet
// Removed — use explicit adapter or xrystal-core dtoMiddleware(config)
runtime.middleware('express') // ✗
runtime.middleware() // ✗
runtime.middleware('socketio') // ✗
// Standalone
app.use(runtime.express())
app.use(runtime.elysia())
io.use(runtime.socketIo())
// xrystal-core (config-driven)
import { dtoMiddleware } from 'xrystal-core/handlers' // your core export path
app.use(dtoMiddleware(config))
// Before
return res.builder.as.ok(data, 'Done')
// After
return res.builder.as.ok(data, 'Done').send()
// Schema only (unchanged intent, now explicit)
const body = res.builder.as.ok(data).build()
// WS client — expect envelope now
// { statusCode, headers, cookies, body: { status, message, … } }Contributing
Contributions are welcome. Open an issue to discuss larger changes, or submit a pull request with a focused diff and tests where behaviour changes.
Before opening a PR, run:
npm test
npm run lintLicense
Links
| | |
|---|---|
| npm | @xrystal/dto |
| Repository | github.com/xdloper/dto |
| Issues | github.com/xdloper/dto/issues |
