@xrystal/dto 26.6.27-v12 → 26.6.27-v13
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 +210 -569
- package/dist/adapters/elysia.cjs +56 -58
- package/dist/adapters/elysia.d.ts +1 -1
- package/dist/adapters/elysia.js +56 -58
- package/dist/adapters/express.cjs +56 -58
- package/dist/adapters/express.d.ts +1 -1
- package/dist/adapters/express.js +56 -58
- package/dist/adapters/index.d.ts +1 -7
- package/dist/adapters/node.cjs +56 -58
- package/dist/adapters/node.d.ts +1 -1
- package/dist/adapters/node.js +56 -58
- package/dist/adapters/shared.d.ts +1 -1
- package/dist/adapters/socketio.cjs +56 -58
- package/dist/adapters/socketio.d.ts +1 -1
- package/dist/adapters/socketio.js +56 -58
- package/dist/adapters/websocket.cjs +56 -58
- package/dist/adapters/websocket.d.ts +1 -1
- package/dist/adapters/websocket.js +56 -58
- package/dist/dto/index.cjs +511 -57
- package/dist/dto/index.d.ts +2 -2
- package/dist/dto/index.js +504 -58
- package/dist/dto/response/assemble.d.ts +9 -1
- package/dist/{core → dto/response}/builder.d.ts +7 -7
- package/dist/{core → dto/response}/config.d.ts +4 -4
- package/dist/dto/response/index.cjs +466 -1
- package/dist/dto/response/index.d.ts +5 -0
- package/dist/dto/response/index.js +462 -2
- package/dist/{core → dto/response}/plugins.d.ts +2 -2
- package/dist/{core → dto/response}/presets.d.ts +3 -5
- package/dist/{core → dto/runtime}/context.d.ts +1 -1
- package/dist/{core → dto/runtime}/hooks.d.ts +1 -1
- package/dist/dto/runtime/{dto.cjs → index.cjs} +79 -47
- package/dist/dto/runtime/index.d.ts +4 -0
- package/dist/dto/runtime/{dto.js → index.js} +77 -48
- package/dist/{core → dto/runtime}/metadata.d.ts +1 -1
- package/dist/index.cjs +545 -547
- package/dist/index.d.ts +6 -3
- package/dist/index.js +545 -547
- package/package.json +54 -59
- package/dist/core/assemble-log.d.ts +0 -1
- package/dist/core/assemble-queue.d.ts +0 -1
- package/dist/core/assemble.d.ts +0 -9
- package/dist/core/index.cjs +0 -1002
- package/dist/core/index.d.ts +0 -14
- package/dist/core/index.js +0 -986
- package/dist/core/log-dto.d.ts +0 -1
- package/dist/core/queue-dto.d.ts +0 -1
- package/dist/core/response-dto.d.ts +0 -1
- package/dist/core/schema-utils.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,333 +1,183 @@
|
|
|
1
1
|
# @xrystal/dto
|
|
2
2
|
|
|
3
|
-
Framework-agnostic **Data Transfer Object** library for
|
|
3
|
+
Framework-agnostic **Data Transfer Object** library for Node.js, Bun, and TypeScript — HTTP, WebSocket, and Socket.IO.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
One predictable JSON contract. Three clear entry points.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Getting started (5 minutes)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
|
11
|
+
### Install
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
}
|
|
13
|
+
```bash
|
|
14
|
+
npm install @xrystal/dto
|
|
15
|
+
# optional peers
|
|
16
|
+
npm install express elysia
|
|
34
17
|
```
|
|
35
18
|
|
|
36
|
-
|
|
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
|
|
19
|
+
### Three concepts
|
|
50
20
|
|
|
51
|
-
|
|
21
|
+
| # | What | When |
|
|
22
|
+
|---|------|------|
|
|
23
|
+
| 1 | **`new DTO()`** | App setup — middleware, plugins, custom codes |
|
|
24
|
+
| 2 | **`res.builder`** / **`ctx.builder`** | Inside a handler — send API response to client |
|
|
25
|
+
| 3 | **`DTO.Response` / `DTO.Log` / `DTO.Queue`** | No request context — cron, logger, queue, tests |
|
|
52
26
|
|
|
53
|
-
|
|
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 |
|
|
27
|
+
**Rule of thumb:** client cevabı → `builder`. Düz JSON üretmek → static `DTO.*`.
|
|
60
28
|
|
|
61
|
-
|
|
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.
|
|
29
|
+
### 1. Wire the runtime (once)
|
|
78
30
|
|
|
79
31
|
```typescript
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
32
|
+
import express from 'express'
|
|
33
|
+
import { DTO } from '@xrystal/dto'
|
|
101
34
|
|
|
102
|
-
|
|
103
|
-
const
|
|
35
|
+
const runtime = new DTO({ withDefaultHeaders: true })
|
|
36
|
+
const app = express()
|
|
37
|
+
app.use(runtime.express())
|
|
104
38
|
```
|
|
105
39
|
|
|
106
|
-
|
|
40
|
+
This attaches `req.ctx` and `res.builder` on every request.
|
|
107
41
|
|
|
108
|
-
|
|
42
|
+
### 2. Respond in a handler
|
|
109
43
|
|
|
110
44
|
```typescript
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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()
|
|
45
|
+
app.get('/users/:id', (req, res) => {
|
|
46
|
+
return res.builder.as.ok(
|
|
47
|
+
{ id: req.params.id },
|
|
48
|
+
'User fetched',
|
|
49
|
+
{ meta: { requestId: req.ctx.requestId } },
|
|
50
|
+
)
|
|
51
|
+
})
|
|
131
52
|
```
|
|
132
53
|
|
|
133
|
-
|
|
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 |
|
|
54
|
+
Sent JSON (fixed order: `status → message → payload? → code`):
|
|
141
55
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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()
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"status": true,
|
|
59
|
+
"message": "User fetched",
|
|
60
|
+
"payload": { "data": { "id": "42" }, "meta": { "requestId": "…" } },
|
|
61
|
+
"code": 0
|
|
62
|
+
}
|
|
163
63
|
```
|
|
164
64
|
|
|
165
|
-
|
|
65
|
+
Presets (`.as.ok`, `.as.notFound`, …) dispatch automatically — **no `.build()`**.
|
|
166
66
|
|
|
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`.
|
|
67
|
+
### 3. Static shape DTOs (no request)
|
|
174
68
|
|
|
175
69
|
```typescript
|
|
176
70
|
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
177
71
|
|
|
72
|
+
// Log line
|
|
178
73
|
DTO.Log.level('info')
|
|
179
|
-
.service('
|
|
74
|
+
.service('api')
|
|
180
75
|
.env(NodeEnvEnum.PROD)
|
|
181
|
-
.message('
|
|
182
|
-
.payload({ userId: 1 })
|
|
76
|
+
.message('Server ready')
|
|
183
77
|
.build()
|
|
184
78
|
|
|
185
|
-
//
|
|
186
|
-
DTO.
|
|
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')
|
|
79
|
+
// Queue message
|
|
80
|
+
DTO.Queue.source('api')
|
|
207
81
|
.eventType('user.created')
|
|
208
|
-
.userId('u-1')
|
|
209
82
|
.payload({ id: 1 })
|
|
210
83
|
.build()
|
|
211
84
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
payload: { invoiceId: 9 },
|
|
215
|
-
}).build()
|
|
85
|
+
// Response JSON only (no HTTP)
|
|
86
|
+
DTO.Response.status(true).message('OK').data({ id: 1 }).build()
|
|
216
87
|
```
|
|
217
88
|
|
|
218
|
-
|
|
89
|
+
Every shape DTO shares the same chain rules:
|
|
219
90
|
|
|
220
|
-
|
|
91
|
+
- Fixed field order
|
|
92
|
+
- `.schema({ … })` for partial input — **last call wins**
|
|
93
|
+
- `payload` in `.schema()` **replaces** the entire payload
|
|
221
94
|
|
|
222
|
-
|
|
223
|
-
npm install @xrystal/dto
|
|
224
|
-
```
|
|
95
|
+
### Elysia — same idea
|
|
225
96
|
|
|
226
|
-
|
|
97
|
+
```typescript
|
|
98
|
+
import { Elysia } from 'elysia'
|
|
99
|
+
import { DTO } from '@xrystal/dto'
|
|
227
100
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
101
|
+
const runtime = new DTO()
|
|
102
|
+
new Elysia()
|
|
103
|
+
.use(runtime.elysia())
|
|
104
|
+
.get('/users/:id', ({ builder, requestId, params }) =>
|
|
105
|
+
builder.as.ok({ id: params.id }, 'User fetched', { meta: { requestId } }),
|
|
106
|
+
)
|
|
231
107
|
```
|
|
232
108
|
|
|
233
|
-
|
|
109
|
+
### When to use what
|
|
234
110
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
111
|
+
| Situation | Use |
|
|
112
|
+
|-----------|-----|
|
|
113
|
+
| Express / Node / Elysia handler | `res.builder` / `ctx.builder` |
|
|
114
|
+
| WebSocket / Socket.IO | `ws.builder` / `socket.builder` |
|
|
115
|
+
| Cron, worker, logger, queue publish | `DTO.Log`, `DTO.Queue`, `DTO.Response` |
|
|
116
|
+
| Custom HTTP status + headers | `res.builder.httpStatus(429).header(...).schema({...}).build()` |
|
|
117
|
+
|
|
118
|
+
Plugins and custom `codes` on `new DTO({ … })` apply **only to `builder`** — not to static `DTO.Log` / `DTO.Queue`.
|
|
243
119
|
|
|
244
120
|
---
|
|
245
121
|
|
|
246
|
-
##
|
|
122
|
+
## Architecture
|
|
247
123
|
|
|
248
|
-
|
|
249
|
-
import express from 'express'
|
|
250
|
-
import { DTO } from '@xrystal/dto'
|
|
124
|
+
Symmetric modules under `src/dto/`:
|
|
251
125
|
|
|
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
126
|
```
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
127
|
+
dto/
|
|
128
|
+
├── runtime/ ← new DTO(), middleware, ContextFactory, Hooks
|
|
129
|
+
│ ├── dto.ts
|
|
130
|
+
│ ├── context.ts
|
|
131
|
+
│ ├── metadata.ts
|
|
132
|
+
│ └── hooks.ts
|
|
133
|
+
├── response/ ← ResponseDTO, Builder, presets, plugins
|
|
134
|
+
├── log/
|
|
135
|
+
├── queue/
|
|
136
|
+
└── shared/
|
|
137
|
+
├── base-dto.ts
|
|
138
|
+
└── schema-utils.ts
|
|
269
139
|
```
|
|
270
140
|
|
|
271
|
-
|
|
141
|
+
### Package exports
|
|
272
142
|
|
|
273
|
-
|
|
274
|
-
|
|
143
|
+
| Import | Contents |
|
|
144
|
+
|--------|----------|
|
|
145
|
+
| `@xrystal/dto` | Everything |
|
|
146
|
+
| `@xrystal/dto/dto` | `DTO`, `createDTO`, runtime helpers |
|
|
147
|
+
| `@xrystal/dto/response` | `ResponseDTO`, `Builder`, presets |
|
|
148
|
+
| `@xrystal/dto/log` | `LogDTO` |
|
|
149
|
+
| `@xrystal/dto/queue` | `QueueDTO` |
|
|
150
|
+
| `@xrystal/dto/express` | Express middleware |
|
|
275
151
|
|
|
276
152
|
---
|
|
277
153
|
|
|
278
|
-
##
|
|
279
|
-
|
|
280
|
-
### 1. Presets (recommended for most handlers)
|
|
281
|
-
|
|
282
|
-
Call `.as.*` — dispatches automatically. **Do not call `.build()`.**
|
|
154
|
+
## Response schema
|
|
283
155
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
```
|
|
156
|
+
| Field | Meaning |
|
|
157
|
+
|-------|---------|
|
|
158
|
+
| `status` | Business success/failure (`boolean`) — independent from HTTP status |
|
|
159
|
+
| `message` | Human-readable summary |
|
|
160
|
+
| `payload` | Optional wrapper (`data`, `meta`, pagination, …) |
|
|
161
|
+
| `code` | App code — default `0` SUCCESS, `1` CLIENT, `2` SERVER, `3` VALIDATION |
|
|
305
162
|
|
|
306
|
-
|
|
163
|
+
Transport (HTTP status, headers, cookies) lives on **`builder`**, not on `DTO.Response`.
|
|
307
164
|
|
|
308
|
-
|
|
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()` |
|
|
165
|
+
---
|
|
313
166
|
|
|
314
|
-
|
|
167
|
+
## Handler API
|
|
315
168
|
|
|
316
|
-
|
|
169
|
+
### Presets (recommended)
|
|
317
170
|
|
|
318
171
|
```typescript
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
.code(4)
|
|
324
|
-
.message('Slow down')
|
|
325
|
-
.data({ retryAfter: 60 })
|
|
326
|
-
.payload({ meta: { requestId: req.ctx.requestId } })
|
|
327
|
-
.build()
|
|
172
|
+
res.builder.as.ok(data, 'Done')
|
|
173
|
+
res.builder.as.fail('Not found')
|
|
174
|
+
res.builder.as.notFound('User missing')
|
|
175
|
+
res.builder.as.tooManyRequests('Slow down')
|
|
328
176
|
```
|
|
329
177
|
|
|
330
|
-
|
|
178
|
+
### Manual chain
|
|
179
|
+
|
|
180
|
+
End with `.build()` when you need headers, cookies, or fine control:
|
|
331
181
|
|
|
332
182
|
```typescript
|
|
333
183
|
return res.builder
|
|
@@ -337,324 +187,109 @@ return res.builder
|
|
|
337
187
|
status: false,
|
|
338
188
|
message: 'Slow down',
|
|
339
189
|
code: 4,
|
|
340
|
-
payload: {
|
|
341
|
-
data: { retryAfter: 60 },
|
|
342
|
-
meta: { requestId: req.ctx.requestId },
|
|
343
|
-
},
|
|
190
|
+
payload: { data: { retryAfter: 60 } },
|
|
344
191
|
})
|
|
345
192
|
.build()
|
|
346
193
|
```
|
|
347
194
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
|
351
|
-
|
|
352
|
-
|
|
|
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 |
|
|
195
|
+
| Chain rule | Detail |
|
|
196
|
+
|------------|--------|
|
|
197
|
+
| Last call wins | Later `.schema()` or `.status()` overrides earlier values |
|
|
198
|
+
| `.schema()` is body-only | HTTP status and headers stay on the chain |
|
|
199
|
+
| `payload` in `.schema()` | Replaces entire payload — no merge |
|
|
369
200
|
|
|
370
201
|
---
|
|
371
202
|
|
|
372
|
-
##
|
|
203
|
+
## Shape DTOs
|
|
373
204
|
|
|
374
|
-
|
|
205
|
+
### Response (`DTO.Response`)
|
|
375
206
|
|
|
376
|
-
|
|
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 |
|
|
207
|
+
Body only — `status → message → payload? → code`.
|
|
463
208
|
|
|
464
209
|
```typescript
|
|
465
|
-
|
|
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
|
-
})
|
|
210
|
+
DTO.Response.status(true).message('OK').data({ id: 1 }).build()
|
|
474
211
|
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
})
|
|
212
|
+
DTO.Response.schema({
|
|
213
|
+
status: false,
|
|
214
|
+
message: 'Not found',
|
|
215
|
+
code: 1,
|
|
216
|
+
}).build()
|
|
495
217
|
```
|
|
496
218
|
|
|
497
|
-
|
|
219
|
+
Inside a handler, `res.builder.dto` exposes the same `ResponseDTO` instance.
|
|
498
220
|
|
|
499
|
-
|
|
500
|
-
import { createServer } from 'node:http'
|
|
501
|
-
import { DTO } from '@xrystal/dto'
|
|
221
|
+
### Log (`DTO.Log`)
|
|
502
222
|
|
|
503
|
-
|
|
223
|
+
Order: `level → service → message? → payload? → code? → timestamp → id? → env`
|
|
504
224
|
|
|
505
|
-
|
|
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:
|
|
225
|
+
Required: `level`, `service`, `env`.
|
|
516
226
|
|
|
517
227
|
```typescript
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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)
|
|
228
|
+
DTO.Log.level('info')
|
|
229
|
+
.service('users-api')
|
|
230
|
+
.env(NodeEnvEnum.PROD)
|
|
231
|
+
.message('User fetched')
|
|
232
|
+
.build()
|
|
535
233
|
```
|
|
536
234
|
|
|
537
|
-
|
|
235
|
+
### Queue (`DTO.Queue`)
|
|
538
236
|
|
|
539
|
-
|
|
237
|
+
Order: `source → eventType → timestamp → correlationId → userId? → extraData?`
|
|
540
238
|
|
|
541
|
-
|
|
239
|
+
Required: `source`, `eventType`. Timestamp and correlationId auto-generate.
|
|
542
240
|
|
|
543
241
|
```typescript
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
)
|
|
242
|
+
DTO.Queue.source('billing')
|
|
243
|
+
.eventType('invoice.paid')
|
|
244
|
+
.payload({ invoiceId: 9 })
|
|
245
|
+
.build()
|
|
557
246
|
```
|
|
558
247
|
|
|
559
|
-
|
|
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
|
-
```
|
|
248
|
+
---
|
|
570
249
|
|
|
571
|
-
|
|
250
|
+
## Runtime config
|
|
572
251
|
|
|
573
252
|
```typescript
|
|
574
|
-
import { DTO } from '@xrystal/dto'
|
|
253
|
+
import { DTO, definePlugin } from '@xrystal/dto'
|
|
575
254
|
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
},
|
|
255
|
+
const audit = definePlugin({
|
|
256
|
+
name: 'audit',
|
|
257
|
+
afterDispatch(result, ctx) {
|
|
258
|
+
console.log(ctx.context?.requestId, result.statusCode)
|
|
587
259
|
},
|
|
588
260
|
})
|
|
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
261
|
|
|
611
262
|
const runtime = new DTO({
|
|
612
263
|
withDefaultHeaders: true,
|
|
613
264
|
codes: { RATE_LIMITED: 4 },
|
|
614
|
-
|
|
265
|
+
plugins: [audit],
|
|
266
|
+
socket: { event: 'api:response' },
|
|
267
|
+
}).use(otherPlugin) // immutable — returns new DTO
|
|
615
268
|
|
|
616
|
-
const app = express()
|
|
617
269
|
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)
|
|
270
|
+
io.use(runtime.socketIo())
|
|
651
271
|
```
|
|
652
272
|
|
|
273
|
+
| Option | Affects |
|
|
274
|
+
|--------|---------|
|
|
275
|
+
| `plugins` | `builder` pipeline only |
|
|
276
|
+
| `codes` | `builder` presets and `.code()` |
|
|
277
|
+
| `withDefaultHeaders` | HTTP middleware |
|
|
278
|
+
| `socket.event` | WebSocket / Socket.IO emit name |
|
|
279
|
+
|
|
653
280
|
---
|
|
654
281
|
|
|
655
|
-
##
|
|
282
|
+
## Runtimes
|
|
656
283
|
|
|
657
|
-
|
|
284
|
+
```typescript
|
|
285
|
+
runtime.express()
|
|
286
|
+
runtime.node()
|
|
287
|
+
runtime.elysia()
|
|
288
|
+
runtime.websocket() / runtime.attachWebSocket(ws)
|
|
289
|
+
runtime.socketIo() / runtime.attachSocketIo(socket)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Errors in handlers
|
|
658
293
|
|
|
659
294
|
```typescript
|
|
660
295
|
try {
|
|
@@ -665,60 +300,66 @@ try {
|
|
|
665
300
|
}
|
|
666
301
|
```
|
|
667
302
|
|
|
668
|
-
Built-in
|
|
303
|
+
Built-in: `ClientError`, `ServerError`, `ValidationError`.
|
|
669
304
|
|
|
670
305
|
---
|
|
671
306
|
|
|
672
|
-
##
|
|
307
|
+
## Full-stack sketch
|
|
673
308
|
|
|
674
309
|
```typescript
|
|
675
|
-
|
|
310
|
+
import express from 'express'
|
|
311
|
+
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
676
312
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
.status(false)
|
|
681
|
-
.message('Too many requests')
|
|
682
|
-
.build()
|
|
313
|
+
const runtime = new DTO({ withDefaultHeaders: true })
|
|
314
|
+
const app = express()
|
|
315
|
+
app.use(runtime.express())
|
|
683
316
|
|
|
684
|
-
|
|
685
|
-
|
|
317
|
+
app.get('/users/:id', async (req, res) => {
|
|
318
|
+
const user = await db.findUser(req.params.id)
|
|
319
|
+
if (!user) return res.builder.as.notFound('User not found')
|
|
320
|
+
|
|
321
|
+
console.log(
|
|
322
|
+
DTO.Log.level('info')
|
|
323
|
+
.service('users-api')
|
|
324
|
+
.env(NodeEnvEnum.PROD)
|
|
325
|
+
.payload({ userId: user.id })
|
|
326
|
+
.build(),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return res.builder.as.ok(user, 'User fetched', {
|
|
330
|
+
meta: { requestId: req.ctx.requestId },
|
|
331
|
+
})
|
|
332
|
+
})
|
|
686
333
|
```
|
|
687
334
|
|
|
688
335
|
---
|
|
689
336
|
|
|
690
|
-
##
|
|
691
|
-
|
|
692
|
-
**Source of truth:** `package.json` `"version"`. Git tag must be **identical** — no env override.
|
|
337
|
+
## HTTP presets reference
|
|
693
338
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
339
|
+
| Preset | HTTP | Code |
|
|
340
|
+
|--------|------|------|
|
|
341
|
+
| `.ok()` | 200 | SUCCESS |
|
|
342
|
+
| `.created()` | 201 | SUCCESS |
|
|
343
|
+
| `.fail()` | 400 | CLIENT |
|
|
344
|
+
| `.notFound()` | 404 | CLIENT |
|
|
345
|
+
| `.unprocessable()` | 422 | VALIDATION |
|
|
346
|
+
| `.tooManyRequests()` | 429 | CLIENT |
|
|
347
|
+
| `.error()` | 500 | SERVER |
|
|
697
348
|
|
|
698
|
-
|
|
349
|
+
See source `dto/response/presets.ts` for the full list.
|
|
699
350
|
|
|
700
|
-
|
|
701
|
-
npm run tag
|
|
702
|
-
```
|
|
351
|
+
---
|
|
703
352
|
|
|
704
|
-
|
|
353
|
+
## Release (maintainers)
|
|
705
354
|
|
|
706
|
-
|
|
355
|
+
Version lives in `package.json` (`YY.MM.DD-vN`). Git tag must match exactly.
|
|
707
356
|
|
|
708
357
|
```bash
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
npm run
|
|
358
|
+
npm run release # lint → test → build → commit → tag → push
|
|
359
|
+
npm run tag # tag only (after manual commit)
|
|
360
|
+
npm run version:check
|
|
712
361
|
```
|
|
713
362
|
|
|
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
363
|
---
|
|
723
364
|
|
|
724
365
|
MIT
|