UNPKG

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