UNPKG

16.7 kBJavaScriptView Raw
1'use strict'
2
3const eos = require('readable-stream').finished
4const statusCodes = require('http').STATUS_CODES
5const flatstr = require('flatstr')
6const FJS = require('fast-json-stringify')
7const {
8 kFourOhFourContext,
9 kReplyErrorHandlerCalled,
10 kReplySent,
11 kReplySentOverwritten,
12 kReplyStartTime,
13 kReplySerializer,
14 kReplySerializerDefault,
15 kReplyIsError,
16 kReplyHeaders,
17 kReplyHasStatusCode,
18 kReplyIsRunningOnErrorHook,
19 kDisableRequestLogging
20} = require('./symbols.js')
21const { hookRunner, hookIterator, onSendHookRunner } = require('./hooks')
22const validation = require('./validation')
23const serialize = validation.serialize
24
25const internals = require('./handleRequest')[Symbol.for('internals')]
26const loggerUtils = require('./logger')
27const now = loggerUtils.now
28const wrapThenable = require('./wrapThenable')
29
30const serializeError = FJS({
31 type: 'object',
32 properties: {
33 statusCode: { type: 'number' },
34 code: { type: 'string' },
35 error: { type: 'string' },
36 message: { type: 'string' }
37 }
38})
39
40const CONTENT_TYPE = {
41 JSON: 'application/json; charset=utf-8',
42 PLAIN: 'text/plain; charset=utf-8',
43 OCTET: 'application/octet-stream'
44}
45const {
46 codes: {
47 FST_ERR_REP_INVALID_PAYLOAD_TYPE,
48 FST_ERR_REP_ALREADY_SENT,
49 FST_ERR_REP_SENT_VALUE,
50 FST_ERR_SEND_INSIDE_ONERR,
51 FST_ERR_BAD_STATUS_CODE
52 }
53} = require('./errors')
54
55var getHeader
56
57function Reply (res, context, request, log) {
58 this.res = res
59 this.context = context
60 this[kReplySent] = false
61 this[kReplySerializer] = null
62 this[kReplyErrorHandlerCalled] = false
63 this[kReplyIsError] = false
64 this[kReplyIsRunningOnErrorHook] = false
65 this.request = request
66 this[kReplyHeaders] = {}
67 this[kReplyHasStatusCode] = false
68 this[kReplyStartTime] = undefined
69 this.log = log
70}
71
72Object.defineProperty(Reply.prototype, 'sent', {
73 enumerable: true,
74 get () {
75 return this[kReplySent]
76 },
77 set (value) {
78 if (value !== true) {
79 throw new FST_ERR_REP_SENT_VALUE()
80 }
81
82 if (this[kReplySent]) {
83 throw new FST_ERR_REP_ALREADY_SENT()
84 }
85
86 this[kReplySentOverwritten] = true
87 this[kReplySent] = true
88 }
89})
90
91Object.defineProperty(Reply.prototype, 'statusCode', {
92 get () {
93 return this.res.statusCode
94 },
95 set (value) {
96 this.code(value)
97 }
98})
99
100Reply.prototype.send = function (payload) {
101 if (this[kReplyIsRunningOnErrorHook] === true) {
102 throw new FST_ERR_SEND_INSIDE_ONERR()
103 }
104
105 if (this[kReplySent]) {
106 this.log.warn({ err: new FST_ERR_REP_ALREADY_SENT() }, 'Reply already sent')
107 return this
108 }
109
110 if (payload instanceof Error || this[kReplyIsError] === true) {
111 onErrorHook(this, payload, onSendHook)
112 return this
113 }
114
115 if (payload === undefined) {
116 onSendHook(this, payload)
117 return this
118 }
119
120 var contentType = getHeader(this, 'content-type')
121 var hasContentType = contentType !== undefined
122
123 if (payload !== null) {
124 if (Buffer.isBuffer(payload) || typeof payload.pipe === 'function') {
125 if (hasContentType === false) {
126 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.OCTET
127 }
128 onSendHook(this, payload)
129 return this
130 }
131
132 if (hasContentType === false && typeof payload === 'string') {
133 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.PLAIN
134 onSendHook(this, payload)
135 return this
136 }
137 }
138
139 if (this[kReplySerializer] !== null) {
140 payload = this[kReplySerializer](payload)
141
142 // The indexOf below also matches custom json mimetypes such as 'application/hal+json' or 'application/ld+json'
143 } else if (hasContentType === false || contentType.indexOf('json') > -1) {
144 if (hasContentType === false) {
145 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
146 } else {
147 // If hasContentType === true, we have a JSON mimetype
148 if (contentType.indexOf('charset') === -1) {
149 // If we have simply application/json instead of a custom json mimetype
150 if (contentType.indexOf('/json') > -1) {
151 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
152 } else {
153 const currContentType = this[kReplyHeaders]['content-type']
154 // We extract the custom mimetype part (e.g. 'hal+' from 'application/hal+json')
155 const customJsonType = currContentType.substring(
156 currContentType.indexOf('/'),
157 currContentType.indexOf('json') + 4
158 )
159
160 // We ensure we set the header to the proper JSON content-type if necessary
161 // (e.g. 'application/hal+json' instead of 'application/json')
162 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON.replace('/json', customJsonType)
163 }
164 }
165 }
166 if (typeof payload !== 'string') {
167 preserializeHook(this, payload)
168 return this
169 }
170 }
171
172 onSendHook(this, payload)
173
174 return this
175}
176
177Reply.prototype.getHeader = function (key) {
178 return getHeader(this, key)
179}
180
181Reply.prototype.hasHeader = function (key) {
182 return this[kReplyHeaders][key.toLowerCase()] !== undefined
183}
184
185Reply.prototype.removeHeader = function (key) {
186 // Node.js does not like headers with keys set to undefined,
187 // so we have to delete the key.
188 delete this[kReplyHeaders][key.toLowerCase()]
189 return this
190}
191
192Reply.prototype.header = function (key, value) {
193 var _key = key.toLowerCase()
194
195 // default the value to ''
196 value = value === undefined ? '' : value
197
198 if (this[kReplyHeaders][_key] && _key === 'set-cookie') {
199 // https://tools.ietf.org/html/rfc7230#section-3.2.2
200 if (typeof this[kReplyHeaders][_key] === 'string') {
201 this[kReplyHeaders][_key] = [this[kReplyHeaders][_key]]
202 }
203 if (Array.isArray(value)) {
204 Array.prototype.push.apply(this[kReplyHeaders][_key], value)
205 } else {
206 this[kReplyHeaders][_key].push(value)
207 }
208 } else {
209 this[kReplyHeaders][_key] = value
210 }
211 return this
212}
213
214Reply.prototype.headers = function (headers) {
215 var keys = Object.keys(headers)
216 for (var i = 0; i < keys.length; i++) {
217 this.header(keys[i], headers[keys[i]])
218 }
219 return this
220}
221
222Reply.prototype.code = function (code) {
223 const intValue = parseInt(code)
224 if (isNaN(intValue) || intValue < 100 || intValue > 600) {
225 throw new FST_ERR_BAD_STATUS_CODE(String(code))
226 }
227
228 this.res.statusCode = intValue
229 this[kReplyHasStatusCode] = true
230 return this
231}
232
233Reply.prototype.status = Reply.prototype.code
234
235Reply.prototype.serialize = function (payload) {
236 if (this[kReplySerializer] !== null) {
237 return this[kReplySerializer](payload)
238 } else {
239 if (this.context && this.context[kReplySerializerDefault]) {
240 return this.context[kReplySerializerDefault](payload, this.res.statusCode)
241 } else {
242 return serialize(this.context, payload, this.res.statusCode)
243 }
244 }
245}
246
247Reply.prototype.serializer = function (fn) {
248 this[kReplySerializer] = fn
249 return this
250}
251
252Reply.prototype.type = function (type) {
253 this[kReplyHeaders]['content-type'] = type
254 return this
255}
256
257Reply.prototype.redirect = function (code, url) {
258 if (typeof code === 'string') {
259 url = code
260 code = this[kReplyHasStatusCode] ? this.res.statusCode : 302
261 }
262
263 this.header('location', url).code(code).send()
264}
265
266Reply.prototype.callNotFound = function () {
267 notFound(this)
268}
269
270Reply.prototype.getResponseTime = function () {
271 var responseTime = 0
272
273 if (this[kReplyStartTime] !== undefined) {
274 responseTime = now() - this[kReplyStartTime]
275 }
276
277 return responseTime
278}
279
280// Make reply a thenable, so it could be used with async/await.
281// See
282// - https://github.com/fastify/fastify/issues/1864 for the discussions
283// - https://promisesaplus.com/ for the definition of thenable
284// - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then for the signature
285Reply.prototype.then = function (fullfilled, rejected) {
286 if (this.sent) {
287 fullfilled()
288 return
289 }
290
291 eos(this.res, function (err) {
292 // We must not treat ERR_STREAM_PREMATURE_CLOSE as
293 // an error because it is created by eos, not by the stream.
294 if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
295 if (rejected) {
296 rejected(err)
297 }
298 } else {
299 fullfilled()
300 }
301 })
302}
303
304function preserializeHook (reply, payload) {
305 if (reply.context.preSerialization !== null) {
306 onSendHookRunner(
307 reply.context.preSerialization,
308 reply.request,
309 reply,
310 payload,
311 preserializeHookEnd
312 )
313 } else {
314 preserializeHookEnd(null, reply.request, reply, payload)
315 }
316}
317
318function preserializeHookEnd (err, request, reply, payload) {
319 if (err != null) {
320 onErrorHook(reply, err)
321 return
322 }
323
324 if (reply.context && reply.context[kReplySerializerDefault]) {
325 payload = reply.context[kReplySerializerDefault](payload, reply.res.statusCode)
326 } else {
327 payload = serialize(reply.context, payload, reply.res.statusCode)
328 }
329
330 flatstr(payload)
331
332 onSendHook(reply, payload)
333}
334
335function onSendHook (reply, payload) {
336 reply[kReplySent] = true
337 if (reply.context.onSend !== null) {
338 onSendHookRunner(
339 reply.context.onSend,
340 reply.request,
341 reply,
342 payload,
343 wrapOnSendEnd
344 )
345 } else {
346 onSendEnd(reply, payload)
347 }
348}
349
350function wrapOnSendEnd (err, request, reply, payload) {
351 if (err != null) {
352 onErrorHook(reply, err)
353 } else {
354 onSendEnd(reply, payload)
355 }
356}
357
358function onSendEnd (reply, payload) {
359 var res = reply.res
360 var statusCode = res.statusCode
361
362 if (payload === undefined || payload === null) {
363 reply[kReplySent] = true
364
365 // according to https://tools.ietf.org/html/rfc7230#section-3.3.2
366 // we cannot send a content-length for 304 and 204, and all status code
367 // < 200.
368 if (statusCode >= 200 && statusCode !== 204 && statusCode !== 304) {
369 reply[kReplyHeaders]['content-length'] = '0'
370 }
371
372 res.writeHead(statusCode, reply[kReplyHeaders])
373 // avoid ArgumentsAdaptorTrampoline from V8
374 res.end(null, null, null)
375 return
376 }
377
378 if (typeof payload.pipe === 'function') {
379 sendStream(payload, res, reply)
380 return
381 }
382
383 if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {
384 throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)
385 }
386
387 if (!reply[kReplyHeaders]['content-length']) {
388 reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
389 }
390
391 reply[kReplySent] = true
392
393 res.writeHead(statusCode, reply[kReplyHeaders])
394
395 // avoid ArgumentsAdaptorTrampoline from V8
396 res.end(payload, null, null)
397}
398
399function sendStream (payload, res, reply) {
400 var sourceOpen = true
401
402 eos(payload, { readable: true, writable: false }, function (err) {
403 sourceOpen = false
404 if (err != null) {
405 if (res.headersSent) {
406 reply.log.warn({ err }, 'response terminated with an error with headers already sent')
407 res.destroy()
408 } else {
409 onErrorHook(reply, err)
410 }
411 }
412 // there is nothing to do if there is not an error
413 })
414
415 eos(res, function (err) {
416 if (err != null) {
417 if (res.headersSent) {
418 reply.log.warn({ err }, 'response terminated with an error with headers already sent')
419 }
420 if (sourceOpen) {
421 if (payload.destroy) {
422 payload.destroy()
423 } else if (typeof payload.close === 'function') {
424 payload.close(noop)
425 } else if (typeof payload.abort === 'function') {
426 payload.abort()
427 }
428 }
429 }
430 })
431
432 // streams will error asynchronously, and we want to handle that error
433 // appropriately, e.g. a 404 for a missing file. So we cannot use
434 // writeHead, and we need to resort to setHeader, which will trigger
435 // a writeHead when there is data to send.
436 if (!res.headersSent) {
437 for (var key in reply[kReplyHeaders]) {
438 res.setHeader(key, reply[kReplyHeaders][key])
439 }
440 } else {
441 reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode')
442 }
443 payload.pipe(res)
444}
445
446function onErrorHook (reply, error, cb) {
447 reply[kReplySent] = true
448 if (reply.context.onError !== null && reply[kReplyErrorHandlerCalled] === true) {
449 reply[kReplyIsRunningOnErrorHook] = true
450 onSendHookRunner(
451 reply.context.onError,
452 reply.request,
453 reply,
454 error,
455 () => handleError(reply, error, cb)
456 )
457 } else {
458 handleError(reply, error, cb)
459 }
460}
461
462function handleError (reply, error, cb) {
463 reply[kReplyIsRunningOnErrorHook] = false
464 var res = reply.res
465 var statusCode = res.statusCode
466 statusCode = (statusCode >= 400) ? statusCode : 500
467 // treat undefined and null as same
468 if (error != null) {
469 if (error.headers !== undefined) {
470 reply.headers(error.headers)
471 }
472 if (error.status >= 400) {
473 statusCode = error.status
474 } else if (error.statusCode >= 400) {
475 statusCode = error.statusCode
476 }
477 }
478
479 res.statusCode = statusCode
480
481 var errorHandler = reply.context.errorHandler
482 if (errorHandler && reply[kReplyErrorHandlerCalled] === false) {
483 reply[kReplySent] = false
484 reply[kReplyIsError] = false
485 reply[kReplyErrorHandlerCalled] = true
486 var result = errorHandler(error, reply.request, reply)
487 if (result && typeof result.then === 'function') {
488 wrapThenable(result, reply)
489 }
490 return
491 }
492
493 var payload = serializeError({
494 error: statusCodes[statusCode + ''],
495 code: error.code,
496 message: error.message || '',
497 statusCode: statusCode
498 })
499 flatstr(payload)
500 reply[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
501 reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
502
503 if (cb) {
504 cb(reply, payload)
505 return
506 }
507
508 reply[kReplySent] = true
509 res.writeHead(res.statusCode, reply[kReplyHeaders])
510 res.end(payload)
511}
512
513function setupResponseListeners (reply) {
514 reply[kReplyStartTime] = now()
515
516 var onResFinished = err => {
517 reply.res.removeListener('finish', onResFinished)
518 reply.res.removeListener('error', onResFinished)
519
520 var ctx = reply.context
521
522 if (ctx && ctx.onResponse !== null) {
523 hookRunner(
524 ctx.onResponse,
525 onResponseIterator,
526 reply.request,
527 reply,
528 onResponseCallback
529 )
530 } else {
531 onResponseCallback(err, reply.request, reply)
532 }
533 }
534
535 reply.res.on('finish', onResFinished)
536 reply.res.on('error', onResFinished)
537}
538
539function onResponseIterator (fn, request, reply, next) {
540 return fn(request, reply, next)
541}
542
543function onResponseCallback (err, request, reply) {
544 if (reply.log[kDisableRequestLogging]) {
545 return
546 }
547
548 var responseTime = reply.getResponseTime()
549
550 if (err != null) {
551 reply.log.error({
552 res: reply.res,
553 err,
554 responseTime
555 }, 'request errored')
556 return
557 }
558
559 reply.log.info({
560 res: reply.res,
561 responseTime
562 }, 'request completed')
563}
564
565function buildReply (R) {
566 function _Reply (res, context, request, log) {
567 this.res = res
568 this.context = context
569 this[kReplyIsError] = false
570 this[kReplyErrorHandlerCalled] = false
571 this[kReplySent] = false
572 this[kReplySentOverwritten] = false
573 this[kReplySerializer] = null
574 this.request = request
575 this[kReplyHeaders] = {}
576 this[kReplyStartTime] = undefined
577 this.log = log
578 }
579 _Reply.prototype = new R()
580 return _Reply
581}
582
583function notFound (reply) {
584 reply[kReplySent] = false
585 reply[kReplyIsError] = false
586
587 if (reply.context[kFourOhFourContext] === null) {
588 reply.log.warn('Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.')
589 reply.code(404).send('404 Not Found')
590 return
591 }
592
593 reply.context = reply.context[kFourOhFourContext]
594
595 // preHandler hook
596 if (reply.context.preHandler !== null) {
597 hookRunner(
598 reply.context.preHandler,
599 hookIterator,
600 reply.request,
601 reply,
602 internals.preHandlerCallback
603 )
604 } else {
605 internals.preHandlerCallback(null, reply.request, reply)
606 }
607}
608
609function noop () {}
610
611function getHeaderProper (reply, key) {
612 key = key.toLowerCase()
613 var res = reply.res
614 var value = reply[kReplyHeaders][key]
615 if (value === undefined && res.hasHeader(key)) {
616 value = res.getHeader(key)
617 }
618 return value
619}
620
621function getHeaderFallback (reply, key) {
622 key = key.toLowerCase()
623 var res = reply.res
624 var value = reply[kReplyHeaders][key]
625 if (value === undefined) {
626 value = res.getHeader(key)
627 }
628 return value
629}
630
631// ponyfill for hasHeader. It has been introduced into Node 7.7,
632// so it's ok to use it in 8+
633{
634 const v = process.version.match(/v(\d+)/)[1]
635 if (Number(v) > 7) {
636 getHeader = getHeaderProper
637 } else {
638 getHeader = getHeaderFallback
639 }
640}
641
642module.exports = Reply
643module.exports.buildReply = buildReply
644module.exports.setupResponseListeners = setupResponseListeners