'use strict' /* * youch * * (c) Harminder Virk * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ const Mustache = require('mustache') const path = require('path') const stackTrace = require('stack-trace') const fs = require('fs') const cookie = require('cookie') const VIEW_PATH = '../resources/error.mustache' const startingSlashRegex = /\\|\// const viewTemplate = fs.readFileSync(path.join(__dirname, VIEW_PATH), 'utf-8') class Youch { constructor (error, request, readSource, baseURL, addCol) { this.error = error this.request = request this.readSource = typeof readSource === 'function' ? readSource : this._readSource this.baseURL = baseURL || '/' this.addCol = addCol === undefined ? true : Boolean(addCol) this.codeContext = 5 this._filterHeaders = ['cookie', 'connection'] this._filterFrames = [ /regenerator-runtime/, /babel-runtime/, /core-js\/library/ ] } /** * Reads the source code for a given frame into frame.contents * * @param {String} path * @return {Promise} */ _readSource (frame) { return new Promise((resolve, reject) => { if(!frame.fileName) { return resolve() } fs.readFile(frame.fileName, 'utf-8', (error, contents) => { if (!error && contents) { frame.contents = contents } resolve() }) }) } /** * Returns source code for a given frame. * * @param {Object} frame * @return {Promise} */ _getFrameSource (frame) { return this.readSource(frame).then(()=> { if (!frame.contents) { return } const lines = frame.contents.split(/\r?\n/) const lineNumber = frame.getLineNumber() return { pre: lines.slice(Math.max(0, lineNumber - (this.codeContext + 1)), lineNumber - 1), line: lines[lineNumber - 1], post: lines.slice(lineNumber, lineNumber + this.codeContext) } }) } /** * Parses the error stack and returns serialized * frames out of it. * * @return {Object} */ _parseError () { const stack = stackTrace.parse(this.error) return Promise.all(stack.map((frame) => { if (this._isNode(frame)) { return Promise.resolve(frame) } return this._getFrameSource(frame).then((context) => { frame.context = context return frame }) })) .then(stack => stack.filter(this._isVisible.bind(this))) .then(stack => { let hasInternal = false for (let frame of stack) { if (!this._isApp(frame) && !this._isNode(frame)) { hasInternal = true break } } return {stack, hasInternal} }) } /** * Returns the context with code for a given * frame. * * @param {Object} * @return {Object} */ _getContext (frame) { if (!frame.context) { return {} } return { start: frame.getLineNumber() - (frame.context.pre || []).length, pre: frame.context.pre.join('\n'), line: frame.context.line, post: frame.context.post.join('\n'), } } /** * Returns classes to be used inside HTML when * displaying the frames list. * * @param {Object} * @param {Number} * * @return {String} */ _getDisplayClasses (frame, index) { const classes = [] if (index === 0) { classes.push('active') } if (!this._isApp(frame)) { classes.push('native-frame') } return classes.join(' ') } /** * Compiles the view using HTML * * @param {String} * @param {Object} * * @return {String} */ _compileView (view, data) { return Mustache.render(view, data) } /** * Serializes frame to a usable error object. * * @param {Object} * * @return {Object} */ _serializeFrame (frame) { const relativeFileName = frame.getFileName().indexOf(process.cwd()) > -1 ? frame.getFileName().replace(process.cwd(), '').replace(startingSlashRegex, '') : frame.getFileName() return { file: relativeFileName, method: frame.getFunctionName(), line: frame.getLineNumber(), column: frame.getColumnNumber(), context: this._getContext(frame), lang: this._getLang(frame), open: this._openURL(frame) } } _openURL(frame) { if (!frame.fullPath) { return } return this.baseURL + '__open-in-editor' + '?file=' + encodeURI(frame.fullPath || frame.fileName) + ':' + (frame.getLineNumber() || 0) + (this.addCol ? (':' + (frame.getColumnNumber() || 0)) : '') } /** * Returns whether frame belongs to nodejs * or not. * * @return {Boolean} [description] */ _isNode (frame) { if (frame.isNative()) { return true } // const filename = frame.getFileName() || '' // return !path.isAbsolute(filename) && filename[0] !== '.' return false } /** * Returns whether code belongs to the app * or not. * * @return {Boolean} [description] */ _isApp (frame) { if (this._isNode(frame)) { return false } return !~(frame.getFileName() || '').indexOf('node_modules' + path.sep) } /** * Returns whether frame should be visible * or not. * * @return {Boolean} [description] */ _isVisible (frame) { return this._filterFrames.every(f => !f.test(frame.getFileName())) } _getLang(frame) { let name = frame.getFileName() || '' let lang = 'js' if(name.indexOf('.vue') !== -1) { lang = 'html' } return lang } /** * Serializes stack to Mustache friendly object to * be used within the view. Optionally can pass * a callback to customize the frames output. * * @param {Object} * @param {Function} [callback] * * @return {Object} */ _serializeData (stack, callback) { callback = callback || this._serializeFrame.bind(this) return { message: this.error.message, name: this.error.name, status: this.error.status, frames: stack instanceof Array === true ? stack.filter((frame) => frame.getFileName()).map(callback) : [] } } /** * Returns a serialized object with important * information. * * @return {Object} */ _serializeRequest () { const headers = [] Object.keys(this.request.headers).forEach((key) => { if (this._filterHeaders.indexOf(key) > -1) { return } headers.push({ key: key.toUpperCase(), value: this.request.headers[key] }) }) const parsedCookies = cookie.parse(this.request.headers.cookie || '') const cookies = Object.keys(parsedCookies).map((key) => { return {key, value: parsedCookies[key]} }) return { url: this.request.url, httpVersion: this.request.httpVersion, method: this.request.method, connection: this.request.headers.connection, headers: headers, cookies: cookies } } /** * Returns error stack as JSON. * * @return {Promise} */ toJSON () { return new Promise((resolve, reject) => { this ._parseError() .then(({ stack, hasInternal }) => { resolve({ error: this._serializeData(stack), hasInternal }) }) .catch(reject) }) } /** * Returns HTML representation of the error stack * by parsing the stack into frames and getting * important info out of it. * * @return {Promise} */ toHTML () { return new Promise((resolve, reject) => { this ._parseError() .then(({ stack, hasInternal }) => { const data = this._serializeData(stack, (frame, index) => { const serializedFrame = this._serializeFrame(frame) serializedFrame.classes = this._getDisplayClasses(frame, index) return serializedFrame }) const request = this._serializeRequest() data.request = request data.hasInternal = hasInternal resolve(this._compileView(viewTemplate, data)) }) .catch(reject) }) } } module.exports = Youch