update devDependencies, add jsdoc lint, fix lint issues
[squeep-api-dingus] / lib / dingus.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * A very minimal API server framework.
6 * Just a self-contained router and some request glue.
7 */
8
9 /**
10 * @typedef {import('node:http')} http
11 */
12
13 require('./patches');
14 const { promises: fsPromises } = require('node:fs');
15 const path = require('node:path');
16 const querystring = require('node:querystring');
17 const common = require('./common');
18 const ContentNegotiation = require('./content-negotiation');
19 const Enum = require('./enum');
20 const { ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors');
21 const { extensionToMime } = require('./mime-helper');
22 const Router = require('./router');
23 const Template = require('./template');
24
25 // For logging.
26 const { fileScope } = require('@squeep/log-helper');
27 const _fileScope = fileScope(__filename);
28
29 const defaultOptions = {
30 ignoreTrailingSlash: true,
31 proxyPrefix: '',
32 strictAccept: true,
33 selfBaseUrl: '',
34 staticMetadata: true,
35 staticPath: undefined, // No reasonable default
36 trustProxy: true,
37 intrinsicHeadMethod: true,
38 intrinsicHeadPersistBody: false,
39 querystring,
40 };
41
42 const cookieSplitRE = /; */;
43
44 class Dingus {
45 /**
46 * @param {object} logger console-like object which implements logging methods
47 * @param {object} options options
48 * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
49 * @param {string} options.proxyPrefix leading part of url path to strip
50 * @param {boolean} options.strictAccept whether to error on unsupported Accept type
51 * @param {string} options.selfBaseUrl for constructing links
52 * @param {boolean} options.staticMetadata serve static headers with static files
53 * @param {boolean} options.trustProxy trust some header data to be provided by proxy
54 * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
55 * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
56 * @param {object} options.querystring alternate qs parser to use
57 */
58 constructor(logger = console, options = {}) {
59 common.setOptions(this, defaultOptions, options);
60
61 this.router = new Router(options);
62
63 if (!this.proxyPrefix) {
64 this._stripPrefix = (p) => p;
65 }
66
67 this.responseTypes = [
68 Enum.ContentType.TextHTML,
69 Enum.ContentType.TextPlain,
70 Enum.ContentType.ApplicationJson,
71 ];
72
73 this.logger = logger;
74 }
75
76
77 /**
78 * Resolve relative and empty paths in url
79 * @param {string} p path
80 * @returns {string} normalized path
81 */
82 _normalizePath(p) {
83 const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
84 return this._stripPrefix(pathNorm);
85 }
86
87
88 /**
89 * Remove a leading portion of url path
90 * N.B. This method gets obliterated if there is no prefix defined at construction
91 * @param {string} p path
92 * @returns {string} de-prefixed path
93 */
94 _stripPrefix(p) {
95 if (p.startsWith(this.proxyPrefix)) {
96 return p.slice(this.proxyPrefix.length);
97 }
98 return p;
99 }
100
101
102 /**
103 * Returns the path part, and querystring object, from a request url.
104 * @param {string} url url
105 * @returns {object} path string and query params
106 */
107 _splitUrl(url) {
108 const [ p, qs ] = common.splitFirst(url, '?');
109 return {
110 pathPart: this._normalizePath(p),
111 queryParams: this.querystring.parse(qs),
112 };
113 }
114
115
116 /**
117 * Insert a new path handler
118 * @param {string|string[]} methods methods to match for this path, '*' allowed
119 * @param {string} urlPath request path to match
120 * @param {Function} handler handler to invoke on match
121 * @param {any[]} handlerArgs additional arguments for handler
122 */
123 on(methods, urlPath, handler, ...handlerArgs) {
124 this.router.on(methods, urlPath, handler, handlerArgs);
125 }
126
127
128 /**
129 * Common header tagging for all requests.
130 * Add our own identifier, and persist any external transit identifiers.
131 * Sets requestId on ctx to a new uuid.
132 * If X-Request-Id or X-Correlation-Id exist on incoming headers, sets them
133 * on outgoing headers and sets on ctx.
134 * @param {http.ClientRequest} req request
135 * @param {http.ServerResponse} res response
136 * @param {object} ctx context
137 * @returns {string} id added request
138 */
139 static tagContext(req, res, ctx) {
140 const requestId = common.requestId();
141 ctx.requestId = requestId;
142 res.setHeader(Enum.Header.RequestId, requestId);
143 [Enum.Header.XRequestId, Enum.Header.XCorrelationId].forEach((h) => {
144 const v = req.getHeader(h);
145 if (v) {
146 ctx[h.replace(/-/g, '')] = v;
147 res.setHeader(h, v);
148 }
149 });
150 return requestId;
151 }
152
153
154 /**
155 *
156 * @param {http.ClientRequest} req request
157 * @returns {string} first forwarded header ip
158 */
159 _getAddress(req) {
160 // TODO: RFC7239 Forwarded support
161 const address = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedFor)) ||
162 (this.trustProxy && req?.getHeader(Enum.Header.XRealIP)) ||
163 (req?.connection?.remoteAddress) ||
164 '';
165 return address.split(/\s*,\s*/u)[0];
166 }
167
168
169 /**
170 *
171 * @param {http.ClientRequest} req request
172 * @returns {string} first forwarded header protocol
173 */
174 _getProtocol(req) {
175 // TODO: RFC7239 Forwarded support
176 const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) ||
177 ((req?.connection?.encrypted) ? 'https' : 'http');
178 return protocol.split(/\s*,\s*/u)[0];
179 }
180
181
182 /**
183 * Sets ctx.clientAddress and ctx.clientProtocol.
184 * @param {http.ClientRequest} req request
185 * @param {http.ServerResponse} res response
186 * @param {object} ctx context
187 */
188 clientAddressContext(req, res, ctx) {
189 ctx.clientAddress = this._getAddress(req);
190 ctx.clientProtocol = this._getProtocol(req);
191 }
192
193
194 /**
195 * Sets ctx.cookie from Cookie header.
196 * @param {http.ClientRequest} req request
197 * @param {http.ServerResponse} res response
198 * @param {object} ctx context
199 */
200 static ingestCookie(req, res, ctx) {
201 ctx.cookie = {};
202 req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => {
203 const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
204 try {
205 return decodeURIComponent(x.trim());
206 } catch (e) {
207 return x;
208 }
209 });
210 if (name && !(name in ctx.cookie)) {
211 const isQuoted = value?.startsWith('"') && value.endsWith('"');
212 ctx.cookie[name] = isQuoted ? value.slice(1, -1) : value; // eslint-disable-line security/detect-object-injection
213 }
214 });
215 }
216
217
218 /**
219 * Called before every request handler.
220 * Sets tracking identifiers and client information on ctx.
221 * @param {http.ClientRequest} req request
222 * @param {http.ServerResponse} res response
223 * @param {object} ctx context
224 */
225 async preHandler(req, res, ctx) {
226 this.constructor.tagContext(req, res, ctx);
227 this.clientAddressContext(req, res, ctx);
228 this.constructor.ingestCookie(req, res, ctx);
229 }
230
231
232 /**
233 * Helper for collecting chunks as array of buffers.
234 * @param {Buffer[]} chunks array to collect chunks in
235 * @param {string|Buffer} chunk chunk to collect
236 * @param {string} encoding chunk encoding
237 */
238 static pushBufChunk(chunks, chunk, encoding = 'utf8') {
239 if (chunk) {
240 if (typeof chunk === 'string') {
241 chunk = Buffer.from(chunk, encoding);
242 }
243 chunks.push(chunk);
244 }
245 }
246
247
248 /**
249 * Sets ctx.responseBody and calls handler upon res.end().
250 * @param {http.ClientRequest} req request
251 * @param {http.ServerResponse} res response
252 * @param {object} ctx context
253 * @param {*} handler fn(req, res, ctx)
254 */
255 static setEndBodyHandler(req, res, ctx, handler) {
256 const origWrite = res.write.bind(res);
257 const origEnd = res.end.bind(res);
258 const chunks = [];
259 res.write = function (chunk, encoding, ...rest) {
260 Dingus.pushBufChunk(chunks, chunk, encoding);
261 return origWrite(chunk, encoding, ...rest);
262 };
263 res.end = function (data, encoding, ...rest) {
264 Dingus.pushBufChunk(chunks, data, encoding);
265 ctx.responseBody = Buffer.concat(chunks);
266 handler(req, res, ctx);
267 return origEnd(data, encoding, ...rest);
268 };
269 }
270
271
272 /**
273 * Intercept writes for head requests, do not send to client,
274 * but send length, and make body available in context.
275 * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
276 * @param {http.ClientRequest} req request
277 * @param {http.ServerResponse} res response
278 * @param {object} ctx context
279 * @param {boolean} persistResponseBody store body buffer on context
280 */
281 static setHeadHandler(req, res, ctx, persistResponseBody = false) {
282 if (req.method === 'HEAD') {
283 const origEnd = res.end.bind(res);
284 const chunks = [];
285 res.write = function (chunk, encoding) {
286 Dingus.pushBufChunk(chunks, chunk, encoding);
287 // No call to original res.write.
288 };
289 res.end = function (data, encoding, ...rest) {
290 Dingus.pushBufChunk(chunks, data, encoding);
291 const responseBody = Buffer.concat(chunks);
292 res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(responseBody));
293 if (persistResponseBody) {
294 ctx.responseBody = responseBody;
295 }
296 return origEnd(undefined, encoding, ...rest);
297 };
298 }
299 }
300
301
302 /**
303 * Resolve the handler to invoke for a request.
304 * @param {http.ClientRequest} req request
305 * @param {http.ServerResponse} res response
306 * @param {object} ctx context
307 * @returns {object} handler and args
308 */
309 _determineHandler(req, res, ctx) {
310 const _scope = _fileScope('_determineHandler');
311
312 const { pathPart, queryParams } = this._splitUrl(req.url);
313 ctx.queryParams = queryParams;
314
315 let handler, handlerArgs = [];
316 try {
317 ({ handler, handlerArgs } = this.router.lookup(req.method, pathPart, ctx));
318 } catch (e) {
319 if (e instanceof URIError) {
320 handler = this.handlerBadRequest.bind(this);
321 } else if (e instanceof RouterNoPathError) {
322 handler = this.handlerNotFound.bind(this);
323 } else if (e instanceof RouterNoMethodError) {
324 if (this.intrinsicHeadMethod && req.method === 'HEAD') {
325 ({ handler, handlerArgs } = this._determineHeadHandler(req, res, ctx, pathPart));
326 } else {
327 handler = this.handlerMethodNotAllowed.bind(this);
328 }
329 } else {
330 this.logger.error(_scope, 'unexpected error', { error: e });
331 handler = this.handlerInternalServerError.bind(this);
332 }
333 }
334 return { handler, handlerArgs };
335 }
336
337
338 /**
339 * For intrinsic HEAD requests, resolve the handler to invoke.
340 * @param {http.ClientRequest} req request
341 * @param {http.ServerResponse} res response
342 * @param {object} ctx context
343 * @param {string} pathPart router path
344 * @returns {object} hander and handler args
345 */
346 _determineHeadHandler(req, res, ctx, pathPart) {
347 const _scope = _fileScope('_determineHeadHandler');
348 let handler, handlerArgs = [];
349 try {
350 ({ handler, handlerArgs } = this.router.lookup('GET', pathPart, ctx));
351 Dingus.setHeadHandler(req, res, ctx, this.intrinsicHeadPersistBody);
352 } catch (e) {
353 if (e instanceof RouterNoMethodError) {
354 handler = this.handlerMethodNotAllowed.bind(this);
355 } else {
356 this.logger.error(_scope, 'unexpected error', { error: e });
357 handler = this.handlerInternalServerError.bind(this);
358 }
359 }
360 return { handler, handlerArgs };
361 }
362
363
364 /**
365 * Dispatch the handler for a request
366 * @param {http.ClientRequest} req request
367 * @param {http.ServerResponse} res response
368 * @param {object} ctx context
369 * @returns {Promise<void>} handler promise
370 */
371 async dispatch(req, res, ctx = {}) {
372 const { handler, handlerArgs } = this._determineHandler(req, res, ctx);
373 try {
374 await this.preHandler(req, res, ctx);
375 return await handler(req, res, ctx, ...handlerArgs);
376 } catch (e) {
377 ctx.error = e;
378 this.sendErrorResponse(e, req, res, ctx);
379 }
380 }
381
382
383 /**
384 * Return normalized type, without any parameters.
385 * @param {http.ClientRequest} req request
386 * @returns {string} bare content type
387 */
388 static getRequestContentType(req) {
389 const contentType = req.getHeader(Enum.Header.ContentType);
390 return (contentType || '').split(';')[0].trim().toLowerCase();
391 }
392
393
394 /**
395 * Parse rawBody as contentType into ctx.parsedBody.
396 * @param {string} contentType type
397 * @param {object} ctx context
398 * @param {string|Buffer} rawBody body data
399 */
400 parseBody(contentType, ctx, rawBody) {
401 const _scope = _fileScope('parseBody');
402
403 switch (contentType) {
404 case Enum.ContentType.ApplicationForm:
405 ctx.parsedBody = this.querystring.parse(rawBody);
406 break;
407
408 case Enum.ContentType.ApplicationJson:
409 try {
410 ctx.parsedBody = JSON.parse(rawBody);
411 } catch (e) {
412 this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e });
413 throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message);
414 }
415 break;
416
417 default:
418 this.logger.debug(_scope, 'unhandled content-type', { requestId: ctx.requestId, contentType });
419 throw new ResponseError(Enum.ErrorResponse.UnsupportedMediaType);
420 }
421 }
422
423
424 /**
425 * Return all body data from a request.
426 * @param {http.ClientRequest} req request
427 * @param {number=} maximumBodySize bytes
428 * @param {boolean=} toString whether to convert body data buffer to string
429 * @returns {Promise<Buffer|string>} body data
430 */
431 async bodyData(req, maximumBodySize, toString = true) {
432 const _scope = _fileScope('bodyData');
433 return new Promise((resolve, reject) => {
434 const body = [];
435 let length = 0;
436 req.on('data', (chunk) => {
437 body.push(chunk);
438 length += Buffer.byteLength(chunk);
439 if (maximumBodySize && length > maximumBodySize) {
440 this.logger.debug(_scope, 'body data exceeded limit', { length, maximumBodySize });
441 reject(new ResponseError(Enum.ErrorResponse.RequestEntityTooLarge));
442 }
443 });
444 req.on('end', () => {
445 const bodyBuffer = Buffer.concat(body);
446 resolve(toString ? bodyBuffer.toString() : bodyBuffer);
447 });
448 req.on('error', (e) => {
449 this.logger.error(_scope, 'failed', { error: e });
450 reject(e);
451 });
452 });
453 }
454
455
456 /**
457 * Read and parse request body data.
458 * Sets ctx.parsedBody, and optionally ctx.rawBody.
459 * @param {http.ClientRequest} req request
460 * @param {http.ServerResponse} res response
461 * @param {object} ctx context
462 * @param {object} options options
463 * @param {boolean} options.parseEmptyBody attempt to parse an empty body, results may vary by encoding
464 * @param {boolean} options.persistRawBody store raw body buffer on context
465 * @param {number=} options.maximumBodySize bytes
466 */
467 async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) {
468 const rawBody = await this.bodyData(req, maximumBodySize);
469 if (persistRawBody) {
470 ctx.rawBody = rawBody;
471 }
472 if (rawBody || parseEmptyBody) {
473 const contentType = Dingus.getRequestContentType(req);
474 this.parseBody(contentType, ctx, rawBody);
475 }
476 }
477
478
479 /**
480 * Return the best matching response type.
481 * @param {string[]} responseTypes available content types
482 * @param {http.ClientRequest} req request
483 * @returns {string} preferred content type
484 */
485 static getResponseContentType(responseTypes, req) {
486 const acceptHeader = req.getHeader(Enum.Header.Accept);
487 return ContentNegotiation.accept(responseTypes, acceptHeader);
488 }
489
490
491 /**
492 * Returns a list of the most-preferred content encodings for the response.
493 * @param {string[]} responseEncodings available encodings
494 * @param {http.ClientRequest} req request
495 * @returns {string} preferred encoding
496 */
497 static getResponseEncoding(responseEncodings, req) {
498 const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
499 return ContentNegotiation.preferred(responseEncodings, acceptEncodingHeader);
500 }
501
502
503 /**
504 * Set the best content type for the response.
505 * Sets ctx.responseType, and Content-Type header.
506 * @param {string[]} responseTypes default first
507 * @param {http.ClientRequest} req request
508 * @param {http.ServerResponse} res response
509 * @param {object} ctx context
510 */
511 setResponseType(responseTypes, req, res, ctx) {
512 const _scope = _fileScope('setResponseType');
513 ctx.responseType = Dingus.getResponseContentType(responseTypes, req);
514 if (!ctx.responseType) {
515 if (this.strictAccept) {
516 this.logger.debug(_scope, 'unhandled strict accept', { requestId: req.requestId });
517 throw new ResponseError(Enum.ErrorResponse.NotAcceptable);
518 } else {
519 ctx.responseType = responseTypes[0];
520 }
521 }
522 res.setHeader(Enum.Header.ContentType, ctx.responseType);
523 }
524
525
526 /**
527 * Inserts an encoding into Content-Encoding header.
528 * @param {http.ServerResponse} res response
529 * @param {string} encoding encoding
530 */
531 static addEncodingHeader(res, encoding) {
532 const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
533 if (existingEncodings) {
534 encoding = `${encoding}, ${existingEncodings}`;
535 }
536 res.setHeader(Enum.Header.ContentEncoding, encoding);
537 }
538
539
540 /**
541 * Attempt to fetch both data and metadata for a file.
542 * @param {string} filePath path to file
543 * @returns {Promise<[object=, object=]>} array with file contents and stats
544 */
545 async _readFileInfo(filePath) {
546 const _scope = _fileScope('_readFileInfo');
547 let result;
548 try {
549 // eslint-disable-next-line security/detect-non-literal-fs-filename
550 const stat = fsPromises.stat(filePath);
551 // eslint-disable-next-line security/detect-non-literal-fs-filename
552 const data = fsPromises.readFile(filePath);
553 result = await Promise.all([stat, data]);
554 } catch (e) {
555 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e.code)) {
556 return [null, null];
557 }
558 this.logger.error(_scope, 'fs error', { error: e, filePath });
559 throw e;
560 }
561 return result;
562 }
563
564
565 /**
566 * Potentially add additional headers from static file meta-file.
567 * @param {http.ServerResponse} res response
568 * @param {string} directory path to file
569 * @param {string} fileName - already normalized and filtered
570 * @returns {boolean} if meta headers were added
571 */
572 async _serveFileMetaHeaders(res, directory, fileName) {
573 const _scope = _fileScope('_serveFileMetaHeaders');
574
575 let added = false;
576 const metaPrefix = '.';
577 const metaSuffix = '.meta';
578 const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`;
579 const metaFilePath = path.join(directory, metaFileName);
580
581 const [stat, data] = await this._readFileInfo(metaFilePath);
582 if (!stat) {
583 return added;
584 }
585
586 const lineBreakRE = /\r\n|\n|\r/;
587 const lines = data.toString().split(lineBreakRE);
588 common.unfoldHeaderLines(lines);
589
590 const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
591 lines.forEach((line) => {
592 if (line) {
593 const result = headerParseRE.exec(line);
594 const { groups: header } = result;
595 res.setHeader(header.name, header.value);
596 added = true;
597 }
598 });
599 return added;
600 }
601
602
603 /**
604 * Serve a file from a directory, with rudimentary cache awareness.
605 * This will also serve pre-encoded variations if available and requested.
606 * @param {http.ClientRequest} req request
607 * @param {http.ServerResponse} res response
608 * @param {object} ctx context
609 * @param {string} directory path to file
610 * @param {string} fileName file name
611 * @returns {Promise<void>=} serves file or renders status code
612 */
613 async serveFile(req, res, ctx, directory, fileName) {
614 const _scope = _fileScope('serveFile');
615 this.logger.debug(_scope, 'called', { req, ctx });
616
617 // Require a directory field.
618 if (!directory) {
619 this.logger.debug(_scope, 'rejected unset directory', { fileName });
620 return this.handlerNotFound(req, res, ctx);
621 }
622
623 // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
624 fileName = path.normalize(fileName);
625
626 // We will not deal with any subdirs, nor any dot-files.
627 // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.)
628 if (fileName.indexOf(path.sep) >= 0
629 || fileName.startsWith('.')) {
630 this.logger.debug(_scope, 'rejected filename', { fileName });
631 return this.handlerNotFound(req, res, ctx);
632 }
633
634 const filePath = path.join(directory, fileName);
635
636 // File must exist, before any alternate static encodings will be considered.
637 let [stat, data] = await this._readFileInfo(filePath);
638 if (!stat) {
639 return this.handlerNotFound(req, res, ctx);
640 }
641
642 // If encodings were requested, check for static versions to serve.
643 // Update stat and data if matching version is found.
644 ctx.availableEncodings = Dingus.getResponseEncoding(Object.values(Enum.EncodingType), req);
645 if (ctx.availableEncodings.length === 0) {
646 // Identity encoding was specifically denied, and nothing else available.
647 this.logger.debug(_scope, 'no suitable encodings', { ctx });
648 return this.handlerMethodNotAllowed(req, res, ctx);
649 }
650 for (const encoding of ctx.availableEncodings) {
651 if (encoding === Enum.EncodingType.Identity) {
652 break;
653 }
654 const suffix = Enum.EncodingTypeSuffix[encoding];
655 if (!suffix) {
656 this.logger.error(_scope, 'supported encoding missing mapped suffix', { ctx, encoding });
657 continue;
658 }
659 const encodedFilePath = `${filePath}${suffix}`;
660 const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
661 if (encodedStat) {
662 ([ stat, data ] = [ encodedStat, encodedData ]);
663 ctx.selectedEncoding = encoding;
664 Dingus.addEncodingHeader(res, encoding);
665 res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
666 this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
667 }
668 break;
669 }
670
671 const lastModifiedDate = new Date(stat.mtimeMs);
672 res.setHeader(Enum.Header.LastModified, lastModifiedDate.toGMTString());
673
674 const eTag = common.generateETag(filePath, stat, data);
675 res.setHeader(Enum.Header.ETag, eTag);
676
677 if (common.isClientCached(req, stat.mtimeMs, eTag)) {
678 this.logger.debug(_scope, 'client cached file', { filePath });
679 res.statusCode = 304; // Not Modified
680 res.end();
681 return;
682 }
683
684 // Set the type based on extension of un-encoded filename.
685 const ext = path.extname(filePath).slice(1); // Drop the dot
686 const contentType = extensionToMime(ext);
687 res.setHeader(Enum.Header.ContentType, contentType);
688
689 // We presume static files are relatively cacheable.
690 res.setHeader(Enum.Header.CacheControl, 'public');
691
692 if (this.staticMetadata) {
693 ctx.metaHeaders = await this._serveFileMetaHeaders(res, directory, fileName);
694 }
695
696 this.logger.debug(_scope, 'serving file', { filePath, contentType });
697 res.end(data);
698 }
699
700
701 /**
702 * Return a content-type appropriate rendering of an errorResponse object.
703 * @param {string} contentType content-type of response
704 * @param {object} err either an Error object, or an error response
705 * @param {number} err.statusCode htttp status code
706 * @param {string} err.errorMessage http status message
707 * @param {string|string[]} err.details additional details to include
708 * @returns {string} rendered err
709 */
710 // eslint-disable-next-line class-methods-use-this
711 renderError(contentType, err) {
712 switch (contentType) {
713 case Enum.ContentType.ApplicationJson:
714 return JSON.stringify(err);
715
716 case Enum.ContentType.TextHTML:
717 return Template.errorHTML(err);
718
719 case Enum.ContentType.TextPlain:
720 default:
721 return [err.errorMessage, err.details].join('\r\n');
722 }
723 }
724
725
726 /**
727 * Send an error response. Terminal.
728 * Logs any non-error-response errors as such.
729 * @param {object} err either an Error object, or an error response
730 * @param {http.ClientRequest} req request
731 * @param {http.ServerResponse} res response
732 * @param {object} ctx context
733 */
734 sendErrorResponse(err, req, res, ctx) {
735 const _scope = _fileScope('sendErrorResponse');
736 let body;
737
738 // Default to a content type if one is not yet present
739 if (!res.hasHeader(Enum.Header.ContentType)) {
740 res.setHeader(Enum.Header.ContentType, Enum.ContentType.TextPlain);
741 }
742
743 if (err?.statusCode) {
744 res.statusCode = err.statusCode;
745 body = this.renderError(res.getHeader(Enum.Header.ContentType), err);
746 this.logger.debug(_scope, 'handler error', { err, req, res, ctx });
747 } else {
748 res.statusCode = 500;
749 body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError);
750 this.logger.error(_scope, 'handler exception', { err, req, res, ctx });
751 }
752 res.end(body);
753 }
754
755
756 /**
757 * @param {http.ClientRequest} req request
758 * @param {http.ServerResponse} res response
759 * @param {object} ctx context
760 * @param {string} file - override ctx.params.file
761 */
762 async handlerGetStaticFile(req, res, ctx, file) {
763 Dingus.setHeadHandler(req, res, ctx);
764
765 // Set a default response type to handle any errors; will be re-set to serve actual static content type.
766 this.setResponseType(this.responseTypes, req, res, ctx);
767
768 await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
769 }
770
771
772 /**
773 * @param {http.ClientRequest} req request
774 * @param {http.ServerResponse} res response
775 * @param {object} ctx context
776 * @param {string} newPath url to redirect to
777 * @param {number=} statusCode status code to use for redirect, default 307
778 */
779 async handlerRedirect(req, res, ctx, newPath, statusCode = 307) {
780 this.setResponseType(this.responseTypes, req, res, ctx);
781 res.setHeader(Enum.Header.Location, newPath);
782 res.statusCode = statusCode;
783 res.end();
784 }
785
786
787 /**
788 * @param {http.ClientRequest} req request
789 * @param {http.ServerResponse} res response
790 * @param {object} ctx context
791 */
792 async handlerMethodNotAllowed(req, res, ctx) {
793 this.setResponseType(this.responseTypes, req, res, ctx);
794 throw new ResponseError(Enum.ErrorResponse.MethodNotAllowed);
795 }
796
797
798 /**
799 * @param {http.ClientRequest} req request
800 * @param {http.ServerResponse} res response
801 * @param {object} ctx context
802 */
803 async handlerNotFound(req, res, ctx) {
804 this.setResponseType(this.responseTypes, req, res, ctx);
805 throw new ResponseError(Enum.ErrorResponse.NotFound);
806 }
807
808
809 /**
810 * @param {http.ClientRequest} req request
811 * @param {http.ServerResponse} res response
812 * @param {object} ctx context
813 */
814 async handlerBadRequest(req, res, ctx) {
815 this.setResponseType(this.responseTypes, req, res, ctx);
816 throw new ResponseError(Enum.ErrorResponse.BadRequest);
817 }
818
819
820 /**
821 * @param {http.ClientRequest} req request
822 * @param {http.ServerResponse} res response
823 * @param {object} ctx context
824 */
825 async handlerInternalServerError(req, res, ctx) {
826 this.setResponseType(this.responseTypes, req, res, ctx);
827 throw new ResponseError(Enum.ErrorResponse.InternalServerError);
828 }
829
830 }
831
832 module.exports = Dingus;