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