Merge branch 'v2.1-dev' as v2.1.2
[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 = /\s*;\s*/;
42 const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
43 const lineBreakRE = /\r\n|\n|\r/;
44 const proxySplitRE = /\s*,\s*/u;
45
46 class Dingus {
47 /**
48 * @param {object} logger console-like object which implements logging methods
49 * @param {object} options options
50 * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
51 * @param {string} options.proxyPrefix leading part of url path to strip
52 * @param {boolean} options.strictAccept whether to error on unsupported Accept type
53 * @param {string} options.selfBaseUrl for constructing links
54 * @param {boolean} options.staticMetadata serve static headers with static files
55 * @param {boolean} options.trustProxy trust some header data to be provided by proxy
56 * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
57 * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
58 * @param {object} options.querystring alternate qs parser to use
59 */
60 constructor(logger = console, options = {}) {
61 common.setOptions(this, defaultOptions, options);
62
63 this.router = new Router(options);
64
65 if (!this.proxyPrefix) {
66 this._stripPrefix = (p) => p;
67 }
68
69 this.responseTypes = [
70 Enum.ContentType.TextHTML,
71 Enum.ContentType.TextPlain,
72 Enum.ContentType.ApplicationJson,
73 ];
74
75 this.logger = logger;
76 }
77
78
79 /**
80 * Resolve relative and empty paths in url
81 * @param {string} p path
82 * @returns {string} normalized path
83 */
84 _normalizePath(p) {
85 const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
86 return this._stripPrefix(pathNorm);
87 }
88
89
90 /**
91 * Remove a leading portion of url path
92 * N.B. This method gets obliterated if there is no prefix defined at construction
93 * @param {string} p path
94 * @returns {string} de-prefixed path
95 */
96 _stripPrefix(p) {
97 if (p.startsWith(this.proxyPrefix)) {
98 return p.slice(this.proxyPrefix.length);
99 }
100 return p;
101 }
102
103
104 /**
105 * Returns the path part, and querystring object, from a request url.
106 * @param {string} url url
107 * @returns {object} path string and query params
108 */
109 _splitUrl(url) {
110 const [ p, qs ] = common.splitFirst(url, '?');
111 return {
112 pathPart: this._normalizePath(p),
113 queryParams: this.querystring.parse(qs),
114 };
115 }
116
117
118 /**
119 * Insert a new path handler
120 * @param {string|string[]} methods methods to match for this path, '*' allowed
121 * @param {string} urlPath request path to match
122 * @param {Function} handler handler to invoke on match
123 * @param {any[]} handlerArgs additional arguments for handler
124 */
125 on(methods, urlPath, handler, ...handlerArgs) {
126 this.router.on(methods, urlPath, handler, handlerArgs);
127 }
128
129
130 /**
131 * Common header tagging for all requests.
132 * Add our own identifier, and persist any external transit identifiers.
133 * Sets requestId on ctx to a new uuid.
134 * If X-Request-Id or X-Correlation-Id exist on incoming headers, sets them
135 * on outgoing headers and sets on ctx.
136 * @param {http.ClientRequest} req request
137 * @param {http.ServerResponse} res response
138 * @param {object} ctx context
139 * @returns {string} id added request
140 */
141 static tagContext(req, res, ctx) {
142 const requestId = common.requestId();
143 ctx.requestId = requestId;
144 res.setHeader(Enum.Header.RequestId, requestId);
145 [Enum.Header.XRequestId, Enum.Header.XCorrelationId].forEach((h) => {
146 const v = req.getHeader(h);
147 if (v) {
148 ctx[h.replace(/-/g, '')] = v;
149 res.setHeader(h, v);
150 }
151 });
152 return requestId;
153 }
154
155
156 /**
157 *
158 * @param {http.ClientRequest} req request
159 * @returns {string} first forwarded header ip
160 */
161 _getAddress(req) {
162 // TODO: RFC7239 Forwarded support
163 const address = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedFor)) ||
164 (this.trustProxy && req?.getHeader(Enum.Header.XRealIP)) ||
165 (req?.connection?.remoteAddress) ||
166 '';
167 return address.split(proxySplitRE)[0];
168 }
169
170
171 /**
172 *
173 * @param {http.ClientRequest} req request
174 * @returns {string} first forwarded header protocol
175 */
176 _getProtocol(req) {
177 // TODO: RFC7239 Forwarded support
178 const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) ||
179 ((req?.connection?.encrypted) ? 'https' : 'http');
180 return protocol.split(proxySplitRE)[0];
181 }
182
183
184 /**
185 * Sets ctx.clientAddress and ctx.clientProtocol.
186 * @param {http.ClientRequest} req request
187 * @param {http.ServerResponse} res response
188 * @param {object} ctx context
189 */
190 clientAddressContext(req, res, ctx) {
191 ctx.clientAddress = this._getAddress(req);
192 ctx.clientProtocol = this._getProtocol(req);
193 }
194
195
196 /**
197 * Sets ctx.cookie from Cookie header.
198 * @param {http.ClientRequest} req request
199 * @param {http.ServerResponse} res response
200 * @param {object} ctx context
201 */
202 static ingestCookie(req, res, ctx) {
203 ctx.cookie = {};
204 req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => {
205 const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
206 try {
207 return decodeURIComponent(x.trim());
208 } catch (e) { // eslint-disable-line no-unused-vars
209 return x;
210 }
211 });
212 if (name && !(name in ctx.cookie)) {
213 const isQuoted = value?.startsWith('"') && value.endsWith('"');
214 ctx.cookie[name] = isQuoted ? value.slice(1, -1) : value; // eslint-disable-line security/detect-object-injection
215 }
216 });
217 }
218
219
220 /**
221 * Called before every request handler.
222 * Sets tracking identifiers and client information on ctx.
223 * @param {http.ClientRequest} req request
224 * @param {http.ServerResponse} res response
225 * @param {object} ctx context
226 */
227 async preHandler(req, res, ctx) {
228 this.constructor.tagContext(req, res, ctx);
229 this.clientAddressContext(req, res, ctx);
230 this.constructor.ingestCookie(req, res, ctx);
231 }
232
233
234 /**
235 * Helper for collecting chunks as array of buffers.
236 * @param {Buffer[]} chunks array to collect chunks in
237 * @param {string|Buffer} chunk chunk to collect
238 * @param {string} encoding chunk encoding
239 */
240 static pushBufChunk(chunks, chunk, encoding = 'utf8') {
241 if (chunk) {
242 if (typeof chunk === 'string') {
243 chunk = Buffer.from(chunk, encoding);
244 }
245 chunks.push(chunk);
246 }
247 }
248
249
250 /**
251 * Sets ctx.responseBody and calls handler upon res.end().
252 * @param {http.ClientRequest} req request
253 * @param {http.ServerResponse} res response
254 * @param {object} ctx context
255 * @param {*} handler fn(req, res, ctx)
256 */
257 static setEndBodyHandler(req, res, ctx, handler) {
258 const origWrite = res.write.bind(res);
259 const origEnd = res.end.bind(res);
260 const chunks = [];
261 res.write = function (chunk, encoding, ...rest) {
262 Dingus.pushBufChunk(chunks, chunk, encoding);
263 return origWrite(chunk, encoding, ...rest);
264 };
265 res.end = function (data, encoding, ...rest) {
266 Dingus.pushBufChunk(chunks, data, encoding);
267 ctx.responseBody = Buffer.concat(chunks);
268 handler(req, res, ctx);
269 return origEnd(data, encoding, ...rest);
270 };
271 }
272
273
274 /**
275 * Intercept writes for head requests, do not send to client,
276 * but send length, and make body available in context.
277 * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
278 * @param {http.ClientRequest} req request
279 * @param {http.ServerResponse} res response
280 * @param {object} ctx context
281 * @param {boolean} persistResponseBody store body buffer on context
282 */
283 static setHeadHandler(req, res, ctx, persistResponseBody = false) {
284 if (req.method === 'HEAD') {
285 const origEnd = res.end.bind(res);
286 const chunks = [];
287 res.write = function (chunk, encoding) {
288 Dingus.pushBufChunk(chunks, chunk, encoding);
289 // No call to original res.write.
290 };
291 res.end = function (data, encoding, ...rest) {
292 Dingus.pushBufChunk(chunks, data, encoding);
293 const responseBody = Buffer.concat(chunks);
294 res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(responseBody));
295 if (persistResponseBody) {
296 ctx.responseBody = responseBody;
297 }
298 return origEnd(undefined, encoding, ...rest);
299 };
300 }
301 }
302
303
304 /**
305 * Resolve the handler to invoke for a request.
306 * @param {http.ClientRequest} req request
307 * @param {http.ServerResponse} res response
308 * @param {object} ctx context
309 * @returns {object} handler and args
310 */
311 _determineHandler(req, res, ctx) {
312 const _scope = _fileScope('_determineHandler');
313
314 const { pathPart, queryParams } = this._splitUrl(req.url);
315 ctx.queryParams = queryParams;
316
317 let handler, handlerArgs = [];
318 try {
319 ({ handler, handlerArgs } = this.router.lookup(req.method, pathPart, ctx));
320 } catch (e) {
321 if (e instanceof URIError) {
322 handler = this.handlerBadRequest.bind(this);
323 } else if (e instanceof RouterNoPathError) {
324 handler = this.handlerNotFound.bind(this);
325 } else if (e instanceof RouterNoMethodError) {
326 if (this.intrinsicHeadMethod && req.method === 'HEAD') {
327 ({ handler, handlerArgs } = this._determineHeadHandler(req, res, ctx, pathPart));
328 } else {
329 handler = this.handlerMethodNotAllowed.bind(this);
330 }
331 } else {
332 this.logger.error(_scope, 'unexpected error', { error: e });
333 handler = this.handlerInternalServerError.bind(this);
334 }
335 }
336 return { handler, handlerArgs };
337 }
338
339
340 /**
341 * For intrinsic HEAD requests, resolve the handler to invoke.
342 * @param {http.ClientRequest} req request
343 * @param {http.ServerResponse} res response
344 * @param {object} ctx context
345 * @param {string} pathPart router path
346 * @returns {object} hander and handler args
347 */
348 _determineHeadHandler(req, res, ctx, pathPart) {
349 const _scope = _fileScope('_determineHeadHandler');
350 let handler, handlerArgs = [];
351 try {
352 ({ handler, handlerArgs } = this.router.lookup('GET', pathPart, ctx));
353 Dingus.setHeadHandler(req, res, ctx, this.intrinsicHeadPersistBody);
354 } catch (e) {
355 if (e instanceof RouterNoMethodError) {
356 handler = this.handlerMethodNotAllowed.bind(this);
357 } else {
358 this.logger.error(_scope, 'unexpected error', { error: e });
359 handler = this.handlerInternalServerError.bind(this);
360 }
361 }
362 return { handler, handlerArgs };
363 }
364
365
366 /**
367 * Dispatch the handler for a request
368 * @param {http.ClientRequest} req request
369 * @param {http.ServerResponse} res response
370 * @param {object} ctx context
371 * @returns {Promise<void>} handler promise
372 */
373 async dispatch(req, res, ctx = {}) {
374 const { handler, handlerArgs } = this._determineHandler(req, res, ctx);
375 try {
376 await this.preHandler(req, res, ctx);
377 return await handler(req, res, ctx, ...handlerArgs);
378 } catch (e) {
379 ctx.error = e;
380 this.sendErrorResponse(e, req, res, ctx);
381 }
382 }
383
384
385 /**
386 * Return normalized type, without any parameters.
387 * @param {http.ClientRequest} req request
388 * @returns {string} bare content type
389 */
390 static getRequestContentType(req) {
391 const contentType = req.getHeader(Enum.Header.ContentType);
392 return (contentType || '').split(';')[0].trim().toLowerCase();
393 }
394
395
396 /**
397 * Parse rawBody as contentType into ctx.parsedBody.
398 * @param {string} contentType type
399 * @param {object} ctx context
400 * @param {string|Buffer} rawBody body data
401 */
402 parseBody(contentType, ctx, rawBody) {
403 const _scope = _fileScope('parseBody');
404
405 switch (contentType) {
406 case Enum.ContentType.ApplicationForm:
407 ctx.parsedBody = this.querystring.parse(rawBody);
408 break;
409
410 case Enum.ContentType.ApplicationJson:
411 try {
412 ctx.parsedBody = JSON.parse(rawBody);
413 } catch (e) {
414 this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e });
415 throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message);
416 }
417 break;
418
419 default:
420 this.logger.debug(_scope, 'unhandled content-type', { requestId: ctx.requestId, contentType });
421 throw new ResponseError(Enum.ErrorResponse.UnsupportedMediaType);
422 }
423 }
424
425
426 /**
427 * Return all body data from a request.
428 * @param {http.ClientRequest} req request
429 * @param {number=} maximumBodySize bytes
430 * @param {boolean=} toString whether to convert body data buffer to string
431 * @returns {Promise<Buffer|string>} body data
432 */
433 async bodyData(req, maximumBodySize, toString = true) {
434 const _scope = _fileScope('bodyData');
435 return new Promise((resolve, reject) => {
436 const body = [];
437 let length = 0;
438 req.on('data', (chunk) => {
439 body.push(chunk);
440 length += Buffer.byteLength(chunk);
441 if (maximumBodySize && length > maximumBodySize) {
442 this.logger.debug(_scope, 'body data exceeded limit', { length, maximumBodySize });
443 reject(new ResponseError(Enum.ErrorResponse.RequestEntityTooLarge));
444 }
445 });
446 req.on('end', () => {
447 const bodyBuffer = Buffer.concat(body);
448 resolve(toString ? bodyBuffer.toString() : bodyBuffer);
449 });
450 req.on('error', (e) => {
451 this.logger.error(_scope, 'failed', { error: e });
452 reject(e);
453 });
454 });
455 }
456
457
458 /**
459 * Read and parse request body data.
460 * Sets ctx.parsedBody, and optionally ctx.rawBody.
461 * @param {http.ClientRequest} req request
462 * @param {http.ServerResponse} res response
463 * @param {object} ctx context
464 * @param {object} options options
465 * @param {boolean} options.parseEmptyBody attempt to parse an empty body, results may vary by encoding
466 * @param {boolean} options.persistRawBody store raw body buffer on context
467 * @param {number=} options.maximumBodySize bytes
468 */
469 async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) {
470 const rawBody = await this.bodyData(req, maximumBodySize);
471 if (persistRawBody) {
472 ctx.rawBody = rawBody;
473 }
474 if (rawBody || parseEmptyBody) {
475 const contentType = Dingus.getRequestContentType(req);
476 this.parseBody(contentType, ctx, rawBody);
477 }
478 }
479
480
481 /**
482 * Return the best matching response type.
483 * @param {string[]} responseTypes available content types
484 * @param {http.ClientRequest} req request
485 * @returns {string} preferred content type
486 */
487 static getResponseContentType(responseTypes, req) {
488 const acceptHeader = req.getHeader(Enum.Header.Accept);
489 return ContentNegotiation.accept(responseTypes, acceptHeader);
490 }
491
492
493 /**
494 * Returns a list of the most-preferred content encodings for the response.
495 * @param {string[]} responseEncodings available encodings
496 * @param {http.ClientRequest} req request
497 * @returns {string} preferred encoding
498 */
499 static getResponseEncoding(responseEncodings, req) {
500 const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
501 return ContentNegotiation.preferred(responseEncodings, acceptEncodingHeader);
502 }
503
504
505 /**
506 * Set the best content type for the response.
507 * Sets ctx.responseType, and Content-Type header.
508 * @param {string[]} responseTypes default first
509 * @param {http.ClientRequest} req request
510 * @param {http.ServerResponse} res response
511 * @param {object} ctx context
512 */
513 setResponseType(responseTypes, req, res, ctx) {
514 const _scope = _fileScope('setResponseType');
515 ctx.responseType = Dingus.getResponseContentType(responseTypes, req);
516 if (!ctx.responseType) {
517 if (this.strictAccept) {
518 this.logger.debug(_scope, 'unhandled strict accept', { requestId: req.requestId });
519 throw new ResponseError(Enum.ErrorResponse.NotAcceptable);
520 } else {
521 ctx.responseType = responseTypes[0];
522 }
523 }
524 res.setHeader(Enum.Header.ContentType, ctx.responseType);
525 }
526
527
528 /**
529 * Inserts an encoding into Content-Encoding header.
530 * @param {http.ServerResponse} res response
531 * @param {string} encoding encoding
532 */
533 static addEncodingHeader(res, encoding) {
534 const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
535 if (existingEncodings) {
536 encoding = `${encoding}, ${existingEncodings}`;
537 }
538 res.setHeader(Enum.Header.ContentEncoding, encoding);
539 }
540
541
542 /**
543 * Attempt to fetch both data and metadata for a file.
544 * @param {string} filePath path to file
545 * @returns {Promise<[object=, object=]>} array with file contents and stats
546 */
547 async _readFileInfo(filePath) {
548 const _scope = _fileScope('_readFileInfo');
549 let result;
550 try {
551 // eslint-disable-next-line security/detect-non-literal-fs-filename
552 const stat = fsPromises.stat(filePath);
553 // eslint-disable-next-line security/detect-non-literal-fs-filename
554 const data = fsPromises.readFile(filePath);
555 result = await Promise.all([stat, data]);
556 } catch (e) {
557 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e.code)) {
558 return [null, null];
559 }
560 this.logger.error(_scope, 'fs error', { error: e, filePath });
561 throw e;
562 }
563 return result;
564 }
565
566
567 /**
568 * Potentially add additional headers from static file meta-file.
569 * @param {http.ServerResponse} res response
570 * @param {string} directory path to file
571 * @param {string} fileName - already normalized and filtered
572 * @returns {boolean} if meta headers were added
573 */
574 async _serveFileMetaHeaders(res, directory, fileName) {
575 const _scope = _fileScope('_serveFileMetaHeaders');
576
577 let added = false;
578 const metaPrefix = '.';
579 const metaSuffix = '.meta';
580 const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`;
581 const metaFilePath = path.join(directory, metaFileName);
582
583 const [stat, data] = await this._readFileInfo(metaFilePath);
584 if (!stat) {
585 return added;
586 }
587
588 const lines = data.toString().split(lineBreakRE);
589 common.unfoldHeaderLines(lines);
590
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]; // eslint-disable-line security/detect-object-injection
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;