UNPKG

13.6 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 kReplyIsError,
15 kReplyHeaders,
16 kReplyHasStatusCode,
17 kReplyIsRunningOnErrorHook
18} = require('./symbols.js')
19const { hookRunner, onSendHookRunner } = require('./hooks')
20const validation = require('./validation')
21const serialize = validation.serialize
22
23const loggerUtils = require('./logger')
24const now = loggerUtils.now
25const wrapThenable = require('./wrapThenable')
26
27const serializeError = FJS({
28 type: 'object',
29 properties: {
30 statusCode: { type: 'number' },
31 code: { type: 'string' },
32 error: { type: 'string' },
33 message: { type: 'string' }
34 }
35})
36
37const CONTENT_TYPE = {
38 JSON: 'application/json; charset=utf-8',
39 PLAIN: 'text/plain; charset=utf-8',
40 OCTET: 'application/octet-stream'
41}
42const {
43 codes: {
44 FST_ERR_REP_INVALID_PAYLOAD_TYPE,
45 FST_ERR_REP_ALREADY_SENT,
46 FST_ERR_REP_SENT_VALUE,
47 FST_ERR_SEND_INSIDE_ONERR
48 }
49} = require('./errors')
50
51var getHeader
52
53function Reply (res, context, request, log) {
54 this.res = res
55 this.context = context
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.defineProperty(Reply.prototype, 'sent', {
69 enumerable: true,
70 get () {
71 return this[kReplySent]
72 },
73 set (value) {
74 if (value !== true) {
75 throw new FST_ERR_REP_SENT_VALUE()
76 }
77
78 if (this[kReplySent]) {
79 throw new FST_ERR_REP_ALREADY_SENT()
80 }
81
82 this[kReplySentOverwritten] = true
83 this[kReplySent] = true
84 }
85})
86
87Reply.prototype.send = function (payload) {
88 if (this[kReplyIsRunningOnErrorHook] === true) {
89 throw new FST_ERR_SEND_INSIDE_ONERR()
90 }
91
92 if (this[kReplySent]) {
93 this.log.warn({ err: new FST_ERR_REP_ALREADY_SENT() }, 'Reply already sent')
94 return
95 }
96
97 if (payload instanceof Error || this[kReplyIsError] === true) {
98 onErrorHook(this, payload, onSendHook)
99 return
100 }
101
102 if (payload === undefined) {
103 onSendHook(this, payload)
104 return
105 }
106
107 var contentType = getHeader(this, 'content-type')
108 var hasContentType = contentType !== undefined
109
110 if (payload !== null) {
111 if (Buffer.isBuffer(payload) || typeof payload.pipe === 'function') {
112 if (hasContentType === false) {
113 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.OCTET
114 }
115 onSendHook(this, payload)
116 return
117 }
118
119 if (hasContentType === false && typeof payload === 'string') {
120 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.PLAIN
121 onSendHook(this, payload)
122 return
123 }
124 }
125
126 if (this[kReplySerializer] !== null) {
127 payload = this[kReplySerializer](payload)
128 } else if (hasContentType === false || contentType.indexOf('application/json') > -1) {
129 if (hasContentType === false || contentType.indexOf('charset') === -1) {
130 this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
131 }
132
133 preserializeHook(this, payload)
134 return
135 }
136
137 onSendHook(this, payload)
138}
139
140Reply.prototype.getHeader = function (key) {
141 return getHeader(this, key)
142}
143
144Reply.prototype.hasHeader = function (key) {
145 return this[kReplyHeaders][key.toLowerCase()] !== undefined
146}
147
148Reply.prototype.removeHeader = function (key) {
149 // Node.js does not like headers with keys set to undefined,
150 // so we have to delete the key.
151 delete this[kReplyHeaders][key.toLowerCase()]
152 return this
153}
154
155Reply.prototype.header = function (key, value) {
156 var _key = key.toLowerCase()
157
158 // default the value to ''
159 value = value === undefined ? '' : value
160
161 if (this[kReplyHeaders][_key] && _key === 'set-cookie') {
162 // https://tools.ietf.org/html/rfc7230#section-3.2.2
163 if (typeof this[kReplyHeaders][_key] === 'string') {
164 this[kReplyHeaders][_key] = [this[kReplyHeaders][_key]]
165 }
166 if (Array.isArray(value)) {
167 Array.prototype.push.apply(this[kReplyHeaders][_key], value)
168 } else {
169 this[kReplyHeaders][_key].push(value)
170 }
171 } else {
172 this[kReplyHeaders][_key] = value
173 }
174 return this
175}
176
177Reply.prototype.headers = function (headers) {
178 var keys = Object.keys(headers)
179 for (var i = 0; i < keys.length; i++) {
180 this.header(keys[i], headers[keys[i]])
181 }
182 return this
183}
184
185Reply.prototype.code = function (code) {
186 this.res.statusCode = code
187 this[kReplyHasStatusCode] = true
188 return this
189}
190
191Reply.prototype.status = Reply.prototype.code
192
193Reply.prototype.serialize = function (payload) {
194 if (this[kReplySerializer] !== null) {
195 return this[kReplySerializer](payload)
196 } else {
197 return serialize(this.context, payload, this.res.statusCode)
198 }
199}
200
201Reply.prototype.serializer = function (fn) {
202 this[kReplySerializer] = fn
203 return this
204}
205
206Reply.prototype.type = function (type) {
207 this[kReplyHeaders]['content-type'] = type
208 return this
209}
210
211Reply.prototype.redirect = function (code, url) {
212 if (typeof code === 'string') {
213 url = code
214 code = this[kReplyHasStatusCode] ? this.res.statusCode : 302
215 }
216
217 this.header('location', url).code(code).send()
218}
219
220Reply.prototype.callNotFound = function () {
221 notFound(this)
222}
223
224function preserializeHook (reply, payload) {
225 if (reply.context.preSerialization !== null) {
226 onSendHookRunner(
227 reply.context.preSerialization,
228 reply.request,
229 reply,
230 payload,
231 preserializeHookEnd
232 )
233 } else {
234 preserializeHookEnd(null, reply.request, reply, payload)
235 }
236}
237
238function preserializeHookEnd (err, request, reply, payload) {
239 if (err != null) {
240 onErrorHook(reply, err)
241 return
242 }
243
244 payload = serialize(reply.context, payload, reply.res.statusCode)
245 flatstr(payload)
246
247 onSendHook(reply, payload)
248}
249
250function onSendHook (reply, payload) {
251 reply[kReplySent] = true
252 if (reply.context.onSend !== null) {
253 onSendHookRunner(
254 reply.context.onSend,
255 reply.request,
256 reply,
257 payload,
258 wrapOnSendEnd
259 )
260 } else {
261 onSendEnd(reply, payload)
262 }
263}
264
265function wrapOnSendEnd (err, request, reply, payload) {
266 if (err != null) {
267 onErrorHook(reply, err)
268 } else {
269 onSendEnd(reply, payload)
270 }
271}
272
273function onSendEnd (reply, payload) {
274 var res = reply.res
275 var statusCode = res.statusCode
276
277 if (payload === undefined || payload === null) {
278 reply[kReplySent] = true
279
280 // according to https://tools.ietf.org/html/rfc7230#section-3.3.2
281 // we cannot send a content-length for 304 and 204, and all status code
282 // < 200.
283 if (statusCode >= 200 && statusCode !== 204 && statusCode !== 304) {
284 reply[kReplyHeaders]['content-length'] = '0'
285 }
286
287 res.writeHead(statusCode, reply[kReplyHeaders])
288 // avoid ArgumentsAdaptorTrampoline from V8
289 res.end(null, null, null)
290 return
291 }
292
293 if (typeof payload.pipe === 'function') {
294 sendStream(payload, res, reply)
295 return
296 }
297
298 if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {
299 throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)
300 }
301
302 if (!reply[kReplyHeaders]['content-length']) {
303 reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
304 }
305
306 reply[kReplySent] = true
307
308 res.writeHead(statusCode, reply[kReplyHeaders])
309
310 // avoid ArgumentsAdaptorTrampoline from V8
311 res.end(payload, null, null)
312}
313
314function sendStream (payload, res, reply) {
315 var sourceOpen = true
316
317 eos(payload, { readable: true, writable: false }, function (err) {
318 sourceOpen = false
319 if (err != null) {
320 if (res.headersSent) {
321 reply.log.warn({ err }, 'response terminated with an error with headers already sent')
322 res.destroy()
323 } else {
324 onErrorHook(reply, err)
325 }
326 }
327 // there is nothing to do if there is not an error
328 })
329
330 eos(res, function (err) {
331 if (err != null) {
332 if (res.headersSent) {
333 reply.log.warn({ err }, 'response terminated with an error with headers already sent')
334 }
335 if (sourceOpen) {
336 if (payload.destroy) {
337 payload.destroy()
338 } else if (typeof payload.close === 'function') {
339 payload.close(noop)
340 } else if (typeof payload.abort === 'function') {
341 payload.abort()
342 }
343 }
344 }
345 })
346
347 // streams will error asynchronously, and we want to handle that error
348 // appropriately, e.g. a 404 for a missing file. So we cannot use
349 // writeHead, and we need to resort to setHeader, which will trigger
350 // a writeHead when there is data to send.
351 if (!res.headersSent) {
352 for (var key in reply[kReplyHeaders]) {
353 res.setHeader(key, reply[kReplyHeaders][key])
354 }
355 } else {
356 reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode')
357 }
358 payload.pipe(res)
359}
360
361function onErrorHook (reply, error, cb) {
362 reply[kReplySent] = true
363 if (reply.context.onError !== null && reply[kReplyErrorHandlerCalled] === true) {
364 reply[kReplyIsRunningOnErrorHook] = true
365 onSendHookRunner(
366 reply.context.onError,
367 reply.request,
368 reply,
369 error,
370 () => handleError(reply, error, cb)
371 )
372 } else {
373 handleError(reply, error, cb)
374 }
375}
376
377function handleError (reply, error, cb) {
378 reply[kReplyIsRunningOnErrorHook] = false
379 var res = reply.res
380 var statusCode = res.statusCode
381 statusCode = (statusCode >= 400) ? statusCode : 500
382 // treat undefined and null as same
383 if (error != null) {
384 if (error.headers !== undefined) {
385 reply.headers(error.headers)
386 }
387 if (error.status >= 400) {
388 statusCode = error.status
389 } else if (error.statusCode >= 400) {
390 statusCode = error.statusCode
391 }
392 }
393
394 res.statusCode = statusCode
395
396 var errorHandler = reply.context.errorHandler
397 if (errorHandler && reply[kReplyErrorHandlerCalled] === false) {
398 reply[kReplySent] = false
399 reply[kReplyIsError] = false
400 reply[kReplyErrorHandlerCalled] = true
401 var result = errorHandler(error, reply.request, reply)
402 if (result && typeof result.then === 'function') {
403 wrapThenable(result, reply)
404 }
405 return
406 }
407
408 var payload = serializeError({
409 error: statusCodes[statusCode + ''],
410 code: error.code,
411 message: error.message || '',
412 statusCode: statusCode
413 })
414 flatstr(payload)
415 reply[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
416
417 if (cb) {
418 cb(reply, payload)
419 return
420 }
421
422 reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
423 reply[kReplySent] = true
424 res.writeHead(res.statusCode, reply[kReplyHeaders])
425 res.end(payload)
426}
427
428function setupResponseListeners (reply) {
429 reply[kReplyStartTime] = now()
430
431 var onResFinished = err => {
432 reply.res.removeListener('finish', onResFinished)
433 reply.res.removeListener('error', onResFinished)
434
435 var ctx = reply.context
436
437 if (ctx && ctx.onResponse !== null) {
438 hookRunner(
439 ctx.onResponse,
440 onResponseIterator,
441 reply.request,
442 reply,
443 onResponseCallback
444 )
445 } else {
446 onResponseCallback(err, reply.request, reply)
447 }
448 }
449
450 reply.res.on('finish', onResFinished)
451 reply.res.on('error', onResFinished)
452}
453
454function onResponseIterator (fn, request, reply, next) {
455 return fn(request, reply, next)
456}
457
458function onResponseCallback (err, request, reply) {
459 var responseTime = 0
460
461 if (reply[kReplyStartTime] !== undefined) {
462 responseTime = now() - reply[kReplyStartTime]
463 }
464
465 if (err != null) {
466 reply.log.error({
467 res: reply.res,
468 err,
469 responseTime
470 }, 'request errored')
471 return
472 }
473
474 reply.log.info({
475 res: reply.res,
476 responseTime
477 }, 'request completed')
478}
479
480function buildReply (R) {
481 function _Reply (res, context, request, log) {
482 this.res = res
483 this.context = context
484 this[kReplyIsError] = false
485 this[kReplyErrorHandlerCalled] = false
486 this[kReplySent] = false
487 this[kReplySentOverwritten] = false
488 this[kReplySerializer] = null
489 this.request = request
490 this[kReplyHeaders] = {}
491 this[kReplyStartTime] = undefined
492 this.log = log
493 }
494 _Reply.prototype = new R()
495 return _Reply
496}
497
498function notFound (reply) {
499 reply[kReplySent] = false
500 reply[kReplyIsError] = false
501
502 if (reply.context[kFourOhFourContext] === null) {
503 reply.log.warn('Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.')
504 reply.code(404).send('404 Not Found')
505 return
506 }
507
508 reply.context = reply.context[kFourOhFourContext]
509 reply.context.handler(reply.request, reply)
510}
511
512function noop () {}
513
514function getHeaderProper (reply, key) {
515 key = key.toLowerCase()
516 var res = reply.res
517 var value = reply[kReplyHeaders][key]
518 if (value === undefined && res.hasHeader(key)) {
519 value = res.getHeader(key)
520 }
521 return value
522}
523
524function getHeaderFallback (reply, key) {
525 key = key.toLowerCase()
526 var res = reply.res
527 var value = reply[kReplyHeaders][key]
528 if (value === undefined) {
529 value = res.getHeader(key)
530 }
531 return value
532}
533
534// ponyfill for hasHeader. It has been introduced into Node 7.7,
535// so it's ok to use it in 8+
536{
537 const v = process.version.match(/v(\d+)/)[1]
538 if (Number(v) > 7) {
539 getHeader = getHeaderProper
540 } else {
541 getHeader = getHeaderFallback
542 }
543}
544
545module.exports = Reply
546module.exports.buildReply = buildReply
547module.exports.setupResponseListeners = setupResponseListeners