dx-server 0.13.0-alpha.1 → 0.13.0-alpha.2
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 +397 -265
- package/lib/body.js +1 -1
- package/lib/body.js.map +1 -0
- package/lib/bodyHelpers.js +22 -9
- package/lib/bodyHelpers.js.map +1 -0
- package/lib/dx.d.ts +1 -1
- package/lib/dx.js +12 -6
- package/lib/dx.js.map +1 -0
- package/lib/dxHelpers.d.ts +2 -1
- package/lib/dxHelpers.js +105 -87
- package/lib/dxHelpers.js.map +1 -0
- package/lib/helpers.js.map +1 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -0
- package/lib/logger.d.ts +3 -2
- package/lib/logger.js +54 -46
- package/lib/logger.js.map +1 -0
- package/lib/router.js +5 -5
- package/lib/router.js.map +1 -0
- package/lib/static.js +4 -3
- package/lib/static.js.map +1 -0
- package/lib/staticHelpers.d.ts +5 -1
- package/lib/staticHelpers.js +150 -134
- package/lib/staticHelpers.js.map +1 -0
- package/lib/stream.d.ts +1 -1
- package/lib/stream.js +11 -5
- package/lib/stream.js.map +1 -0
- package/lib/vendors/contentType.js +7 -30
- package/lib/vendors/contentType.js.map +1 -0
- package/lib/vendors/etag.d.ts +2 -2
- package/lib/vendors/etag.js +15 -25
- package/lib/vendors/etag.js.map +1 -0
- package/lib/vendors/fresh.js +10 -17
- package/lib/vendors/fresh.js.map +1 -0
- package/lib/vendors/mime.js +4 -4
- package/lib/vendors/mime.js.map +1 -0
- package/lib/vendors/mimeDb.d.ts +2544 -2544
- package/lib/vendors/mimeDb.js +7100 -7079
- package/lib/vendors/mimeDb.js.map +1 -0
- package/lib/vendors/mimeScore.js +10 -11
- package/lib/vendors/mimeScore.js.map +1 -0
- package/lib/vendors/rangeParser.d.ts +2 -10
- package/lib/vendors/rangeParser.js +16 -29
- package/lib/vendors/rangeParser.js.map +1 -0
- package/package.json +32 -27
package/README.md
CHANGED
|
@@ -5,6 +5,13 @@ A modern, unopinionated, and performant Node.js server framework built on AsyncL
|
|
|
5
5
|
[](https://www.npmjs.com/package/dx-server)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
+
## Important caveats
|
|
9
|
+
|
|
10
|
+
- **ETag is supported.** Static file serving defaults to a weak (mtime/size-based) ETag, while other responses (`setJson`, `setHtml`, `setText`, `setBuffer`, …) use a strong (content-based) ETag. Redirects get no ETag.
|
|
11
|
+
- **Request compression is supported.** Request bodies with `Content-Encoding: gzip`, `deflate`, or `br` are decompressed automatically.
|
|
12
|
+
- **Response compression is NOT supported.** dx-server never sets `Content-Encoding` on responses — handle response compression at the reverse proxy / CDN level.
|
|
13
|
+
- **Static file serving supports Range requests.**
|
|
14
|
+
|
|
8
15
|
## Features
|
|
9
16
|
|
|
10
17
|
- 🎯 **Elegant API interface** - No need to pass req/res objects through middleware chains
|
|
@@ -46,11 +53,10 @@ To check if your runtime supports URLPattern natively:
|
|
|
46
53
|
|
|
47
54
|
```javascript
|
|
48
55
|
if (typeof URLPattern === 'undefined') {
|
|
49
|
-
|
|
56
|
+
console.log('URLPattern not supported, polyfill required')
|
|
50
57
|
}
|
|
51
58
|
```
|
|
52
59
|
|
|
53
|
-
|
|
54
60
|
## Quick Start
|
|
55
61
|
|
|
56
62
|
### Basic Server
|
|
@@ -60,54 +66,60 @@ import {Server} from 'node:http'
|
|
|
60
66
|
import chain from 'jchain'
|
|
61
67
|
import dxServer, {getReq, getRes, router, setHtml, setText} from 'dx-server'
|
|
62
68
|
|
|
63
|
-
new Server()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
new Server()
|
|
70
|
+
.on('request', (req, res) =>
|
|
71
|
+
chain(
|
|
72
|
+
dxServer(req, res),
|
|
73
|
+
async next => {
|
|
74
|
+
try {
|
|
75
|
+
// Access req/res from anywhere - no prop drilling!
|
|
76
|
+
getRes().setHeader('Cache-Control', 'no-cache')
|
|
77
|
+
console.log(getReq().method, getReq().url)
|
|
78
|
+
await next()
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(e)
|
|
81
|
+
setHtml('internal server error', {status: 500})
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
router.get({
|
|
85
|
+
'/'() {
|
|
86
|
+
setHtml('hello world')
|
|
87
|
+
},
|
|
88
|
+
'/health'() {
|
|
89
|
+
setText('ok')
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
() => setHtml('not found', {status: 404}),
|
|
93
|
+
)(),
|
|
94
|
+
)
|
|
95
|
+
.listen(3000, () => console.log('server is listening at 3000'))
|
|
82
96
|
```
|
|
83
97
|
|
|
84
|
-
###
|
|
98
|
+
### Custom Contexts
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
import {Server} from 'node:http'
|
|
88
|
-
import chain from 'jchain'
|
|
89
|
-
import dxServer, {router, setJson, getJson} from 'dx-server'
|
|
100
|
+
Create reusable context objects with `makeDxContext`:
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
```javascript
|
|
103
|
+
import {makeDxContext, getReq} from 'dx-server'
|
|
104
|
+
|
|
105
|
+
// Create auth context
|
|
106
|
+
const authContext = makeDxContext(async () => {
|
|
107
|
+
const token = getReq().headers.authorization
|
|
108
|
+
if (!token) return null
|
|
109
|
+
return await validateToken(token) // Your validation logic
|
|
110
|
+
})
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
}),
|
|
109
|
-
() => setJson({error: 'Not found'}, {status: 404})
|
|
110
|
-
)()).listen(3000)
|
|
112
|
+
// Use in middleware
|
|
113
|
+
chain(
|
|
114
|
+
authContext.chain(), // Initialize for all requests
|
|
115
|
+
next => {
|
|
116
|
+
if (!authContext.value) {
|
|
117
|
+
setJson({error: 'Unauthorized'}, {status: 401})
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
return next()
|
|
121
|
+
},
|
|
122
|
+
)
|
|
111
123
|
```
|
|
112
124
|
|
|
113
125
|
### Static File Server
|
|
@@ -118,13 +130,17 @@ import chain from 'jchain'
|
|
|
118
130
|
import dxServer, {chainStatic, setHtml} from 'dx-server'
|
|
119
131
|
import {resolve} from 'node:path'
|
|
120
132
|
|
|
121
|
-
new Server()
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
133
|
+
new Server()
|
|
134
|
+
.on('request', (req, res) =>
|
|
135
|
+
chain(
|
|
136
|
+
dxServer(req, res),
|
|
137
|
+
chainStatic('/*', {
|
|
138
|
+
root: resolve(import.meta.dirname, 'public'),
|
|
139
|
+
}),
|
|
140
|
+
() => setHtml('not found', {status: 404}),
|
|
141
|
+
)(),
|
|
142
|
+
)
|
|
143
|
+
.listen(3000)
|
|
128
144
|
```
|
|
129
145
|
|
|
130
146
|
### Production-Ready Server
|
|
@@ -134,89 +150,124 @@ import {Server} from 'node:http'
|
|
|
134
150
|
import {promisify} from 'node:util'
|
|
135
151
|
import chain from 'jchain'
|
|
136
152
|
import dxServer, {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
getReq,
|
|
154
|
+
getRes,
|
|
155
|
+
getBuffer,
|
|
156
|
+
getJson,
|
|
157
|
+
getRaw,
|
|
158
|
+
getText,
|
|
159
|
+
getUrlEncoded,
|
|
160
|
+
getQuery,
|
|
161
|
+
setHtml,
|
|
162
|
+
setJson,
|
|
163
|
+
setText,
|
|
164
|
+
setEmpty,
|
|
165
|
+
setBuffer,
|
|
166
|
+
setRedirect,
|
|
167
|
+
setNodeStream,
|
|
168
|
+
setWebStream,
|
|
169
|
+
setFile,
|
|
170
|
+
router,
|
|
171
|
+
chainStatic,
|
|
172
|
+
makeDxContext,
|
|
141
173
|
} from 'dx-server'
|
|
142
174
|
import {resolve} from 'node:path'
|
|
143
175
|
|
|
144
176
|
// it is best practice to create custom error class for non-system error
|
|
145
177
|
class ServerError extends Error {
|
|
146
|
-
|
|
178
|
+
name = 'ServerError'
|
|
147
179
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
180
|
+
constructor(message, status = 400, code = 'unknown') {
|
|
181
|
+
super(message)
|
|
182
|
+
this.status = status
|
|
183
|
+
this.code = code
|
|
184
|
+
}
|
|
153
185
|
}
|
|
154
186
|
|
|
155
187
|
const authContext = makeDxContext(async () => {
|
|
156
|
-
|
|
188
|
+
if (getReq().headers.authorization) return {id: 1, name: 'joe (private)'}
|
|
157
189
|
})
|
|
158
190
|
|
|
159
191
|
function requireAuth() {
|
|
160
|
-
|
|
192
|
+
if (!authContext.value) throw new ServerError('Unauthorized', 401, 'UNAUTHORIZED')
|
|
161
193
|
}
|
|
162
194
|
|
|
163
195
|
const serverChain = chain(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
196
|
+
next => {
|
|
197
|
+
// req, res can be accessed from anywhere via context which uses NodeJS's AsyncLocalStorage under the hood
|
|
198
|
+
getRes().setHeader('Cache-Control', 'no-cache')
|
|
199
|
+
return next() // must return or await
|
|
200
|
+
},
|
|
201
|
+
async next => {
|
|
202
|
+
// global error catching for all following middlewares
|
|
203
|
+
try {
|
|
204
|
+
await next()
|
|
205
|
+
} catch (e) {
|
|
206
|
+
// only app error message should be shown to user
|
|
207
|
+
if (e instanceof ServerError) setHtml(`${e.message} (code: ${e.code})`, {status: e.status})
|
|
208
|
+
else {
|
|
209
|
+
// report system error
|
|
210
|
+
console.error(e)
|
|
211
|
+
setHtml('internal server error (code: internal)', {status: 500})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
// pattern '/public/*' maps a request '/public/x.js' to '<root>/public/x.js', so root is the
|
|
216
|
+
// directory that *contains* the public folder (not the public folder itself).
|
|
217
|
+
chainStatic('/public/*', {root: import.meta.dirname}),
|
|
218
|
+
authContext.chain(), // chain context will set the context value to authContext.value in every request
|
|
219
|
+
router.post('/api/*', async ({next}) => {
|
|
220
|
+
// example of catching error for all /api/* routes
|
|
221
|
+
try {
|
|
222
|
+
await next()
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e instanceof ServerError)
|
|
225
|
+
setJson(
|
|
226
|
+
{
|
|
227
|
+
// only app error message should be shown to user
|
|
228
|
+
error: e.message,
|
|
229
|
+
code: e.code,
|
|
230
|
+
},
|
|
231
|
+
{status: e.status},
|
|
232
|
+
)
|
|
233
|
+
else {
|
|
234
|
+
// report system error
|
|
235
|
+
console.error(e)
|
|
236
|
+
setJson(
|
|
237
|
+
{
|
|
238
|
+
message: 'internal server error',
|
|
239
|
+
code: 'internal',
|
|
240
|
+
},
|
|
241
|
+
{status: 500},
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
router.post({
|
|
247
|
+
'/api/sample-public-api'() {
|
|
248
|
+
// sample POST router
|
|
249
|
+
setJson({name: 'joe'})
|
|
250
|
+
},
|
|
251
|
+
'/api/me'() {
|
|
252
|
+
// sample private router
|
|
253
|
+
requireAuth()
|
|
254
|
+
setJson({name: authContext.value.name})
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
router.get('/', () => setHtml('ok')), // router.method() accepts 2 formats
|
|
258
|
+
router.get('/health', () => setText('ok')),
|
|
259
|
+
() => {
|
|
260
|
+
// not found router
|
|
261
|
+
throw new ServerError('Not found', 404, 'NOT_FOUND')
|
|
262
|
+
},
|
|
213
263
|
)
|
|
214
264
|
|
|
215
|
-
const tcpServer = new Server()
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
265
|
+
const tcpServer = new Server().on('request', (req, res) =>
|
|
266
|
+
chain(
|
|
267
|
+
dxServer(req, res), // basic dx-server context
|
|
268
|
+
serverChain,
|
|
269
|
+
)(),
|
|
270
|
+
)
|
|
220
271
|
|
|
221
272
|
await promisify(tcpServer.listen.bind(tcpServer))(3000)
|
|
222
273
|
console.log('server is listening at 3000')
|
|
@@ -233,9 +284,9 @@ dx-server uses Node.js AsyncLocalStorage to provide request/response context glo
|
|
|
233
284
|
import {getReq, getRes} from 'dx-server'
|
|
234
285
|
|
|
235
286
|
function someDeepFunction() {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
287
|
+
const req = getReq() // No need to pass req through multiple layers
|
|
288
|
+
const res = getRes()
|
|
289
|
+
res.setHeader('X-Custom', 'value')
|
|
239
290
|
}
|
|
240
291
|
```
|
|
241
292
|
|
|
@@ -252,11 +303,11 @@ const text = await getText()
|
|
|
252
303
|
|
|
253
304
|
// Sync usage (requires chaining)
|
|
254
305
|
chain(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
306
|
+
getJson.chain({bodyLimit: 1024 * 1024}), // 1MB limit
|
|
307
|
+
next => {
|
|
308
|
+
console.log(getJson.value) // Access synchronously
|
|
309
|
+
return next()
|
|
310
|
+
},
|
|
260
311
|
)
|
|
261
312
|
```
|
|
262
313
|
|
|
@@ -269,23 +320,22 @@ import {makeDxContext, getReq} from 'dx-server'
|
|
|
269
320
|
|
|
270
321
|
// Create auth context
|
|
271
322
|
const authContext = makeDxContext(async () => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
323
|
+
const token = getReq().headers.authorization
|
|
324
|
+
if (!token) return null
|
|
325
|
+
return await validateToken(token) // Your validation logic
|
|
275
326
|
})
|
|
276
327
|
|
|
277
328
|
// Use in middleware
|
|
278
329
|
chain(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
330
|
+
authContext.chain(), // Initialize for all requests
|
|
331
|
+
next => {
|
|
332
|
+
if (!authContext.value) {
|
|
333
|
+
setJson({error: 'Unauthorized'}, {status: 401})
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
return next()
|
|
337
|
+
},
|
|
287
338
|
)
|
|
288
|
-
|
|
289
339
|
```
|
|
290
340
|
|
|
291
341
|
## API Reference
|
|
@@ -294,38 +344,60 @@ chain(
|
|
|
294
344
|
|
|
295
345
|
```javascript
|
|
296
346
|
import dxServer, {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
347
|
+
// Request/Response access
|
|
348
|
+
getReq,
|
|
349
|
+
getRes,
|
|
350
|
+
|
|
351
|
+
// Request body parsers
|
|
352
|
+
getBuffer,
|
|
353
|
+
getJson,
|
|
354
|
+
getRaw,
|
|
355
|
+
getText,
|
|
356
|
+
getUrlEncoded,
|
|
357
|
+
getQuery,
|
|
358
|
+
|
|
359
|
+
// Response setters
|
|
360
|
+
setHtml,
|
|
361
|
+
setJson,
|
|
362
|
+
setText,
|
|
363
|
+
setEmpty,
|
|
364
|
+
setBuffer,
|
|
365
|
+
setRedirect,
|
|
366
|
+
setNodeStream,
|
|
367
|
+
setWebStream,
|
|
368
|
+
setFile,
|
|
369
|
+
|
|
370
|
+
// Utilities
|
|
371
|
+
router,
|
|
372
|
+
chainStatic,
|
|
373
|
+
makeDxContext,
|
|
374
|
+
|
|
375
|
+
// Logging
|
|
376
|
+
logger,
|
|
377
|
+
logJson,
|
|
312
378
|
} from 'dx-server'
|
|
313
379
|
|
|
314
380
|
// Low-level helpers
|
|
315
381
|
import {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
382
|
+
setBufferBodyDefaultOptions,
|
|
383
|
+
bufferFromReq,
|
|
384
|
+
jsonFromReq,
|
|
385
|
+
rawFromReq,
|
|
386
|
+
textFromReq,
|
|
387
|
+
urlEncodedFromReq,
|
|
388
|
+
queryFromReq,
|
|
319
389
|
} from 'dx-server/helpers'
|
|
320
390
|
```
|
|
321
391
|
|
|
322
392
|
### Core Functions
|
|
323
393
|
|
|
324
394
|
#### Request/Response Access
|
|
395
|
+
|
|
325
396
|
- **`getReq()`** - Get the current request object
|
|
326
397
|
- **`getRes()`** - Get the current response object
|
|
327
398
|
|
|
328
399
|
#### Body Parsers
|
|
400
|
+
|
|
329
401
|
All body parsers are async, lazy-loaded, and cached per request:
|
|
330
402
|
|
|
331
403
|
- **`getJson(options?)`** - Parse JSON body (requires `Content-Type: application/json`)
|
|
@@ -336,6 +408,7 @@ All body parsers are async, lazy-loaded, and cached per request:
|
|
|
336
408
|
- **`getQuery(options?)`** - Parse query string parameters
|
|
337
409
|
|
|
338
410
|
Options:
|
|
411
|
+
|
|
339
412
|
```typescript
|
|
340
413
|
{
|
|
341
414
|
bodyLimit?: number // Max body size in bytes (default: 100KB)
|
|
@@ -345,44 +418,70 @@ Options:
|
|
|
345
418
|
```
|
|
346
419
|
|
|
347
420
|
#### Response Setters
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
- **`
|
|
353
|
-
- **`
|
|
354
|
-
- **`
|
|
355
|
-
- **`
|
|
356
|
-
- **`
|
|
421
|
+
|
|
422
|
+
Setters only take a `{status?}` option (except `setRedirect`/`setFile`). To set response
|
|
423
|
+
headers, use `getRes().setHeader(name, value)` before (or after) calling a setter.
|
|
424
|
+
|
|
425
|
+
- **`setJson(data, {status?})`** - Send JSON response (`application/json; charset=utf-8`)
|
|
426
|
+
- **`setHtml(html, {status?})`** - Send HTML response (`text/html; charset=utf-8`)
|
|
427
|
+
- **`setText(text, {status?})`** - Send plain text (`text/plain; charset=utf-8`)
|
|
428
|
+
- **`setBuffer(buffer, {status?})`** - Send buffer (`application/octet-stream`)
|
|
429
|
+
- **`setFile(path, options?)`** - Send file (see `SendFileOptions`)
|
|
430
|
+
- **`setNodeStream(stream, {status?})`** - Send Node.js stream
|
|
431
|
+
- **`setWebStream(stream, {status?})`** - Send Web stream
|
|
432
|
+
- **`setRedirect(url, status)`** - Redirect response; `status` is `301 | 302` (required, positional)
|
|
433
|
+
- **`setEmpty({status?})`** - Send empty response
|
|
357
434
|
|
|
358
435
|
#### Context Management
|
|
436
|
+
|
|
359
437
|
- **`makeDxContext(fn)`** - Create a custom context object
|
|
438
|
+
|
|
360
439
|
```javascript
|
|
361
440
|
const ctx = makeDxContext(() => computeValue())
|
|
362
|
-
|
|
441
|
+
|
|
363
442
|
// Access value
|
|
364
|
-
await ctx()
|
|
365
|
-
ctx.value
|
|
366
|
-
ctx.get(req)
|
|
367
|
-
|
|
443
|
+
await ctx() // Lazy load
|
|
444
|
+
ctx.value // Sync access (after loading)
|
|
445
|
+
ctx.get(req) // Get for specific request
|
|
446
|
+
|
|
368
447
|
// Set value
|
|
369
448
|
ctx.value = newValue
|
|
370
449
|
ctx.set(req, newValue)
|
|
371
450
|
```
|
|
372
451
|
|
|
373
452
|
#### Middleware Utilities
|
|
453
|
+
|
|
374
454
|
- **`chainStatic(pattern, options)`** - Serve static files
|
|
455
|
+
|
|
375
456
|
```javascript
|
|
376
457
|
chainStatic('/public/*', {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
458
|
+
// directory that contains the files. A request '/public/x.js' resolves to '<root>/public/x.js',
|
|
459
|
+
// so root is the parent of the public folder, not the public folder itself.
|
|
460
|
+
root: '/path/to/parent',
|
|
461
|
+
// optional. Receives the URLPattern match and returns the file path to serve. The default
|
|
462
|
+
// serves decodeURIComponent(url.pathname). A custom getPathname must return an
|
|
463
|
+
// already-decoded path. Example: pin a single file regardless of URL:
|
|
464
|
+
// getPathname: () => '/path/to/fixed-file.html'
|
|
465
|
+
getPathname: undefined,
|
|
466
|
+
allowDotfiles: false, // default: dotfiles are denied
|
|
467
|
+
etag: 'weak', // 'weak' (default) | 'strong' | 'disabled'
|
|
468
|
+
disableLastModified: false,
|
|
469
|
+
disableFollowSymlinks: false, // set true to 403 files whose real path escapes root
|
|
383
470
|
})
|
|
384
471
|
```
|
|
385
472
|
|
|
473
|
+
- **`logger(log?, options?)`** - Request logging middleware
|
|
474
|
+
|
|
475
|
+
```javascript
|
|
476
|
+
import {logger, logJson} from 'dx-server'
|
|
477
|
+
|
|
478
|
+
// logs one line on request and one on response completion
|
|
479
|
+
chain(logger()) // defaults: log = logJson (JSON to stdout), timestamps in UTC
|
|
480
|
+
|
|
481
|
+
// custom sink and timezone offset (in hours) for the human-readable timestamp
|
|
482
|
+
chain(logger(logJson, {timezoneOffset: 9})) // +09:00 (JST)
|
|
483
|
+
```
|
|
484
|
+
|
|
386
485
|
### Routing
|
|
387
486
|
|
|
388
487
|
dx-server uses [URLPattern API](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) for routing, which differs from Express patterns:
|
|
@@ -392,15 +491,21 @@ import {router} from 'dx-server'
|
|
|
392
491
|
|
|
393
492
|
// Single route
|
|
394
493
|
router.get('/users/:id', ({matched}) => {
|
|
395
|
-
|
|
396
|
-
|
|
494
|
+
const {id} = matched.pathname.groups
|
|
495
|
+
setJson({userId: id})
|
|
397
496
|
})
|
|
398
497
|
|
|
399
498
|
// Multiple routes
|
|
400
499
|
router.post({
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
500
|
+
'/api/users'() {
|
|
501
|
+
/* create user */
|
|
502
|
+
},
|
|
503
|
+
'/api/users/:id'({matched}) {
|
|
504
|
+
/* update user */
|
|
505
|
+
},
|
|
506
|
+
'/api/users/:id/posts'({matched}) {
|
|
507
|
+
/* get user posts */
|
|
508
|
+
},
|
|
404
509
|
})
|
|
405
510
|
|
|
406
511
|
// All HTTP methods supported
|
|
@@ -411,28 +516,32 @@ router.delete(pattern, handler)
|
|
|
411
516
|
router.patch(pattern, handler)
|
|
412
517
|
router.head(pattern, handler)
|
|
413
518
|
router.options(pattern, handler)
|
|
414
|
-
router.all(pattern, handler)
|
|
519
|
+
router.all(pattern, handler) // Any method
|
|
415
520
|
|
|
416
521
|
// Custom method
|
|
417
522
|
router.method('CUSTOM', pattern, handler)
|
|
418
523
|
|
|
419
524
|
// With prefix option
|
|
420
|
-
router.get(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
525
|
+
router.get(
|
|
526
|
+
{
|
|
527
|
+
'/users': listUsers,
|
|
528
|
+
'/users/:id': getUser,
|
|
529
|
+
},
|
|
530
|
+
{prefix: '/api'},
|
|
531
|
+
) // Routes become /api/users, /api/users/:id
|
|
424
532
|
```
|
|
425
533
|
|
|
426
534
|
#### URLPattern vs Express Patterns
|
|
427
535
|
|
|
428
|
-
| Pattern
|
|
429
|
-
|
|
430
|
-
| Wildcard
|
|
431
|
-
| Optional trailing slash | `{/}?`
|
|
432
|
-
| Named params
|
|
433
|
-
| Optional params
|
|
536
|
+
| Pattern | URLPattern | Express |
|
|
537
|
+
| ----------------------- | ---------- | ----------------------- |
|
|
538
|
+
| Wildcard | `/api/*` | `/api/*` or `/api/(.*)` |
|
|
539
|
+
| Optional trailing slash | `{/}?` | `/path/?` |
|
|
540
|
+
| Named params | `/:id` | `/:id` |
|
|
541
|
+
| Optional params | `/:id?` | `/:id?` |
|
|
434
542
|
|
|
435
543
|
**Important differences:**
|
|
544
|
+
|
|
436
545
|
- `'/foo'` matches `/foo` but NOT `/foo/`
|
|
437
546
|
- `'/foo/'` matches `/foo/` but NOT `/foo`
|
|
438
547
|
- Use `'/foo{/}?'` to match both
|
|
@@ -452,12 +561,13 @@ import cors from 'cors'
|
|
|
452
561
|
// Adapt one Connect/Express-style middleware (or a full Express app, which has the
|
|
453
562
|
// same (req, res, next) shape) into a jchain step. Always calls next() so the rest of
|
|
454
563
|
// the chain runs — dx-server handles the case where the response is already ended.
|
|
455
|
-
const fromConnect = mw => next =>
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
})
|
|
564
|
+
const fromConnect = mw => next =>
|
|
565
|
+
new Promise((resolve, reject) => {
|
|
566
|
+
mw(getReq(), getRes(), err => {
|
|
567
|
+
if (err) return reject(err)
|
|
568
|
+
next().then(resolve, reject)
|
|
569
|
+
})
|
|
570
|
+
})
|
|
461
571
|
|
|
462
572
|
const app = express()
|
|
463
573
|
app.set('trust proxy', true)
|
|
@@ -465,12 +575,12 @@ app.use('/public', express.static('public'))
|
|
|
465
575
|
app.get('/legacy', (req, res) => res.json({message: 'Express route'}))
|
|
466
576
|
|
|
467
577
|
chain(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
578
|
+
fromConnect(app), // mount the entire Express app first
|
|
579
|
+
fromConnect(morgan('common')),
|
|
580
|
+
fromConnect(helmet()),
|
|
581
|
+
fromConnect(cors()),
|
|
582
|
+
// dx-server routes continue here
|
|
583
|
+
router.get('/', () => setHtml('ok')),
|
|
474
584
|
)
|
|
475
585
|
```
|
|
476
586
|
|
|
@@ -480,15 +590,21 @@ Pure functions for custom implementations:
|
|
|
480
590
|
|
|
481
591
|
```javascript
|
|
482
592
|
import {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
593
|
+
setBufferBodyDefaultOptions,
|
|
594
|
+
bufferFromReq,
|
|
595
|
+
jsonFromReq,
|
|
596
|
+
rawFromReq,
|
|
597
|
+
textFromReq,
|
|
598
|
+
urlEncodedFromReq,
|
|
599
|
+
queryFromReq,
|
|
486
600
|
} from 'dx-server/helpers'
|
|
487
601
|
|
|
488
602
|
// Set global defaults
|
|
489
603
|
setBufferBodyDefaultOptions({
|
|
490
|
-
|
|
491
|
-
|
|
604
|
+
bodyLimit: 10 * 1024 * 1024, // 10MB
|
|
605
|
+
queryParser(search) {
|
|
606
|
+
return myCustomParser(search)
|
|
607
|
+
},
|
|
492
608
|
})
|
|
493
609
|
|
|
494
610
|
// Use directly with req/res (no context required)
|
|
@@ -499,61 +615,70 @@ const query = queryFromReq(req)
|
|
|
499
615
|
## Security Considerations
|
|
500
616
|
|
|
501
617
|
### Body Size Limits
|
|
618
|
+
|
|
502
619
|
Always set appropriate body size limits to prevent DoS attacks:
|
|
503
620
|
|
|
504
621
|
```javascript
|
|
622
|
+
import {setBufferBodyDefaultOptions} from 'dx-server/helpers'
|
|
623
|
+
|
|
624
|
+
// per-parse limit
|
|
505
625
|
chain(
|
|
506
|
-
|
|
507
|
-
// or globally:
|
|
508
|
-
dxServer(req, res, {bodyLimit: 5 * 1024 * 1024}) // 5MB
|
|
626
|
+
getJson.chain({bodyLimit: 1024 * 1024}), // 1MB limit
|
|
509
627
|
)
|
|
628
|
+
|
|
629
|
+
// or globally, once at startup (default is 100KB):
|
|
630
|
+
setBufferBodyDefaultOptions({bodyLimit: 5 * 1024 * 1024}) // 5MB
|
|
510
631
|
```
|
|
511
632
|
|
|
633
|
+
Bodies that exceed the limit reject with an error carrying `statusCode: 413`; a body shorter than its
|
|
634
|
+
`Content-Length` rejects with `statusCode: 400`. Map these in your error middleware.
|
|
635
|
+
|
|
512
636
|
### Error Handling
|
|
637
|
+
|
|
513
638
|
Never expose internal errors to clients:
|
|
514
639
|
|
|
515
640
|
```javascript
|
|
516
641
|
class AppError extends Error {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
642
|
+
constructor(message, status = 400, code = 'ERROR') {
|
|
643
|
+
super(message)
|
|
644
|
+
this.status = status
|
|
645
|
+
this.code = code
|
|
646
|
+
}
|
|
522
647
|
}
|
|
523
648
|
|
|
524
|
-
chain(
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
)
|
|
649
|
+
chain(async next => {
|
|
650
|
+
try {
|
|
651
|
+
await next()
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error instanceof AppError) {
|
|
654
|
+
setJson({error: error.message, code: error.code}, {status: error.status})
|
|
655
|
+
} else {
|
|
656
|
+
console.error(error) // Log for debugging
|
|
657
|
+
setJson({error: 'Internal server error'}, {status: 500})
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
})
|
|
538
661
|
```
|
|
539
662
|
|
|
540
663
|
### Input Validation
|
|
664
|
+
|
|
541
665
|
Always validate input data:
|
|
542
666
|
|
|
543
667
|
```javascript
|
|
544
668
|
router.post('/api/users', async () => {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
669
|
+
const data = await getJson()
|
|
670
|
+
|
|
671
|
+
// Validate
|
|
672
|
+
if (!data?.email || !isValidEmail(data.email)) {
|
|
673
|
+
throw new AppError('Invalid email', 400, 'INVALID_EMAIL')
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Process...
|
|
553
677
|
})
|
|
554
678
|
```
|
|
555
679
|
|
|
556
680
|
### Security Headers
|
|
681
|
+
|
|
557
682
|
Use security middleware via the `fromConnect` adapter shown in [Express Integration](#express-integration):
|
|
558
683
|
|
|
559
684
|
```javascript
|
|
@@ -561,58 +686,65 @@ import helmet from 'helmet'
|
|
|
561
686
|
import cors from 'cors'
|
|
562
687
|
|
|
563
688
|
chain(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
689
|
+
fromConnect(helmet()),
|
|
690
|
+
fromConnect(
|
|
691
|
+
cors({
|
|
692
|
+
origin: process.env.ALLOWED_ORIGINS?.split(','),
|
|
693
|
+
credentials: true,
|
|
694
|
+
}),
|
|
695
|
+
),
|
|
569
696
|
)
|
|
570
697
|
```
|
|
571
698
|
|
|
572
699
|
## Advanced Examples
|
|
573
700
|
|
|
574
701
|
### File Upload with Busboy
|
|
702
|
+
|
|
575
703
|
```javascript
|
|
576
704
|
import busboy from 'busboy'
|
|
577
705
|
|
|
578
706
|
router.post('/upload', () => {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
707
|
+
const req = getReq()
|
|
708
|
+
const bb = busboy({headers: req.headers, limits: {fileSize: 10 * 1024 * 1024}})
|
|
709
|
+
|
|
710
|
+
bb.on('file', (name, file, info) => {
|
|
711
|
+
// Handle file stream
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
req.pipe(bb)
|
|
587
715
|
})
|
|
588
716
|
```
|
|
589
717
|
|
|
590
718
|
### WebSocket Upgrade
|
|
719
|
+
|
|
591
720
|
```javascript
|
|
592
721
|
import {WebSocketServer} from 'ws'
|
|
593
722
|
|
|
594
723
|
const wss = new WebSocketServer({noServer: true})
|
|
595
724
|
|
|
596
725
|
server.on('upgrade', (request, socket, head) => {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
726
|
+
if (request.url === '/ws') {
|
|
727
|
+
wss.handleUpgrade(request, socket, head, ws => {
|
|
728
|
+
wss.emit('connection', ws, request)
|
|
729
|
+
})
|
|
730
|
+
}
|
|
602
731
|
})
|
|
603
732
|
```
|
|
604
733
|
|
|
605
734
|
### Rate Limiting
|
|
735
|
+
|
|
606
736
|
Using the `fromConnect` adapter from [Express Integration](#express-integration):
|
|
607
737
|
|
|
608
738
|
```javascript
|
|
609
739
|
import rateLimit from 'express-rate-limit'
|
|
610
740
|
|
|
611
741
|
chain(
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
742
|
+
fromConnect(
|
|
743
|
+
rateLimit({
|
|
744
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
745
|
+
max: 100, // limit each IP to 100 requests per windowMs
|
|
746
|
+
}),
|
|
747
|
+
),
|
|
616
748
|
)
|
|
617
749
|
```
|
|
618
750
|
|
|
@@ -633,14 +765,14 @@ chain(
|
|
|
633
765
|
```javascript
|
|
634
766
|
// Express
|
|
635
767
|
app.get('/users/:id', (req, res) => {
|
|
636
|
-
|
|
637
|
-
|
|
768
|
+
const {id} = req.params
|
|
769
|
+
res.json({userId: id})
|
|
638
770
|
})
|
|
639
771
|
|
|
640
772
|
// dx-server
|
|
641
773
|
router.get('/users/:id', ({matched}) => {
|
|
642
|
-
|
|
643
|
-
|
|
774
|
+
const {id} = matched.pathname.groups
|
|
775
|
+
setJson({userId: id})
|
|
644
776
|
})
|
|
645
777
|
```
|
|
646
778
|
|