@xrystal/dto 26.6.27-v12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +724 -0
- package/dist/adapters/elysia.cjs +820 -0
- package/dist/adapters/elysia.d.ts +138 -0
- package/dist/adapters/elysia.js +816 -0
- package/dist/adapters/express.cjs +789 -0
- package/dist/adapters/express.d.ts +49 -0
- package/dist/adapters/express.js +785 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/node.cjs +790 -0
- package/dist/adapters/node.d.ts +27 -0
- package/dist/adapters/node.js +786 -0
- package/dist/adapters/shared.d.ts +11 -0
- package/dist/adapters/socketio.cjs +767 -0
- package/dist/adapters/socketio.d.ts +31 -0
- package/dist/adapters/socketio.js +763 -0
- package/dist/adapters/websocket.cjs +759 -0
- package/dist/adapters/websocket.d.ts +31 -0
- package/dist/adapters/websocket.js +754 -0
- package/dist/core/assemble-log.d.ts +1 -0
- package/dist/core/assemble-queue.d.ts +1 -0
- package/dist/core/assemble.d.ts +9 -0
- package/dist/core/builder.d.ts +49 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/context.d.ts +5 -0
- package/dist/core/hooks.d.ts +8 -0
- package/dist/core/index.cjs +1002 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +986 -0
- package/dist/core/log-dto.d.ts +1 -0
- package/dist/core/metadata.d.ts +6 -0
- package/dist/core/plugins.d.ts +10 -0
- package/dist/core/presets.d.ts +32 -0
- package/dist/core/queue-dto.d.ts +1 -0
- package/dist/core/response-dto.d.ts +1 -0
- package/dist/core/schema-utils.d.ts +1 -0
- package/dist/dto/index.cjs +909 -0
- package/dist/dto/index.d.ts +6 -0
- package/dist/dto/index.js +898 -0
- package/dist/dto/log/assemble.d.ts +3 -0
- package/dist/dto/log/builder.d.ts +41 -0
- package/dist/dto/log/index.cjs +152 -0
- package/dist/dto/log/index.d.ts +3 -0
- package/dist/dto/log/index.js +149 -0
- package/dist/dto/log/types.d.ts +26 -0
- package/dist/dto/queue/assemble.d.ts +2 -0
- package/dist/dto/queue/builder.d.ts +35 -0
- package/dist/dto/queue/index.cjs +154 -0
- package/dist/dto/queue/index.d.ts +3 -0
- package/dist/dto/queue/index.js +151 -0
- package/dist/dto/queue/types.d.ts +34 -0
- package/dist/dto/response/assemble.d.ts +1 -0
- package/dist/dto/response/dto.d.ts +39 -0
- package/dist/dto/response/index.cjs +179 -0
- package/dist/dto/response/index.d.ts +2 -0
- package/dist/dto/response/index.js +176 -0
- package/dist/dto/runtime/dto.cjs +1318 -0
- package/dist/dto/runtime/dto.d.ts +64 -0
- package/dist/dto/runtime/dto.js +1315 -0
- package/dist/dto/shared/base-dto.d.ts +12 -0
- package/dist/dto/shared/schema-utils.d.ts +1 -0
- package/dist/enums/node-env.d.ts +5 -0
- package/dist/errors/codes.d.ts +15 -0
- package/dist/errors/error.d.ts +32 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/index.cjs +1482 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +1433 -0
- package/dist/types/builder.d.ts +15 -0
- package/dist/types/codes.d.ts +7 -0
- package/dist/types/context.d.ts +16 -0
- package/dist/types/hooks.d.ts +7 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/log.d.ts +1 -0
- package/dist/types/metadata.d.ts +7 -0
- package/dist/types/options.d.ts +24 -0
- package/dist/types/plugin.d.ts +35 -0
- package/dist/types/queue.d.ts +1 -0
- package/dist/types/response.d.ts +43 -0
- package/dist/utils/constants/http.d.ts +2 -0
- package/dist/utils/constants/index.d.ts +3 -0
- package/dist/utils/constants/security-headers.d.ts +1 -0
- package/dist/utils/constants/status-codes.d.ts +35 -0
- package/dist/utils/helpers/index.d.ts +2 -0
- package/dist/utils/helpers/request-id.d.ts +3 -0
- package/dist/utils/helpers/uuid.d.ts +2 -0
- package/dist/utils/index.d.ts +2 -0
- package/package.json +130 -0
package/README.md
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
# @xrystal/dto
|
|
2
|
+
|
|
3
|
+
Framework-agnostic **Data Transfer Object** library for **Express**, **Node/Bun HTTP**, **Elysia**, **WebSocket**, and **Socket.IO**.
|
|
4
|
+
|
|
5
|
+
Every runtime shares the same response builder, and the same modular DTO pipeline — response, log, and queue shapes with predictable JSON field order.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why use it?
|
|
10
|
+
|
|
11
|
+
- **One response schema** across HTTP and realtime transports
|
|
12
|
+
- **Presets** for common cases (`.as.ok`, `.as.notFound`, …) — no boilerplate
|
|
13
|
+
- **Manual chain** when you need headers, cookies, rate limits, or a full body object via `.schema()`
|
|
14
|
+
- **Plugins** to audit, trace, or transform responses middleware-style
|
|
15
|
+
- **Modular DTOs** — response (request-scoped), log and queue (static global builders)
|
|
16
|
+
- **TypeScript-first** with strict field ordering in every JSON body
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Response schema
|
|
21
|
+
|
|
22
|
+
Every response body follows this fixed field order:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"status": true,
|
|
27
|
+
"message": "User fetched",
|
|
28
|
+
"payload": {
|
|
29
|
+
"data": { "id": 1 },
|
|
30
|
+
"meta": { "requestId": "abc-123" }
|
|
31
|
+
},
|
|
32
|
+
"code": 0
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Field | Type | Required in JSON | Meaning |
|
|
37
|
+
|-------|------|------------------|---------|
|
|
38
|
+
| `status` | `boolean` | always | Business outcome — `true` success, `false` failure. Independent from HTTP status. |
|
|
39
|
+
| `message` | `string` | always | Human-readable summary for clients and logs. |
|
|
40
|
+
| `payload` | `object` | optional | Wrapper for extra data. Omitted when empty. |
|
|
41
|
+
| `payload.data` | `any` | optional | Primary result — set via `.data()` or preset first argument. |
|
|
42
|
+
| `payload.meta` | `object` | optional | Tracing, pagination context, etc. |
|
|
43
|
+
| `code` | `number` | always | App-level code. Defaults: `0` SUCCESS, `1` CLIENT, `2` SERVER, `3` VALIDATION. Override or extend via `DTO` config. |
|
|
44
|
+
|
|
45
|
+
Transport layer (HTTP status, headers, cookies) is **separate** from the body schema.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Mental model
|
|
50
|
+
|
|
51
|
+
Everything hangs off one root: **`DTO`**.
|
|
52
|
+
|
|
53
|
+
| Layer | Entry | Scope | Use for |
|
|
54
|
+
|-------|--------|-------|---------|
|
|
55
|
+
| Runtime | `new DTO()` | App-wide config | Middleware, plugins, codes, socket event |
|
|
56
|
+
| Response body | `DTO.Response` or `res.builder.dto` | Static or per-request | JSON body shape only |
|
|
57
|
+
| Response dispatch | `res.builder` / `ctx.builder` | Per request | Body + HTTP status + headers + plugins |
|
|
58
|
+
| Log | `DTO.Log` / `LogDTO` | Static | Structured logs |
|
|
59
|
+
| Queue | `DTO.Queue` / `QueueDTO` | Static | Message bus payloads |
|
|
60
|
+
|
|
61
|
+
All shape DTOs extend the same rules:
|
|
62
|
+
|
|
63
|
+
- Fluent chain in **fixed field order**
|
|
64
|
+
- `.schema()` for partial input — **last call wins**
|
|
65
|
+
- `payload` in `.schema()` **replaces** the entire payload (no merge)
|
|
66
|
+
|
|
67
|
+
`Builder` is transport on top of `ResponseDTO` — same split as HTTP status vs JSON body.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## When to use what
|
|
72
|
+
|
|
73
|
+
This is the most important distinction in the library.
|
|
74
|
+
|
|
75
|
+
### You **have** a request (Express, Node, Elysia, WebSocket, Socket.IO)
|
|
76
|
+
|
|
77
|
+
Middleware from `new DTO()` attaches **`builder`** to the request/socket. Use that in handlers — it has context, dispatch, HTTP status, headers, and plugins.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// Express / Node — res.builder
|
|
81
|
+
app.get('/users/:id', (req, res) => {
|
|
82
|
+
return res.builder.as.ok({ id: req.params.id }, 'User fetched', {
|
|
83
|
+
meta: { requestId: req.ctx.requestId },
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Elysia — ctx.builder (same idea, flat context)
|
|
88
|
+
.get('/users/:id', ({ builder, requestId, params }) =>
|
|
89
|
+
builder.as.ok({ id: params.id }, 'User fetched', { meta: { requestId } }),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Manual chain when you need HTTP + body control
|
|
93
|
+
return res.builder
|
|
94
|
+
.httpStatus(429)
|
|
95
|
+
.header('Retry-After', '60')
|
|
96
|
+
.schema({ status: false, message: 'Slow down', code: 4 })
|
|
97
|
+
.build()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`DTO.Response` is **not** used here directly — `res.builder` already owns an internal `ResponseDTO`. You can inspect or reuse it:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const bodyOnly = res.builder.dto.build() // same JSON shape, no dispatch yet
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### You **do not** have a request (cron, worker, unit test, logger, queue publisher)
|
|
107
|
+
|
|
108
|
+
No `req`, no `res`, no `ctx` — nothing to attach a builder to. Use **static shape DTOs**:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// Log line for your logger transport
|
|
112
|
+
const logLine = DTO.Log.level('info')
|
|
113
|
+
.service('billing-cron')
|
|
114
|
+
.env(NodeEnvEnum.PROD)
|
|
115
|
+
.message('Invoices synced')
|
|
116
|
+
.build()
|
|
117
|
+
logger.info(logLine)
|
|
118
|
+
|
|
119
|
+
// Rabbit / SQS / Bull payload
|
|
120
|
+
const message = DTO.Queue.source('billing-cron')
|
|
121
|
+
.eventType('invoice.synced')
|
|
122
|
+
.payload({ count: 42 })
|
|
123
|
+
.build()
|
|
124
|
+
await queue.publish(message)
|
|
125
|
+
|
|
126
|
+
// Plain response JSON (e.g. cache, test fixture, RPC payload without HTTP)
|
|
127
|
+
const rpcBody = DTO.Response.status(true)
|
|
128
|
+
.message('Computed')
|
|
129
|
+
.data({ total: 100 })
|
|
130
|
+
.build()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Summary
|
|
134
|
+
|
|
135
|
+
| Situation | Use | Returns |
|
|
136
|
+
|-----------|-----|---------|
|
|
137
|
+
| HTTP handler (Express/Node/Elysia) | `res.builder` / `ctx.builder` | Dispatches response to client |
|
|
138
|
+
| WebSocket / Socket.IO handler | `ws.builder` / `socket.builder` | Sends JSON on wire |
|
|
139
|
+
| Cron, worker, logger, queue | `DTO.Log`, `DTO.Queue`, `DTO.Response` | Plain object — you send/write it |
|
|
140
|
+
| Need body JSON only inside a handler | `res.builder.dto` | `ResponseBody` object |
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Response DTO
|
|
145
|
+
|
|
146
|
+
Body only (`status → message → payload? → code`). No HTTP status here.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { DTO } from '@xrystal/dto'
|
|
150
|
+
|
|
151
|
+
DTO.Response.status(true)
|
|
152
|
+
.message('User fetched')
|
|
153
|
+
.data({ id: 1 })
|
|
154
|
+
.build()
|
|
155
|
+
|
|
156
|
+
// Partial object — same .schema() rules as the builder chain
|
|
157
|
+
DTO.Response.schema({
|
|
158
|
+
status: false,
|
|
159
|
+
message: 'Not found',
|
|
160
|
+
code: 1,
|
|
161
|
+
payload: { data: { reason: 'missing' } },
|
|
162
|
+
}).build()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
In a handler, `res.builder` delegates body fields to an internal `ResponseDTO`. Access it via `res.builder.dto` when you only need the shape.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Log DTO
|
|
170
|
+
|
|
171
|
+
Fixed field order: `level → service → message? → payload? → code? → timestamp → id? → env`
|
|
172
|
+
|
|
173
|
+
**Required at build:** `level`, `service`, `env`.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
177
|
+
|
|
178
|
+
DTO.Log.level('info')
|
|
179
|
+
.service('users-api')
|
|
180
|
+
.env(NodeEnvEnum.PROD)
|
|
181
|
+
.message('User fetched')
|
|
182
|
+
.payload({ userId: 1 })
|
|
183
|
+
.build()
|
|
184
|
+
|
|
185
|
+
// One-shot via .schema()
|
|
186
|
+
DTO.Log.schema({
|
|
187
|
+
level: 'error',
|
|
188
|
+
service: 'auth',
|
|
189
|
+
env: NodeEnvEnum.DEV,
|
|
190
|
+
message: 'Login failed',
|
|
191
|
+
code: 'AUTH_FAIL',
|
|
192
|
+
}).build()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Queue DTO
|
|
198
|
+
|
|
199
|
+
Shape: `{ metadata, payload }` — metadata order `source → eventType → timestamp → correlationId → userId? → extraData?`
|
|
200
|
+
|
|
201
|
+
**Required at build:** `source`, `eventType`.
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
import { DTO } from '@xrystal/dto'
|
|
205
|
+
|
|
206
|
+
DTO.Queue.source('users-api')
|
|
207
|
+
.eventType('user.created')
|
|
208
|
+
.userId('u-1')
|
|
209
|
+
.payload({ id: 1 })
|
|
210
|
+
.build()
|
|
211
|
+
|
|
212
|
+
DTO.Queue.schema({
|
|
213
|
+
metadata: { source: 'billing', eventType: 'invoice.paid' },
|
|
214
|
+
payload: { invoiceId: 9 },
|
|
215
|
+
}).build()
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Install
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npm install @xrystal/dto
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Peer dependencies (install only what you use):
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
npm install express # Express adapter
|
|
230
|
+
npm install elysia # Elysia adapter
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Subpath imports are available if you want a smaller surface:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { DTO, LogDTO, ResponseDTO, QueueDTO, Builder } from '@xrystal/dto'
|
|
237
|
+
import { DTO } from '@xrystal/dto/dto' // runtime only
|
|
238
|
+
import { ResponseDTO } from '@xrystal/dto/response'
|
|
239
|
+
import { LogDTO } from '@xrystal/dto/log'
|
|
240
|
+
import { QueueDTO } from '@xrystal/dto/queue'
|
|
241
|
+
import { expressMiddleware } from '@xrystal/dto/express'
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Quick start (Express)
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import express from 'express'
|
|
250
|
+
import { DTO } from '@xrystal/dto'
|
|
251
|
+
|
|
252
|
+
const app = express()
|
|
253
|
+
app.use(new DTO().express())
|
|
254
|
+
|
|
255
|
+
app.get('/users/:id', (req, res) => {
|
|
256
|
+
return res.builder.as.ok({ id: req.params.id }, 'User fetched')
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Response sent to the client:
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"status": true,
|
|
265
|
+
"message": "User fetched",
|
|
266
|
+
"payload": { "data": { "id": "42" } },
|
|
267
|
+
"code": 0
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Middleware attaches:
|
|
272
|
+
|
|
273
|
+
- `req.ctx` — `{ requestId, startTime }`
|
|
274
|
+
- `res.builder` — fluent response builder
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Two ways to respond
|
|
279
|
+
|
|
280
|
+
### 1. Presets (recommended for most handlers)
|
|
281
|
+
|
|
282
|
+
Call `.as.*` — dispatches automatically. **Do not call `.build()`.**
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// Success
|
|
286
|
+
return res.builder.as.ok({ id: 1 }, 'User fetched')
|
|
287
|
+
|
|
288
|
+
// With payload extras (meta, pagination fields, …)
|
|
289
|
+
return res.builder.as.ok(
|
|
290
|
+
{ id: 1 },
|
|
291
|
+
'User fetched',
|
|
292
|
+
{ meta: { requestId: req.ctx.requestId } },
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// Generic failures
|
|
296
|
+
return res.builder.as.fail('Could not fetch users')
|
|
297
|
+
return res.builder.as.fail({ reason: 'invalid_query' }, 'Could not fetch users')
|
|
298
|
+
return res.builder.as.error({ ref: 'db-01' }, 'Something went wrong')
|
|
299
|
+
|
|
300
|
+
// Exact HTTP status
|
|
301
|
+
return res.builder.as.notFound('User not found')
|
|
302
|
+
return res.builder.as.unauthorized('Token expired')
|
|
303
|
+
return res.builder.as.tooManyRequests('Slow down')
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Preset argument shape**
|
|
307
|
+
|
|
308
|
+
| Pattern | Example |
|
|
309
|
+
|---------|---------|
|
|
310
|
+
| Message only | `.fail('Not found')` |
|
|
311
|
+
| Message + extras | `.fail('Rate limited', { meta: { retryAfter: 60 } })` |
|
|
312
|
+
| Data + message + extras | `.ok(data, 'Done', { meta: { … } })` — same as `.fail()` / `.error()` |
|
|
313
|
+
|
|
314
|
+
### 2. Manual chain (headers, cookies, custom cases)
|
|
315
|
+
|
|
316
|
+
Use when you need transport control or a schema-shaped body. Always end with `.build()`.
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
return res.builder
|
|
320
|
+
.httpStatus(429)
|
|
321
|
+
.header('Retry-After', '60')
|
|
322
|
+
.status(false)
|
|
323
|
+
.code(4)
|
|
324
|
+
.message('Slow down')
|
|
325
|
+
.data({ retryAfter: 60 })
|
|
326
|
+
.payload({ meta: { requestId: req.ctx.requestId } })
|
|
327
|
+
.build()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Or apply the body in one step with `.schema()`:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
return res.builder
|
|
334
|
+
.httpStatus(429)
|
|
335
|
+
.header('Retry-After', '60')
|
|
336
|
+
.schema({
|
|
337
|
+
status: false,
|
|
338
|
+
message: 'Slow down',
|
|
339
|
+
code: 4,
|
|
340
|
+
payload: {
|
|
341
|
+
data: { retryAfter: 60 },
|
|
342
|
+
meta: { requestId: req.ctx.requestId },
|
|
343
|
+
},
|
|
344
|
+
})
|
|
345
|
+
.build()
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Chain rules**
|
|
349
|
+
|
|
350
|
+
| Rule | Detail |
|
|
351
|
+
|------|--------|
|
|
352
|
+
| Last call wins | `.schema()` overrides earlier `.status()` / `.code()` / `.data()`; a later `.status()` overrides `.schema()` too. |
|
|
353
|
+
| `.schema()` body only | Put `meta`, `data`, etc. inside `payload`. Put `httpStatus`, headers, cookies on the chain. |
|
|
354
|
+
| `payload` in `.schema()` | Replaces the entire payload — no merge with earlier chain calls. |
|
|
355
|
+
| Required body fields | `status`, `message`, and `code` are always emitted. Defaults fill omissions at `.build()` time. |
|
|
356
|
+
| `.data()` vs `.payload()` | Separate methods — `.data(x)` sets `payload.data` only; `.payload({ meta })` sets other payload fields. |
|
|
357
|
+
|
|
358
|
+
| Method | Purpose |
|
|
359
|
+
|--------|---------|
|
|
360
|
+
| `.httpStatus(200)` | HTTP status code |
|
|
361
|
+
| `.schema({ … })` | Apply partial response body |
|
|
362
|
+
| `.status(true)` | Body `status` field |
|
|
363
|
+
| `.code(0)` | Body `code` field |
|
|
364
|
+
| `.message('…')` | Body `message` |
|
|
365
|
+
| `.data(x)` | Sets `payload.data` |
|
|
366
|
+
| `.payload({ meta })` | Other payload fields |
|
|
367
|
+
| `.header()` / `.setCookies()` | Response headers and cookies |
|
|
368
|
+
| `.build()` | Assemble and dispatch |
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## DTO setup
|
|
373
|
+
|
|
374
|
+
Single entry point: **`DTO`**. Instance = runtime (middleware). Static namespaces = shape DTOs (`DTO.Response`, `DTO.Log`, `DTO.Queue`).
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
import { DTO, definePlugin } from '@xrystal/dto'
|
|
378
|
+
|
|
379
|
+
const requestIdPlugin = definePlugin({
|
|
380
|
+
name: 'request-id',
|
|
381
|
+
priority: -10,
|
|
382
|
+
afterBuild(result, ctx) {
|
|
383
|
+
return {
|
|
384
|
+
...result,
|
|
385
|
+
headers: { ...result.headers, 'X-Request-Id': ctx.context?.requestId ?? '' },
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const dto = new DTO({
|
|
391
|
+
withDefaultHeaders: true,
|
|
392
|
+
codes: { RATE_LIMITED: 4 },
|
|
393
|
+
plugins: [requestIdPlugin],
|
|
394
|
+
socket: { event: 'api:response' },
|
|
395
|
+
}).use(auditPlugin) // immutable — returns a new DTO
|
|
396
|
+
|
|
397
|
+
app.use(dto.express())
|
|
398
|
+
io.use(dto.socketIo())
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Default codes (always merged; override only what you need):
|
|
402
|
+
|
|
403
|
+
| Key | Default | Typical use |
|
|
404
|
+
|-----|---------|-------------|
|
|
405
|
+
| `SUCCESS` | `0` | Happy path |
|
|
406
|
+
| `CLIENT` | `1` | Bad input, auth, not found, … |
|
|
407
|
+
| `SERVER` | `2` | Internal errors |
|
|
408
|
+
| `VALIDATION` | `3` | Schema / field validation |
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Core presets
|
|
413
|
+
|
|
414
|
+
| Method | HTTP | Code | Purpose |
|
|
415
|
+
|--------|------|------|---------|
|
|
416
|
+
| `.ok(data?, message?, extras?)` | 200 | SUCCESS | Success |
|
|
417
|
+
| `.fail(data?, message?, extras?)` | 400 | CLIENT | Generic client failure |
|
|
418
|
+
| `.error(data?, message?, extras?)` | 500 | SERVER | Generic server failure |
|
|
419
|
+
| `.created(data?, message?, extras?)` | 201 | SUCCESS | Resource created |
|
|
420
|
+
| `.accepted(message?, extras?)` | 202 | SUCCESS | Accepted |
|
|
421
|
+
| `.noContent(extras?)` | 204 | SUCCESS | No body |
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## HTTP-specific presets
|
|
426
|
+
|
|
427
|
+
Use when you need an exact HTTP status. Core `.fail()` / `.error()` cover most cases.
|
|
428
|
+
|
|
429
|
+
### Client (4xx)
|
|
430
|
+
|
|
431
|
+
| Method | HTTP | Code |
|
|
432
|
+
|--------|------|------|
|
|
433
|
+
| `.badRequest(…)` | 400 | CLIENT |
|
|
434
|
+
| `.unauthorized(…)` | 401 | CLIENT |
|
|
435
|
+
| `.forbidden(…)` | 403 | CLIENT |
|
|
436
|
+
| `.notFound(…)` | 404 | CLIENT |
|
|
437
|
+
| `.conflict(…)` | 409 | CLIENT |
|
|
438
|
+
| `.unprocessable(…)` | 422 | VALIDATION |
|
|
439
|
+
| `.tooManyRequests(…)` | 429 | CLIENT |
|
|
440
|
+
|
|
441
|
+
### Server (5xx)
|
|
442
|
+
|
|
443
|
+
| Method | HTTP | Code |
|
|
444
|
+
|--------|------|------|
|
|
445
|
+
| `.internalServerError(…)` | 500 | SERVER |
|
|
446
|
+
| `.serviceUnavailable(…)` | 503 | SERVER |
|
|
447
|
+
| `.gatewayTimeout(…)` | 504 | SERVER |
|
|
448
|
+
|
|
449
|
+
All HTTP presets share the same argument shape as `.ok()` / `.fail()`.
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## Plugins
|
|
454
|
+
|
|
455
|
+
Middleware-style pipeline. Lower `priority` runs first (default `0`).
|
|
456
|
+
|
|
457
|
+
| Hook | When | Use for |
|
|
458
|
+
|------|------|---------|
|
|
459
|
+
| `beforeBuild` | Before body assembly | Mutate builder state |
|
|
460
|
+
| `afterBuild` | After `FinalResult` is ready | Transform body, headers, cookies |
|
|
461
|
+
| `beforeDispatch` | Same stage as `afterBuild` | Alias hook for dispatch pipeline |
|
|
462
|
+
| `afterDispatch` | After response is sent | Logging, metrics, audit |
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import { definePlugin } from '@xrystal/dto'
|
|
466
|
+
|
|
467
|
+
export const auditPlugin = definePlugin({
|
|
468
|
+
name: 'audit',
|
|
469
|
+
priority: 0,
|
|
470
|
+
afterDispatch(result, ctx) {
|
|
471
|
+
console.log(ctx.context?.requestId, result.statusCode, result.body.code)
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
new DTO({ plugins: [auditPlugin] })
|
|
476
|
+
new DTO().use(auditPlugin, otherPlugin)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Runtime guides
|
|
482
|
+
|
|
483
|
+
### Express
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import express from 'express'
|
|
487
|
+
import { DTO } from '@xrystal/dto'
|
|
488
|
+
|
|
489
|
+
const app = express()
|
|
490
|
+
app.use(new DTO().express())
|
|
491
|
+
|
|
492
|
+
app.get('/users/:id', (req, res) => {
|
|
493
|
+
return res.builder.as.ok({ id: req.params.id }, 'User fetched')
|
|
494
|
+
})
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Node HTTP
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
import { createServer } from 'node:http'
|
|
501
|
+
import { DTO } from '@xrystal/dto'
|
|
502
|
+
|
|
503
|
+
const dto = new DTO()
|
|
504
|
+
|
|
505
|
+
createServer((req, res) => {
|
|
506
|
+
dto.node()(req, res, () => {
|
|
507
|
+
if (req.url === '/health') return res.builder?.as.ok({ ok: true }, 'Up')
|
|
508
|
+
return res.builder?.as.fail('Not found')
|
|
509
|
+
})
|
|
510
|
+
}).listen(3000)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Bun HTTP
|
|
514
|
+
|
|
515
|
+
Bun uses the Node adapter via `node:http` — same API:
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
import { createServer } from 'node:http'
|
|
519
|
+
import { DTO } from '@xrystal/dto'
|
|
520
|
+
|
|
521
|
+
const dto = new DTO()
|
|
522
|
+
|
|
523
|
+
createServer((req, res) => {
|
|
524
|
+
dto.node()(req, res, () => {
|
|
525
|
+
if (req.url === '/health') return res.builder?.as.ok({ ok: true }, 'Up')
|
|
526
|
+
|
|
527
|
+
return res.builder
|
|
528
|
+
?.code(4)
|
|
529
|
+
.httpStatus(429)
|
|
530
|
+
.status(false)
|
|
531
|
+
.message('Too many requests')
|
|
532
|
+
.build()
|
|
533
|
+
})
|
|
534
|
+
}).listen(3000)
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
`req.ctx` holds `requestId`; `res.builder` is attached by middleware (use `?.` until the handler runs).
|
|
538
|
+
|
|
539
|
+
### Elysia
|
|
540
|
+
|
|
541
|
+
The handler argument **is** the context. The plugin adds `builder`, `requestId`, and `startTime` via scoped `derive`:
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
import { Elysia } from 'elysia'
|
|
545
|
+
import { DTO } from '@xrystal/dto'
|
|
546
|
+
|
|
547
|
+
const dto = new DTO()
|
|
548
|
+
|
|
549
|
+
new Elysia()
|
|
550
|
+
.use(dto.elysia())
|
|
551
|
+
.get('/health', ({ builder, requestId }) =>
|
|
552
|
+
builder.as.ok({ healthy: true }, 'OK', { meta: { requestId } }),
|
|
553
|
+
)
|
|
554
|
+
.get('/users/:id', ({ builder, requestId, params }) =>
|
|
555
|
+
builder.as.ok({ id: params.id }, 'User fetched', { meta: { requestId } }),
|
|
556
|
+
)
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Outside a request (e.g. startup log):
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
console.log(
|
|
563
|
+
DTO.Log.level('info')
|
|
564
|
+
.service('api')
|
|
565
|
+
.env(NodeEnvEnum.PROD)
|
|
566
|
+
.message('Server listening')
|
|
567
|
+
.build(),
|
|
568
|
+
)
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### WebSocket
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
import { DTO } from '@xrystal/dto'
|
|
575
|
+
|
|
576
|
+
const dto = new DTO()
|
|
577
|
+
|
|
578
|
+
Bun.serve({
|
|
579
|
+
websocket: {
|
|
580
|
+
open(ws) {
|
|
581
|
+
dto.attachWebSocket(ws)
|
|
582
|
+
ws.builder?.as.ok({ connected: true }, 'Ready')
|
|
583
|
+
},
|
|
584
|
+
message(ws, raw) {
|
|
585
|
+
ws.builder?.as.ok({ echo: raw.toString() }, 'Echo')
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
})
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Socket.IO
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
dto.attachSocketIo(socket)
|
|
595
|
+
socket.builder?.as.ok({ room: 'lobby' }, 'Connected')
|
|
596
|
+
|
|
597
|
+
io.use(dto.socketIo())
|
|
598
|
+
socket.builder?.as.ok({ socketId: socket.id }, 'Connected')
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
WebSocket and Socket.IO have no HTTP status — use presets or `.build()`. JSON is sent via `ws.send` / `socket.emit`.
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Full-stack example
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
import express from 'express'
|
|
609
|
+
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
610
|
+
|
|
611
|
+
const runtime = new DTO({
|
|
612
|
+
withDefaultHeaders: true,
|
|
613
|
+
codes: { RATE_LIMITED: 4 },
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
const app = express()
|
|
617
|
+
app.use(runtime.express())
|
|
618
|
+
|
|
619
|
+
app.get('/users/:id', async (req, res) => {
|
|
620
|
+
try {
|
|
621
|
+
const user = await db.findUser(req.params.id)
|
|
622
|
+
if (!user) return res.builder.as.notFound('User not found')
|
|
623
|
+
|
|
624
|
+
// Side-effect: structured log (no req/res needed for shape)
|
|
625
|
+
console.log(
|
|
626
|
+
DTO.Log.level('info')
|
|
627
|
+
.service('users-api')
|
|
628
|
+
.env(NodeEnvEnum.PROD)
|
|
629
|
+
.message('User fetched')
|
|
630
|
+
.payload({ userId: user.id })
|
|
631
|
+
.build(),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
return res.builder.as.ok(user, 'User fetched', {
|
|
635
|
+
meta: { requestId: req.ctx.requestId },
|
|
636
|
+
})
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return res.builder.fromError(err).build()
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
// Cron job — no builder, static DTOs only
|
|
643
|
+
setInterval(() => {
|
|
644
|
+
const event = DTO.Queue.source('users-api')
|
|
645
|
+
.eventType('users.active.count')
|
|
646
|
+
.payload({ count: db.activeCount() })
|
|
647
|
+
.build()
|
|
648
|
+
|
|
649
|
+
queue.publish(event)
|
|
650
|
+
}, 60_000)
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
655
|
+
## Errors
|
|
656
|
+
|
|
657
|
+
Map thrown errors to the standard body:
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
try {
|
|
661
|
+
const user = await fetchUser(id)
|
|
662
|
+
return res.builder.as.ok(user, 'User fetched')
|
|
663
|
+
} catch (err) {
|
|
664
|
+
return res.builder.fromError(err).build()
|
|
665
|
+
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Built-in error types: `ClientError`, `ServerError`, `ValidationError`. Unknown errors are masked when `verbose: false`.
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## Custom codes & HTTP status
|
|
673
|
+
|
|
674
|
+
```typescript
|
|
675
|
+
const dto = new DTO({ codes: { RATE_LIMITED: 4 } })
|
|
676
|
+
|
|
677
|
+
return res.builder
|
|
678
|
+
.code(4)
|
|
679
|
+
.httpStatus(429)
|
|
680
|
+
.status(false)
|
|
681
|
+
.message('Too many requests')
|
|
682
|
+
.build()
|
|
683
|
+
|
|
684
|
+
// Or when HTTP maps cleanly:
|
|
685
|
+
return res.builder.as.tooManyRequests('Too many requests')
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## Release (maintainers)
|
|
691
|
+
|
|
692
|
+
**Source of truth:** `package.json` `"version"`. Git tag must be **identical** — no env override.
|
|
693
|
+
|
|
694
|
+
Format: `YY.MM.DD-vN` (e.g. `26.06.27-v8`).
|
|
695
|
+
|
|
696
|
+
### Quick tag (commit already done)
|
|
697
|
+
|
|
698
|
+
Bump `package.json`, commit, then:
|
|
699
|
+
|
|
700
|
+
```bash
|
|
701
|
+
npm run tag
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Reads version from **committed** `package.json`, tags **current commit**, pushes branch + tag → Publish workflow runs.
|
|
705
|
+
|
|
706
|
+
### Full release (lint + test + build + commit + tag)
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
# 1. Bump version in package.json
|
|
710
|
+
# 2. Run everything:
|
|
711
|
+
npm run release
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
| Command | What it does |
|
|
715
|
+
|---------|----------------|
|
|
716
|
+
| `npm run version:check` | Warns if HEAD tag ≠ package.json |
|
|
717
|
+
| `npm run tag` | Tag HEAD from package.json → push branch + tag |
|
|
718
|
+
| `npm run release` | lint → test → build → commit → `npm run tag` |
|
|
719
|
+
|
|
720
|
+
GitHub Publish verifies `tag === package.json version` before npm publish.
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
724
|
+
MIT
|