npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xrystal/dto

v26.6.28-v1

Published

Framework-agnostic Data Transfer Object library for HTTP, WebSocket, and Socket.IO responses, logs, and queue messages.

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 elysia

Three 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 wins
  • payload in .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 socket

When to import adapters directly

Most apps only need new DTO().elysia() or new DTO().express() (etc.). Import adapters / sinks when you:

  • Integrate a custom frameworkcreateBuilderRuntime(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 | NoDTO.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, Hooks

Subpath 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 use dtoMiddleware(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(), … return PresetTerminal with 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 adapterruntime.fetch(), runtime.forRequest(request), @xrystal/dto/fetch. Returns Web Response from .send(). Works with Workers, Hono, Bun.serve, Next.js route handlers.
  • Core transport layerdto/send/transport.ts centralises headers, cookies, JSON body, Fetch Response, and realtime wire envelope.
  • Send sinksexpressSink, fetchSink, … replace dispatcher classes; attached per request, invoked only on .send().

Fixes & consistency

  • ElysiaElysiaAdapter.plugin() + elysiaSink(set) (same pattern as Express/Node/Fetch).
  • Node httpSet-Cookie support added (was Express-only via res.cookie).
  • Dispatch hooksbeforeDispatch / afterDispatch plugins 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 lint

License

MIT © xrystal

Links

| | | |---|---| | npm | @xrystal/dto | | Repository | github.com/xdloper/dto | | Issues | github.com/xdloper/dto/issues |