@xrystal/dto 26.6.27-v12 → 26.6.27-v14
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 +405 -529
- package/dist/adapters/elysia.cjs +245 -221
- package/dist/adapters/elysia.js +244 -222
- package/dist/adapters/express.cjs +231 -219
- package/dist/adapters/express.js +231 -220
- package/dist/{core/index.js → adapters/fetch.cjs} +276 -430
- package/dist/{core/index.cjs → adapters/fetch.js} +271 -447
- package/dist/adapters/node.cjs +251 -220
- package/dist/adapters/node.js +251 -221
- package/dist/adapters/socketio.cjs +198 -189
- package/dist/adapters/socketio.js +198 -190
- package/dist/adapters/websocket.cjs +201 -189
- package/dist/adapters/websocket.js +201 -190
- package/dist/dto/adapters/bind.d.ts +40 -0
- package/dist/dto/adapters/elysia.d.ts +1 -0
- package/dist/dto/adapters/express.d.ts +1 -0
- package/dist/dto/adapters/fetch.d.ts +1 -0
- package/dist/dto/adapters/http.d.ts +231 -0
- package/dist/dto/adapters/node.d.ts +1 -0
- package/dist/dto/adapters/realtime.d.ts +53 -0
- package/dist/dto/adapters/socketio.d.ts +1 -0
- package/dist/dto/adapters/websocket.d.ts +1 -0
- package/dist/{core/context.d.ts → dto/factories.d.ts} +6 -0
- package/dist/dto/index.cjs +734 -228
- package/dist/dto/index.d.ts +5 -6
- package/dist/dto/index.js +711 -229
- package/dist/dto/log/index.cjs +20 -39
- package/dist/dto/log/index.js +20 -39
- package/dist/dto/queue/index.cjs +20 -33
- package/dist/dto/queue/index.js +20 -33
- package/dist/dto/response/index.cjs +663 -44
- package/dist/dto/response/index.js +644 -45
- package/dist/dto/runtime/{dto.cjs → index.cjs} +544 -475
- package/dist/dto/runtime/index.d.ts +71 -0
- package/dist/dto/runtime/{dto.js → index.js} +541 -476
- package/dist/{core → dto/send}/builder.d.ts +9 -8
- package/dist/dto/send/index.d.ts +7 -0
- package/dist/{core → dto/send}/plugins.d.ts +2 -2
- package/dist/dto/send/presets.d.ts +34 -0
- package/dist/dto/send/transport.d.ts +50 -0
- package/dist/dto/{log/builder.d.ts → shapes/log.d.ts} +28 -12
- package/dist/dto/{queue/builder.d.ts → shapes/queue.d.ts} +36 -9
- package/dist/dto/{response/dto.d.ts → shapes/response.d.ts} +8 -6
- package/dist/{core/config.d.ts → dto/shared.d.ts} +5 -0
- package/dist/index.cjs +721 -614
- package/dist/index.d.ts +12 -9
- package/dist/index.js +704 -615
- package/dist/types/builder.d.ts +2 -1
- package/dist/types/log.d.ts +1 -1
- package/dist/types/queue.d.ts +1 -1
- package/package.json +59 -59
- package/dist/adapters/elysia.d.ts +0 -138
- package/dist/adapters/express.d.ts +0 -49
- package/dist/adapters/index.d.ts +0 -7
- package/dist/adapters/node.d.ts +0 -27
- package/dist/adapters/shared.d.ts +0 -11
- package/dist/adapters/socketio.d.ts +0 -31
- package/dist/adapters/websocket.d.ts +0 -31
- 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/hooks.d.ts +0 -8
- package/dist/core/index.d.ts +0 -14
- package/dist/core/log-dto.d.ts +0 -1
- package/dist/core/metadata.d.ts +0 -6
- package/dist/core/presets.d.ts +0 -32
- 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/dist/dto/log/assemble.d.ts +0 -3
- package/dist/dto/log/index.d.ts +0 -3
- package/dist/dto/log/types.d.ts +0 -26
- package/dist/dto/queue/assemble.d.ts +0 -2
- package/dist/dto/queue/index.d.ts +0 -3
- package/dist/dto/queue/types.d.ts +0 -34
- package/dist/dto/response/assemble.d.ts +0 -1
- package/dist/dto/response/index.d.ts +0 -2
- package/dist/dto/runtime/dto.d.ts +0 -64
- package/dist/dto/shared/base-dto.d.ts +0 -12
- package/dist/dto/shared/schema-utils.d.ts +0 -1
- /package/dist/{enums/node-env.d.ts → utils/constants/enums.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,724 +1,600 @@
|
|
|
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
|
|
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 |
|
|
19
|
+
### Three concepts
|
|
60
20
|
|
|
61
|
-
|
|
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 |
|
|
62
26
|
|
|
63
|
-
|
|
64
|
-
- `.schema()` for partial input — **last call wins**
|
|
65
|
-
- `payload` in `.schema()` **replaces** the entire payload (no merge)
|
|
27
|
+
**Rule of thumb:** client cevabı → `builder`. Düz JSON üretmek → static `DTO.*`.
|
|
66
28
|
|
|
67
|
-
|
|
29
|
+
### 1. Wire the runtime (once)
|
|
68
30
|
|
|
69
|
-
|
|
31
|
+
```typescript
|
|
32
|
+
import express from 'express'
|
|
33
|
+
import { DTO } from '@xrystal/dto'
|
|
70
34
|
|
|
71
|
-
|
|
35
|
+
const runtime = new DTO({ withDefaultHeaders: true })
|
|
36
|
+
const app = express()
|
|
37
|
+
app.use(runtime.express())
|
|
38
|
+
```
|
|
72
39
|
|
|
73
|
-
This
|
|
40
|
+
This attaches `req.ctx` and `res.builder` on every request.
|
|
74
41
|
|
|
75
|
-
###
|
|
42
|
+
### 2. Respond in a handler
|
|
76
43
|
|
|
77
|
-
|
|
44
|
+
Every chain ends with a **verb** — `.send()` (go to client) or `.build()` (schema only):
|
|
78
45
|
|
|
79
46
|
```typescript
|
|
80
|
-
// Express / Node — res.builder
|
|
81
47
|
app.get('/users/:id', (req, res) => {
|
|
82
|
-
return res.builder.as.ok(
|
|
83
|
-
|
|
84
|
-
|
|
48
|
+
return res.builder.as.ok(
|
|
49
|
+
{ id: req.params.id },
|
|
50
|
+
'User fetched',
|
|
51
|
+
{ meta: { requestId: req.ctx.requestId } },
|
|
52
|
+
).send()
|
|
85
53
|
})
|
|
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
54
|
```
|
|
99
55
|
|
|
100
|
-
|
|
56
|
+
Sent JSON (fixed order: `status → message → payload? → code`):
|
|
101
57
|
|
|
102
|
-
```
|
|
103
|
-
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"status": true,
|
|
61
|
+
"message": "User fetched",
|
|
62
|
+
"payload": { "data": { "id": "42" }, "meta": { "requestId": "…" } },
|
|
63
|
+
"code": 0
|
|
64
|
+
}
|
|
104
65
|
```
|
|
105
66
|
|
|
106
|
-
|
|
67
|
+
| Verb | What it does |
|
|
68
|
+
|------|----------------|
|
|
69
|
+
| `.send()` | Assembles + **pushes** via send sink (HTTP write, WebSocket emit, …) |
|
|
70
|
+
| `.build()` | Assembles **schema only** — no network side-effect |
|
|
71
|
+
|
|
72
|
+
Presets (`.as.ok`, `.as.notFound`, …) configure the body and return a terminal — you still call `.send()` or `.build()`.
|
|
107
73
|
|
|
108
|
-
|
|
74
|
+
### 3. Static shape DTOs (no request)
|
|
109
75
|
|
|
110
76
|
```typescript
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
77
|
+
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
78
|
+
|
|
79
|
+
// Log line
|
|
80
|
+
DTO.Log.level('info')
|
|
81
|
+
.service('api')
|
|
114
82
|
.env(NodeEnvEnum.PROD)
|
|
115
|
-
.message('
|
|
83
|
+
.message('Server ready')
|
|
116
84
|
.build()
|
|
117
|
-
logger.info(logLine)
|
|
118
85
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
.eventType('
|
|
122
|
-
.payload({
|
|
86
|
+
// Queue message
|
|
87
|
+
DTO.Queue.source('api')
|
|
88
|
+
.eventType('user.created')
|
|
89
|
+
.payload({ id: 1 })
|
|
123
90
|
.build()
|
|
124
|
-
await queue.publish(message)
|
|
125
91
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
.message('Computed')
|
|
129
|
-
.data({ total: 100 })
|
|
130
|
-
.build()
|
|
92
|
+
// Response JSON only (no HTTP)
|
|
93
|
+
DTO.Response.status(true).message('OK').data({ id: 1 }).build()
|
|
131
94
|
```
|
|
132
95
|
|
|
133
|
-
|
|
96
|
+
Every shape DTO shares the same chain rules:
|
|
134
97
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 |
|
|
98
|
+
- Fixed field order
|
|
99
|
+
- `.schema({ … })` for partial input — **last call wins**
|
|
100
|
+
- `payload` in `.schema()` **replaces** the entire payload
|
|
141
101
|
|
|
142
|
-
|
|
102
|
+
### Elysia — same idea
|
|
143
103
|
|
|
144
|
-
|
|
104
|
+
```typescript
|
|
105
|
+
import { Elysia } from 'elysia'
|
|
106
|
+
import { DTO } from '@xrystal/dto'
|
|
107
|
+
|
|
108
|
+
const runtime = new DTO()
|
|
109
|
+
new Elysia()
|
|
110
|
+
.use(runtime.elysia())
|
|
111
|
+
.get('/users/:id', ({ builder, requestId, params }) =>
|
|
112
|
+
builder.as.ok({ id: params.id }, 'User fetched', { meta: { requestId } }).send(),
|
|
113
|
+
)
|
|
114
|
+
```
|
|
145
115
|
|
|
146
|
-
|
|
116
|
+
### Fetch API — Workers, Hono, Bun.serve
|
|
147
117
|
|
|
148
118
|
```typescript
|
|
149
119
|
import { DTO } from '@xrystal/dto'
|
|
150
120
|
|
|
151
|
-
DTO
|
|
152
|
-
.message('User fetched')
|
|
153
|
-
.data({ id: 1 })
|
|
154
|
-
.build()
|
|
121
|
+
const runtime = new DTO({ withDefaultHeaders: true })
|
|
155
122
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
payload: { data: { reason: 'missing' } },
|
|
162
|
-
}).build()
|
|
123
|
+
export async function GET(request: Request) {
|
|
124
|
+
const { builder, requestId } = runtime.forRequest(request)
|
|
125
|
+
return builder.as.ok({ ok: true }, 'Healthy', { meta: { requestId } }).send()
|
|
126
|
+
// → Web Response
|
|
127
|
+
}
|
|
163
128
|
```
|
|
164
129
|
|
|
165
|
-
|
|
130
|
+
### When to use what
|
|
166
131
|
|
|
167
|
-
|
|
132
|
+
| Situation | Use |
|
|
133
|
+
|-----------|-----|
|
|
134
|
+
| Express / Node / Elysia / Fetch handler | `builder…send()` |
|
|
135
|
+
| WebSocket / Socket.IO | `socket.builder…send()` |
|
|
136
|
+
| Schema only (log, queue, test, transform) | `builder…build()` or `DTO.*.build()` |
|
|
137
|
+
| Cron, worker, logger, queue publish | `DTO.Log`, `DTO.Queue`, `DTO.Response` |
|
|
138
|
+
| Custom HTTP status + headers | `builder.httpStatus(429).header(...).schema({...}).send()` |
|
|
168
139
|
|
|
169
|
-
|
|
140
|
+
Plugins and custom `codes` on `new DTO({ … })` apply **only to `builder`** — not to static `DTO.Log` / `DTO.Queue`.
|
|
170
141
|
|
|
171
|
-
|
|
142
|
+
---
|
|
172
143
|
|
|
173
|
-
|
|
144
|
+
## Adapters & send sinks
|
|
174
145
|
|
|
175
|
-
|
|
176
|
-
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
146
|
+
Each runtime has an **adapter** (wires `builder` per request) and a **send sink** (writes `FinalResult` to the transport on `.send()`).
|
|
177
147
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.payload({ userId: 1 })
|
|
183
|
-
.build()
|
|
148
|
+
| Piece | Role |
|
|
149
|
+
|-------|------|
|
|
150
|
+
| **Adapter** | Framework middleware / derive — creates `requestId`, attaches `builder`, applies default headers |
|
|
151
|
+
| **Send sink** | `{ dispatch(result) }` object — one line per runtime, delegates to `send/transport.ts` |
|
|
184
152
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}).build()
|
|
153
|
+
```
|
|
154
|
+
Handler chain Core Transport
|
|
155
|
+
───────────── ───── ─────────
|
|
156
|
+
builder.as.ok() → .build() → FinalResult
|
|
157
|
+
→ .send() → dispatchResult() → sink.dispatch()
|
|
158
|
+
→ send/transport.ts
|
|
159
|
+
→ res.json / Response / ws.send / …
|
|
193
160
|
```
|
|
194
161
|
|
|
195
|
-
|
|
162
|
+
Core dispatch lives in `dto/send/transport.ts`. Adapters pass a sink into `createBuilderRuntime()` — no separate dispatcher classes needed for normal use. `ExpressDispatcher` and friends remain as deprecated aliases for custom wiring.
|
|
196
163
|
|
|
197
|
-
|
|
164
|
+
### Runtime map
|
|
198
165
|
|
|
199
|
-
|
|
166
|
+
| Runtime | Adapter | Sink | Attaches | `.send()` output |
|
|
167
|
+
|---------|---------|------|----------|------------------|
|
|
168
|
+
| **Express** | `ExpressAdapter` | `expressSink(res)` | `req.ctx`, `res.builder` | writes `res.status().json()` |
|
|
169
|
+
| **Node http** | `NodeAdapter` | `nodeSink(res)` | `req.ctx`, `res.builder` | writes `res.end(JSON)` + Set-Cookie |
|
|
170
|
+
| **Fetch API** | `FetchAdapter` | `fetchSink` | `builder`, `requestId` per `Request` | returns `Response` |
|
|
171
|
+
| **Elysia** | `ElysiaAdapter` | `elysiaSink(set)` | `builder`, `requestId`, `startTime` on ctx | mutates `set`, returns body |
|
|
172
|
+
| **WebSocket** | `WebSocketAdapter` | `websocketSink(socket)` | `socket.ctx`, `socket.builder` | `socket.send(wire envelope JSON)` |
|
|
173
|
+
| **Socket.IO** | `SocketIoAdapter` | `socketIoSink(socket, event)` | `socket.ctx`, `socket.builder` | `socket.emit(event, wire envelope)` |
|
|
200
174
|
|
|
201
|
-
|
|
175
|
+
WebSocket and Socket.IO send the full transport envelope (`statusCode`, `headers`, `cookies`, `body`) so `.httpStatus()` and `.header()` are not lost on realtime transports.
|
|
176
|
+
|
|
177
|
+
### `new DTO()` shortcuts
|
|
202
178
|
|
|
203
179
|
```typescript
|
|
204
|
-
|
|
180
|
+
const runtime = new DTO({ withDefaultHeaders: true, socket: { event: 'api:response' } })
|
|
205
181
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
182
|
+
runtime.express() // → Express middleware
|
|
183
|
+
runtime.node() // → Node http middleware
|
|
184
|
+
runtime.elysia() // → Elysia plugin
|
|
185
|
+
runtime.fetch() // → (request: Request) => { builder, requestId, … }
|
|
186
|
+
runtime.forRequest(request) // → one-shot Fetch context
|
|
211
187
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
188
|
+
runtime.websocket() // → (socket, next?) => void
|
|
189
|
+
runtime.attachWebSocket(ws) // → attach builder to one socket
|
|
190
|
+
|
|
191
|
+
runtime.socketIo() // → (socket, next?) => void
|
|
192
|
+
runtime.attachSocketIo(socket) // → attach builder to one socket
|
|
216
193
|
```
|
|
217
194
|
|
|
218
|
-
|
|
195
|
+
### When to import adapters directly
|
|
219
196
|
|
|
220
|
-
|
|
197
|
+
Most apps only need `new DTO().express()` (etc.). Import adapters / sinks when you:
|
|
221
198
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
199
|
+
- Integrate a **custom framework** — `createBuilderRuntime(expressSink(res), options, context)`
|
|
200
|
+
- **Unit-test** transport without full middleware
|
|
201
|
+
- Build an **internal wrapper** (e.g. Fastify plugin)
|
|
225
202
|
|
|
226
|
-
|
|
203
|
+
```typescript
|
|
204
|
+
import { createBuilderRuntime, expressSink, ContextFactory } from '@xrystal/dto'
|
|
227
205
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
206
|
+
// Custom wiring example
|
|
207
|
+
const context = ContextFactory.create(req)
|
|
208
|
+
const { builder } = createBuilderRuntime(expressSink(res), options, context)
|
|
231
209
|
```
|
|
232
210
|
|
|
233
|
-
Subpath imports
|
|
211
|
+
Subpath imports (tree-shake friendly):
|
|
234
212
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
213
|
+
| Import | Contents |
|
|
214
|
+
|--------|----------|
|
|
215
|
+
| `@xrystal/dto/express` | `ExpressAdapter`, `ExpressDispatcher`, `expressMiddleware` |
|
|
216
|
+
| `@xrystal/dto/node` | `NodeAdapter`, `NodeDispatcher`, `nodeMiddleware` |
|
|
217
|
+
| `@xrystal/dto/fetch` | `FetchAdapter`, `FetchDispatcher`, `createFetchDerive` |
|
|
218
|
+
| `@xrystal/dto/elysia` | `ElysiaAdapter`, `ElysiaDispatcher`, `createElysiaDerive` |
|
|
219
|
+
| `@xrystal/dto/websocket` | `WebSocketAdapter`, `WebSocketDispatcher` |
|
|
220
|
+
| `@xrystal/dto/socketio` | `SocketIoAdapter`, `SocketIoDispatcher` |
|
|
243
221
|
|
|
244
222
|
---
|
|
245
223
|
|
|
246
|
-
##
|
|
224
|
+
## Core vs DTO — tek adapter yeter mi?
|
|
247
225
|
|
|
248
|
-
|
|
249
|
-
import express from 'express'
|
|
250
|
-
import { DTO } from '@xrystal/dto'
|
|
226
|
+
Kütüphanenin **iki işi** var; mimari buna göre bölündü:
|
|
251
227
|
|
|
252
|
-
|
|
253
|
-
|
|
228
|
+
| İş | Katman | Adapter gerekir mi? |
|
|
229
|
+
|----|--------|---------------------|
|
|
230
|
+
| **Şema üret** | `shapes/` + `shared.ts` | **Hayır** — `DTO.Log.build()`, `DTO.Response.build()`, testler |
|
|
231
|
+
| **Client'a gönder** | `send/` + `adapters/` | **Evet** — `.send()` bir transport'a yazmalı |
|
|
254
232
|
|
|
255
|
-
app.get('/users/:id', (req, res) => {
|
|
256
|
-
return res.builder.as.ok({ id: req.params.id }, 'User fetched')
|
|
257
|
-
})
|
|
258
233
|
```
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
234
|
+
┌─────────────────────────────────────────────┐
|
|
235
|
+
│ new DTO() — tek giriş noktası │
|
|
236
|
+
│ runtime.express() / .fetch() / … │
|
|
237
|
+
└──────────────────┬──────────────────────────┘
|
|
238
|
+
│
|
|
239
|
+
┌──────────────────▼──────────────────────────┐
|
|
240
|
+
│ adapters/ — sadece runtime wiring │
|
|
241
|
+
│ middleware → res.builder + send sink │
|
|
242
|
+
└──────────────────┬──────────────────────────┘
|
|
243
|
+
│
|
|
244
|
+
┌──────────────────▼──────────────────────────┐
|
|
245
|
+
│ send/ — Builder, presets, transport │
|
|
246
|
+
│ .build() şema | .send() client'a yazar │
|
|
247
|
+
└──────────────────┬──────────────────────────┘
|
|
248
|
+
│
|
|
249
|
+
┌──────────────────▼──────────────────────────┐
|
|
250
|
+
│ shapes/ — ResponseDTO, LogDTO, QueueDTO │
|
|
251
|
+
│ field sırası, .schema() ezme kuralları │
|
|
252
|
+
└─────────────────────────────────────────────┘
|
|
269
253
|
```
|
|
270
254
|
|
|
271
|
-
|
|
255
|
+
### Tek adapter bağlamak yeter mi?
|
|
272
256
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
257
|
+
| Senaryo | Ne yaparsın |
|
|
258
|
+
|---------|-------------|
|
|
259
|
+
| **Normal HTTP app (Express vb.)** | `new DTO().express()` — başka adapter import etmezsin |
|
|
260
|
+
| **Sadece JSON üret (log, queue, test)** | `DTO.Log` / `DTO.Queue` / `builder.build()` — adapter yok |
|
|
261
|
+
| **Custom framework (Fastify vb.)** | `createBuilderRuntime(expressSink(res), …)` veya kendi sink'in |
|
|
262
|
+
| **WS + HTTP aynı app'te** | Her transport için bir kez wire: `.express()` + `.websocket()` |
|
|
277
263
|
|
|
278
|
-
|
|
264
|
+
**Özet:** Handler'dan client'a cevap gönderiyorsan bir adapter (veya sink) şart — ama çoğu app'te `new DTO().express()` tek satır yeter; `ExpressAdapter` import etmezsin. Dispatcher sınıfına gerek yok; adapter içinde `expressSink(res)` gibi tek satırlık `{ dispatch }` objesi var.
|
|
279
265
|
|
|
280
|
-
|
|
266
|
+
| Layer | Import eder misin? | Ne yapar |
|
|
267
|
+
|-------|-------------------|----------|
|
|
268
|
+
| **shapes** | Nadiren (`@xrystal/dto/log`) | Saf JSON şema |
|
|
269
|
+
| **send** | Nadiren (`@xrystal/dto/response`) | Builder, transport, presets |
|
|
270
|
+
| **adapters** | Custom entegrasyon / subpath | `builder`'ı request'e takar |
|
|
271
|
+
| **DTO** | Her zaman | Config + `runtime.express()` shortcut |
|
|
281
272
|
|
|
282
|
-
|
|
273
|
+
### Request lifecycle (Express)
|
|
283
274
|
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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')
|
|
275
|
+
```
|
|
276
|
+
1. Request gelir
|
|
277
|
+
2. ExpressAdapter middleware:
|
|
278
|
+
ContextFactory → requestId
|
|
279
|
+
createBuilderRuntime(expressSink(res), options, ctx)
|
|
280
|
+
res.builder = builder
|
|
281
|
+
3. Handler:
|
|
282
|
+
res.builder.as.ok(data).send()
|
|
283
|
+
→ .build() → FinalResult
|
|
284
|
+
→ .send() → transport.ts → expressSink → res.json()
|
|
304
285
|
```
|
|
305
286
|
|
|
306
|
-
|
|
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()` |
|
|
287
|
+
Şema step 2'de oluşmaz — `.build()` veya `.send()` çağrıldığında assemble edilir.
|
|
313
288
|
|
|
314
|
-
|
|
289
|
+
---
|
|
315
290
|
|
|
316
|
-
|
|
291
|
+
## Architecture
|
|
317
292
|
|
|
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
293
|
```
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
294
|
+
src/dto/
|
|
295
|
+
├── shared.ts BaseDTO, field rules, builder config
|
|
296
|
+
├── factories.ts ContextFactory, MetadataFactory
|
|
297
|
+
├── shapes/ ← görev 1: şema
|
|
298
|
+
│ ├── response.ts
|
|
299
|
+
│ ├── log.ts
|
|
300
|
+
│ └── queue.ts
|
|
301
|
+
├── send/ ← görev 2: build + push
|
|
302
|
+
│ ├── builder.ts
|
|
303
|
+
│ ├── presets.ts
|
|
304
|
+
│ ├── transport.ts
|
|
305
|
+
│ └── plugins.ts
|
|
306
|
+
├── adapters/ runtime wiring
|
|
307
|
+
│ ├── http.ts Express, Node, Fetch, Elysia + *Sink helpers
|
|
308
|
+
│ ├── realtime.ts WebSocket, Socket.IO
|
|
309
|
+
│ ├── bind.ts DTO.express() shortcuts
|
|
310
|
+
│ └── express.ts … @xrystal/dto/express subpath shims
|
|
311
|
+
└── runtime/
|
|
312
|
+
└── index.ts DTO class, Hooks, createBuilderRuntime
|
|
346
313
|
```
|
|
347
314
|
|
|
348
|
-
|
|
315
|
+
Subpath exports (`@xrystal/dto/express`, `/response`, …) unchanged — shims map to the folders above.
|
|
349
316
|
|
|
350
|
-
|
|
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. |
|
|
317
|
+
### Package exports
|
|
357
318
|
|
|
358
|
-
|
|
|
359
|
-
|
|
360
|
-
|
|
|
361
|
-
|
|
|
362
|
-
|
|
|
363
|
-
|
|
|
364
|
-
|
|
|
365
|
-
|
|
|
366
|
-
|
|
|
367
|
-
|
|
|
368
|
-
|
|
|
319
|
+
| Import | Contents |
|
|
320
|
+
|--------|----------|
|
|
321
|
+
| `@xrystal/dto` | Everything (including all adapters & dispatchers) |
|
|
322
|
+
| `@xrystal/dto/dto` | `DTO`, `createDTO`, runtime helpers |
|
|
323
|
+
| `@xrystal/dto/response` | `ResponseDTO`, `Builder`, `PresetTerminal`, transport helpers |
|
|
324
|
+
| `@xrystal/dto/log` | `LogDTO` |
|
|
325
|
+
| `@xrystal/dto/queue` | `QueueDTO` |
|
|
326
|
+
| `@xrystal/dto/express` | Express adapter |
|
|
327
|
+
| `@xrystal/dto/node` | Node http adapter |
|
|
328
|
+
| `@xrystal/dto/fetch` | Fetch API adapter |
|
|
329
|
+
| `@xrystal/dto/elysia` | Elysia adapter |
|
|
330
|
+
| `@xrystal/dto/websocket` | WebSocket adapter |
|
|
331
|
+
| `@xrystal/dto/socketio` | Socket.IO adapter |
|
|
369
332
|
|
|
370
333
|
---
|
|
371
334
|
|
|
372
|
-
##
|
|
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
|
-
```
|
|
335
|
+
## Response schema
|
|
400
336
|
|
|
401
|
-
|
|
337
|
+
| Field | Meaning |
|
|
338
|
+
|-------|---------|
|
|
339
|
+
| `status` | Business success/failure (`boolean`) — independent from HTTP status |
|
|
340
|
+
| `message` | Human-readable summary |
|
|
341
|
+
| `payload` | Optional wrapper (`data`, `meta`, pagination, …) |
|
|
342
|
+
| `code` | App code — default `0` SUCCESS, `1` CLIENT, `2` SERVER, `3` VALIDATION |
|
|
402
343
|
|
|
403
|
-
|
|
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 |
|
|
344
|
+
Transport (HTTP status, headers, cookies) lives on **`builder`**, not on `DTO.Response`.
|
|
409
345
|
|
|
410
346
|
---
|
|
411
347
|
|
|
412
|
-
##
|
|
348
|
+
## Handler API
|
|
413
349
|
|
|
414
|
-
|
|
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 |
|
|
350
|
+
### Presets (recommended)
|
|
422
351
|
|
|
423
|
-
|
|
352
|
+
Presets return a terminal — always end with `.send()` or `.build()`:
|
|
424
353
|
|
|
425
|
-
|
|
354
|
+
```typescript
|
|
355
|
+
return res.builder.as.ok(data, 'Done').send()
|
|
356
|
+
return res.builder.as.fail('Not found').send()
|
|
357
|
+
return res.builder.as.notFound('User missing').send()
|
|
358
|
+
return res.builder.as.tooManyRequests('Slow down').send()
|
|
426
359
|
|
|
427
|
-
|
|
360
|
+
// Headers before preset
|
|
361
|
+
return res.builder.header('X-Trace', traceId).as.ok(data).send()
|
|
362
|
+
```
|
|
428
363
|
|
|
429
|
-
###
|
|
364
|
+
### Manual chain
|
|
430
365
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
366
|
+
```typescript
|
|
367
|
+
return res.builder
|
|
368
|
+
.httpStatus(429)
|
|
369
|
+
.header('Retry-After', '60')
|
|
370
|
+
.schema({
|
|
371
|
+
status: false,
|
|
372
|
+
message: 'Slow down',
|
|
373
|
+
code: 4,
|
|
374
|
+
payload: { data: { retryAfter: 60 } },
|
|
375
|
+
})
|
|
376
|
+
.send()
|
|
377
|
+
```
|
|
440
378
|
|
|
441
|
-
|
|
379
|
+
Schema only (no dispatch):
|
|
442
380
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
| `.gatewayTimeout(…)` | 504 | SERVER |
|
|
381
|
+
```typescript
|
|
382
|
+
const result = res.builder.schema({ payload: { data: user } }).build()
|
|
383
|
+
await auditLog.write(result.body)
|
|
384
|
+
```
|
|
448
385
|
|
|
449
|
-
|
|
386
|
+
| Chain rule | Detail |
|
|
387
|
+
|------------|--------|
|
|
388
|
+
| Last call wins | Later `.schema()` or `.status()` overrides earlier values |
|
|
389
|
+
| `.schema()` is body-only | HTTP status and headers stay on the chain |
|
|
390
|
+
| `payload` in `.schema()` | Replaces entire payload — no merge |
|
|
391
|
+
| Preset terminal | `.as.*()` exposes only `.build()` and `.send()` |
|
|
450
392
|
|
|
451
393
|
---
|
|
452
394
|
|
|
453
|
-
##
|
|
395
|
+
## Shape DTOs
|
|
454
396
|
|
|
455
|
-
|
|
397
|
+
### Response (`DTO.Response`)
|
|
456
398
|
|
|
457
|
-
|
|
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 |
|
|
399
|
+
Body only — `status → message → payload? → code`.
|
|
463
400
|
|
|
464
401
|
```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
|
-
})
|
|
402
|
+
DTO.Response.status(true).message('OK').data({ id: 1 }).build()
|
|
474
403
|
|
|
475
|
-
|
|
476
|
-
|
|
404
|
+
DTO.Response.schema({
|
|
405
|
+
status: false,
|
|
406
|
+
message: 'Not found',
|
|
407
|
+
code: 1,
|
|
408
|
+
}).build()
|
|
477
409
|
```
|
|
478
410
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
## Runtime guides
|
|
411
|
+
Inside a handler, `res.builder.dto` exposes the same `ResponseDTO` instance.
|
|
482
412
|
|
|
483
|
-
###
|
|
413
|
+
### Log (`DTO.Log`)
|
|
484
414
|
|
|
485
|
-
|
|
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
|
-
```
|
|
415
|
+
Order: `level → service → message? → payload? → code? → timestamp → id? → env`
|
|
496
416
|
|
|
497
|
-
|
|
417
|
+
Required: `level`, `service`, `env`.
|
|
498
418
|
|
|
499
419
|
```typescript
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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)
|
|
420
|
+
DTO.Log.level('info')
|
|
421
|
+
.service('users-api')
|
|
422
|
+
.env(NodeEnvEnum.PROD)
|
|
423
|
+
.message('User fetched')
|
|
424
|
+
.build()
|
|
511
425
|
```
|
|
512
426
|
|
|
513
|
-
###
|
|
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'
|
|
427
|
+
### Queue (`DTO.Queue`)
|
|
520
428
|
|
|
521
|
-
|
|
429
|
+
Order: `source → eventType → timestamp → correlationId → userId? → extraData?`
|
|
522
430
|
|
|
523
|
-
|
|
524
|
-
dto.node()(req, res, () => {
|
|
525
|
-
if (req.url === '/health') return res.builder?.as.ok({ ok: true }, 'Up')
|
|
431
|
+
Required: `source`, `eventType`. Timestamp and correlationId auto-generate.
|
|
526
432
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
.build()
|
|
533
|
-
})
|
|
534
|
-
}).listen(3000)
|
|
433
|
+
```typescript
|
|
434
|
+
DTO.Queue.source('billing')
|
|
435
|
+
.eventType('invoice.paid')
|
|
436
|
+
.payload({ invoiceId: 9 })
|
|
437
|
+
.build()
|
|
535
438
|
```
|
|
536
439
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
### Elysia
|
|
440
|
+
---
|
|
540
441
|
|
|
541
|
-
|
|
442
|
+
## Runtime config
|
|
542
443
|
|
|
543
444
|
```typescript
|
|
544
|
-
import {
|
|
545
|
-
import { DTO } from '@xrystal/dto'
|
|
445
|
+
import { DTO, definePlugin } from '@xrystal/dto'
|
|
546
446
|
|
|
547
|
-
const
|
|
447
|
+
const audit = definePlugin({
|
|
448
|
+
name: 'audit',
|
|
449
|
+
afterDispatch(result, ctx) {
|
|
450
|
+
console.log(ctx.context?.requestId, result.statusCode)
|
|
451
|
+
},
|
|
452
|
+
})
|
|
548
453
|
|
|
549
|
-
new
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
454
|
+
const runtime = new DTO({
|
|
455
|
+
withDefaultHeaders: true,
|
|
456
|
+
codes: { RATE_LIMITED: 4 },
|
|
457
|
+
plugins: [audit],
|
|
458
|
+
socket: { event: 'api:response' },
|
|
459
|
+
}).use(otherPlugin) // immutable — returns new DTO
|
|
460
|
+
|
|
461
|
+
app.use(runtime.express())
|
|
462
|
+
io.use(runtime.socketIo())
|
|
557
463
|
```
|
|
558
464
|
|
|
559
|
-
|
|
465
|
+
| Option | Affects |
|
|
466
|
+
|--------|---------|
|
|
467
|
+
| `plugins` | `builder` pipeline only (`beforeDispatch` runs on `.send()`, not `.build()`) |
|
|
468
|
+
| `codes` | `builder` presets and `.code()` |
|
|
469
|
+
| `withDefaultHeaders` | HTTP / Fetch middleware |
|
|
470
|
+
| `socket.event` | Socket.IO emit name |
|
|
560
471
|
|
|
561
|
-
|
|
562
|
-
console.log(
|
|
563
|
-
DTO.Log.level('info')
|
|
564
|
-
.service('api')
|
|
565
|
-
.env(NodeEnvEnum.PROD)
|
|
566
|
-
.message('Server listening')
|
|
567
|
-
.build(),
|
|
568
|
-
)
|
|
569
|
-
```
|
|
472
|
+
---
|
|
570
473
|
|
|
571
|
-
|
|
474
|
+
## Runtimes
|
|
572
475
|
|
|
573
476
|
```typescript
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
},
|
|
587
|
-
},
|
|
588
|
-
})
|
|
477
|
+
runtime.express()
|
|
478
|
+
runtime.node()
|
|
479
|
+
runtime.elysia()
|
|
480
|
+
runtime.fetch() / runtime.forRequest(request)
|
|
481
|
+
runtime.websocket() / runtime.attachWebSocket(ws)
|
|
482
|
+
runtime.socketIo() / runtime.attachSocketIo(socket)
|
|
589
483
|
```
|
|
590
484
|
|
|
591
|
-
###
|
|
485
|
+
### Errors in handlers
|
|
592
486
|
|
|
593
487
|
```typescript
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
488
|
+
try {
|
|
489
|
+
const user = await fetchUser(id)
|
|
490
|
+
return res.builder.as.ok(user, 'User fetched').send()
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return res.builder.fromError(err).send()
|
|
493
|
+
}
|
|
599
494
|
```
|
|
600
495
|
|
|
601
|
-
|
|
496
|
+
Built-in: `ClientError`, `ServerError`, `ValidationError`.
|
|
602
497
|
|
|
603
498
|
---
|
|
604
499
|
|
|
605
|
-
## Full-stack
|
|
500
|
+
## Full-stack sketch
|
|
606
501
|
|
|
607
502
|
```typescript
|
|
608
503
|
import express from 'express'
|
|
609
504
|
import { DTO, NodeEnvEnum } from '@xrystal/dto'
|
|
610
505
|
|
|
611
|
-
const runtime = new DTO({
|
|
612
|
-
withDefaultHeaders: true,
|
|
613
|
-
codes: { RATE_LIMITED: 4 },
|
|
614
|
-
})
|
|
615
|
-
|
|
506
|
+
const runtime = new DTO({ withDefaultHeaders: true })
|
|
616
507
|
const app = express()
|
|
617
508
|
app.use(runtime.express())
|
|
618
509
|
|
|
619
510
|
app.get('/users/:id', async (req, res) => {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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()
|
|
511
|
+
const user = await db.findUser(req.params.id)
|
|
512
|
+
if (!user) return res.builder.as.notFound('User not found').send()
|
|
513
|
+
|
|
514
|
+
console.log(
|
|
515
|
+
DTO.Log.level('info')
|
|
516
|
+
.service('users-api')
|
|
517
|
+
.env(NodeEnvEnum.PROD)
|
|
518
|
+
.payload({ userId: user.id })
|
|
519
|
+
.build(),
|
|
520
|
+
)
|
|
648
521
|
|
|
649
|
-
|
|
650
|
-
},
|
|
522
|
+
return res.builder.as.ok(user, 'User fetched', {
|
|
523
|
+
meta: { requestId: req.ctx.requestId },
|
|
524
|
+
}).send()
|
|
525
|
+
})
|
|
651
526
|
```
|
|
652
527
|
|
|
653
528
|
---
|
|
654
529
|
|
|
655
|
-
##
|
|
530
|
+
## HTTP presets reference
|
|
656
531
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
```
|
|
532
|
+
| Preset | HTTP | Code |
|
|
533
|
+
|--------|------|------|
|
|
534
|
+
| `.ok()` | 200 | SUCCESS |
|
|
535
|
+
| `.created()` | 201 | SUCCESS |
|
|
536
|
+
| `.fail()` | 400 | CLIENT |
|
|
537
|
+
| `.notFound()` | 404 | CLIENT |
|
|
538
|
+
| `.unprocessable()` | 422 | VALIDATION |
|
|
539
|
+
| `.tooManyRequests()` | 429 | CLIENT |
|
|
540
|
+
| `.error()` | 500 | SERVER |
|
|
667
541
|
|
|
668
|
-
|
|
542
|
+
See source `dto/send/presets.ts` for the full list.
|
|
669
543
|
|
|
670
544
|
---
|
|
671
545
|
|
|
672
|
-
|
|
546
|
+
### Breaking / API changes
|
|
673
547
|
|
|
674
|
-
|
|
675
|
-
|
|
548
|
+
- **Explicit terminal verbs** — every handler chain must end with `.send()` (dispatch to client) or `.build()` (schema only). No automatic dispatch.
|
|
549
|
+
- **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()`).
|
|
550
|
+
- **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.
|
|
676
551
|
|
|
677
|
-
|
|
678
|
-
.code(4)
|
|
679
|
-
.httpStatus(429)
|
|
680
|
-
.status(false)
|
|
681
|
-
.message('Too many requests')
|
|
682
|
-
.build()
|
|
552
|
+
### New
|
|
683
553
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
554
|
+
- **Fetch API adapter** — `runtime.fetch()`, `runtime.forRequest(request)`, `@xrystal/dto/fetch`. Returns Web `Response` from `.send()`. Works with Workers, Hono, Bun.serve, Next.js route handlers.
|
|
555
|
+
- **Core transport layer** — `dto/send/transport.ts` centralises headers, cookies, JSON body, Fetch `Response`, and realtime wire envelope.
|
|
556
|
+
- **Exported adapters & dispatchers** — `ExpressDispatcher`, `FetchDispatcher`, `ElysiaDispatcher`, … available from main entry and subpaths for custom integrations.
|
|
687
557
|
|
|
688
|
-
|
|
558
|
+
### Fixes & consistency
|
|
689
559
|
|
|
690
|
-
|
|
560
|
+
- **Elysia** — inline dispatcher replaced with `ElysiaDispatcher` class (same pattern as Express/Node/Fetch).
|
|
561
|
+
- **Node http** — `Set-Cookie` support added (was Express-only via `res.cookie`).
|
|
562
|
+
- **Dispatch hooks** — `beforeDispatch` / `afterDispatch` plugins run on `.send()` only, not on `.build()`.
|
|
563
|
+
|
|
564
|
+
### Package surface
|
|
691
565
|
|
|
692
|
-
|
|
566
|
+
| Runtimes | 6 — Express, Node, Fetch, Elysia, WebSocket, Socket.IO |
|
|
567
|
+
| Tests | 77 passing |
|
|
568
|
+
| Subpaths | `/express`, `/node`, `/fetch`, `/elysia`, `/websocket`, `/socketio`, `/response`, `/log`, `/queue`, `/dto` |
|
|
693
569
|
|
|
694
|
-
|
|
570
|
+
### Migration cheat sheet
|
|
695
571
|
|
|
696
|
-
|
|
572
|
+
```typescript
|
|
573
|
+
// Before
|
|
574
|
+
return res.builder.as.ok(data, 'Done')
|
|
697
575
|
|
|
698
|
-
|
|
576
|
+
// After
|
|
577
|
+
return res.builder.as.ok(data, 'Done').send()
|
|
699
578
|
|
|
700
|
-
|
|
701
|
-
|
|
579
|
+
// Schema only (unchanged intent, now explicit)
|
|
580
|
+
const body = res.builder.as.ok(data).build()
|
|
581
|
+
|
|
582
|
+
// WS client — expect envelope now
|
|
583
|
+
// { statusCode, headers, cookies, body: { status, message, … } }
|
|
702
584
|
```
|
|
703
585
|
|
|
704
|
-
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Release (maintainers)
|
|
705
589
|
|
|
706
|
-
|
|
590
|
+
Version lives in `package.json` (`YY.MM.DD-vN`). Git tag must match exactly.
|
|
707
591
|
|
|
708
592
|
```bash
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
npm run
|
|
593
|
+
npm run release # lint → test → build → commit → tag → push
|
|
594
|
+
npm run tag # tag only (after manual commit)
|
|
595
|
+
npm run version:check
|
|
712
596
|
```
|
|
713
597
|
|
|
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
598
|
---
|
|
723
599
|
|
|
724
600
|
MIT
|