UNPKG

stacklyn

Version:

Turn JavaScript stack traces into structured data.

1,257 lines (1,043 loc) 53.2 kB
/* Copyright 2025 DocToon. Licensed under Apache 2.0, * you may not use this file except in compliance with the License. * You may obtain a copy at: https://apache.org/licenses/LICENSE-2.0 ================================================================== */ /* ------------------------------------------ *\ WARNING: code quality is not the best, this is a v1 tho and i wrote it by myself \* ------------------------------------------ */ "use strict"; class Stacklyn { // ====== PUBLIC API - PARSING ====== \\ /** Parses an error into an array of structured stack frames * @param {object} error The error (must pass _isValidError) * @param {object} opts Optional options object (see docs for more info) * @returns {object[]} Array of parsed stack frames (see docs for more info) */ static parse(error, opts = { ALLOW_CALLSITES: false, FULL_ERROR: false }) { if (!Stacklyn._isValidError(error)) return; let frames, out; error.toString = Error.prototype.toString; // filter out v8-style stacktrace headers function removeV8Header(error, serialize = false) { if (serialize || !error.stack) { return { name: error.name, message: error.message, header: error.toString() }; } return error.stack?.replace(error.toString()+"\n", ""); } // some flags to make sure it parses correctly const isV8Env = (typeof window !== 'undefined' && !!window.chrome) || typeof process !== "undefined"; const canGetCallSites = !!(isV8Env && opts.ALLOW_CALLSITES === true && error instanceof Error); if (canGetCallSites) { out = Stacklyn.parseCS(error); } else { frames = error.stack?.split("\n").filter(Boolean) || []; if (frames.includes(error.toString())) { frames = removeV8Header(error).split("\n").filter(Boolean); } const isV8 = frames?.some(frame => frame.startsWith(" at ")); const isIE = frames?.some(frame => frame.startsWith(" at ")); const isEspruino = frames?.some(frame => frame.includes(" ^")); const isFirefox = frames?.some(frame => /^.*?@.+?(:\d+)?(?::\d+)?$/.test(frame)); if (Stacklyn._detectOperaMode(error) !== "failed") { // one of the most ancient browsers out = Stacklyn.parseOpera(error).filter(Boolean); } else if (isEspruino) { // yes... JS for microcontrollers out = Stacklyn.parseEspruino(error).filter(Boolean); } else if (isV8) { // oh my god its chrome, node, and... bun? frames = removeV8Header(error, false).split("\n").filter(Boolean); out = frames.map(frame => Stacklyn.parseV8(frame, null)).filter(Boolean); } else if (isIE) { // IE/legacy edge parser frames = removeV8Header(error, false).split("\n").filter(Boolean); out = frames.map(frame => Stacklyn.parseIE(frame)).filter(Boolean); } else if (isFirefox) { // safari or firefox out = frames.map(frame => Stacklyn.parseSpiderMonkey(frame)).filter(Boolean); } else { throw new Stacklyn.Error("unsupported stacktrace format:", error, { cause: error }); } } out = Stacklyn._filterUndefined(out); if (opts.FULL_ERROR) { out = { ...Stacklyn.serializeError(error), parsedStack: Array.from(out) }; } return out; } /** Parses a locally thrown V8 error with CallSite info * @param {Error} error The V8 error object (has to be an error, not an ordinary object) * @param {{ FULL_ERROR: boolean; }} [opts={ FULL_ERROR: false }] Optional options object * @returns {object[]} An array of parsed V8 frames with CallSite info */ static parseCS(error, opts = { FULL_ERROR: false }) { if (!error instanceof Error) return; let frameIndex = 0; const { stack, callSites } = Stacklyn.getCallSites(error); let out = Stacklyn._filterUndefined( stack.split("\n").slice(1).map(frame => Stacklyn.parseV8(frame, callSites[frameIndex++])) ); if (opts.FULL_ERROR) { out = { ...Stacklyn.serializeError(error), parsedStack: Array.from(out), callSites }; } return out; } /** Parse a V8 (chromium, chrome, nodejs...) stack frame * @param {string} frame The stack frame string * @param {Array} callSite The callsite (this should never be added manually) * @returns {object} Stack frame object */ static parseV8(frame, callSite = undefined) { if (!frame.startsWith(" at")) return; let parsedLocation, callerFunc; const env = { host: "Chromium", format: "V8", type: "browser" }; let rawName, alias, sourceURL; const V8_PARENS_REGEXP = /^ {4}at (.+?)(?: \[as ([^\]]+)\])?\s*\((.+)\)\s*$/; const V8_REGEXP = /^ {4}at (.+)$/; if (frame.includes("(") && frame.includes(")")) { const match = frame.match(V8_PARENS_REGEXP); if (match) { [, rawName, alias, sourceURL ] = match; } } else { const match = frame.match(V8_REGEXP); if (match) { [, sourceURL ] = match; } } function parseEvalChain(path) { let result = null, trailingLoc = null, index = 0; const match = path.match(/, ([^,]+:\d+:\d+)$/); if (match) { trailingLoc = match[1]; path = path.slice(0, path.lastIndexOf(", ")); } const parts = []; while (path.startsWith("eval at ", index)) { index += 8; const nameEnd = path.indexOf(" (", index), name = path.slice(index, nameEnd); let depth = 1, subIndex = nameEnd + 2; index = subIndex; while (subIndex < path.length && depth > 0) { if (path[subIndex] === "(") { depth++; } else if (path[subIndex] === ")") { depth--; } subIndex++; } const inner = path.slice(index, subIndex - 1); parts.push({ name, location: null, inner }); path = inner; index = 0; } if (parts.length && path.match(/:\d+:\d+$/)) { parts[parts.length - 1].location = path; } if (trailingLoc && parts.length) { parts[0].location = trailingLoc; } for (let i = parts.length - 1; i >= 0; i--) { let sourceURL = null, line = null, column = null; const { name, location } = parts[i], match = location?.match(/^(.*):(\d+):(\d+)$/); if (match) { [, sourceURL, line, column] = match; } else if (location) { sourceURL = location; } result = { name, sourceURL: sourceURL || null, fileName: sourceURL?.includes("://") ? Stacklyn._getFilename(sourceURL) : null, line: line ? Number(line) : null, column: column ? Number(column) : null, eval: result }; } return result; } const isAnonLoc = ["native", "unknown location", "<anonymous>"] .includes(sourceURL); const isAnonWithLine = ["native:", "<anonymous>:"] .some(source => sourceURL.startsWith(source)); if (isAnonLoc || !sourceURL) { parsedLocation = { sourceURL, anonymous: true }; } else if (isAnonWithLine) { // sometimes they could have line info if (sourceURL.startsWith("native:")) { env.host = "Bun"; env.type = "runtime"; } const { line, column } = Stacklyn._extractLocation(sourceURL); parsedLocation = { sourceURL: null, fileName: null, line, column, anonymous: true }; } else if (sourceURL === "unknown") { // bun env.host = "Bun"; env.type = "runtime"; parsedLocation = { sourceURL, anonymous: true }; } else if (sourceURL.startsWith("eval at")) { parsedLocation = parseEvalChain(sourceURL); } else if (sourceURL){ if (sourceURL.startsWith("node:")) { env.host = "Node.js"; env.type = "runtime"; } const { line, column } = Stacklyn._extractLocation(sourceURL); const cleanPath = Stacklyn._cleanPath(sourceURL); parsedLocation = { sourceURL: cleanPath, fileName: Stacklyn._getFilename(cleanPath), line, column, anonymous: !cleanPath }; } if (rawName?.includes("<anonymous>")) { if (rawName.endsWith(".<anonymous>")) { // Object.<anonymous> is often seen callerFunc = { name: rawName.replace(".<anonymous>", ""), method: null, anonymous: true }; } else { // name is probably just "<anonymous>" callerFunc = { name: null, anonymous: true }; } } else if (rawName) { const isSafariLoc = ["global code", "module code", "eval code"] .includes(rawName); if (isSafariLoc) { env.host = "Bun"; env.type = "runtime"; } callerFunc = Stacklyn.parseFunctionName(rawName, {alias, rawName}); } else if (!rawName) { callerFunc = { name: null, anonymous: true }; } return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: parsedLocation }, extra: { callSite: callSite ?? undefined, environment: env } }); } /** Parse a SpiderMonkey (firefox, safari, netscape...) stack frame * (im not sure if netscape actually called it spidermonkey but whatever) * @param {string} frame The stack frame string * @returns {object} Stack frame object */ static parseSpiderMonkey(frame) { if (!frame || frame.length < 2) return; let parsedLocation, evalChain, callerFunc, env = { host: "Firefox", format: "SpiderMonkey", type: "browser" }; // inferred function name, we split like this because people have @s in filenames. const at = frame.lastIndexOf("@"); const [rawName, path] = at !== -1 ? [frame.slice(0, at), frame.slice(at + 1)] : [null, ""]; function parseEvalChain(path) { // get location parts let result = null; const parts = path.split(" > "); // for each part, parse a node/chain of eval calls from the stack trace for (let i = parts.length - 1; i >= 0; i--) { // really cool regex const match = parts[i].match(/^(.*) line (\d+)$/) || parts[i].match(/^(.*):(\d+):(\d+)$/); if (!match) { continue; } const chain = { sourceURL: match[1].includes("://") ? match[1] : null, fileName: match[1].includes("://") ? Stacklyn._getFilename(match[1]) : null, line: Number(match[2]), column: match[3] ? Number(match[3]) : null, type: match[1].includes("://") ? "file" : match[1], eval: result }; result = chain; } return result; } const { line, column } = Stacklyn._extractLocation(path); const isWeirdSafariLocation = ["[native code]", "[wasm code]"] .some(p => path.includes(p)); const isSafariFunc = ["global code", "module code", "eval code"] .some(name => rawName.includes(name)); if (path.includes(" line ") && path.includes(" > ")) { evalChain = parseEvalChain(path); } else if (path.startsWith("javascript:")) { const inlineSource = Stacklyn._cleanPath(path.replace("javascript:", ""), "partial"); parsedLocation = { inlineSource, line, column, type: "JSUrl" }; } else if (isWeirdSafariLocation || !path.match(/:(\d+)(?::(\d+))?$/)) { // believe it or not, // safari's stack frames are so similar that this is the only way to detect them env = { host: "Safari", format: "JavaScriptCore", type: "browser" }; parsedLocation = { sourceURL: path, line, column, type: path.includes("[native code]") ? "native" : "wasm" }; } else { const cleanPath = Stacklyn._cleanPath(path); // file location if it took a shower parsedLocation = { sourceURL: cleanPath, fileName: Stacklyn._getFilename(cleanPath), line, column }; } if (rawName) { // inferred function name by the engine if (rawName.includes("/")) { // nested functions, methods, or properties inside an object(s) const names = rawName.split("/"), parsedNames = []; names.forEach(name => { let result; if (name.includes("<")) { // timeout/<@{LOCATION HERE}:LINE:COL result = { name, index: name.match(/\[(\d+)\]</) || undefined, flags: ["NESTED_ANON"] }; } else { result = Stacklyn.parseFunctionName(name, {rawName}); } parsedNames.push(result); }); const out = { ...parsedNames[0] }; let current = out; for (let i = 1; i < parsedNames.length; i++) { current.func = { ...parsedNames[i] }; current = current.func; } callerFunc = out; } else if (rawName.includes("*")) { /* setTimeout handler*timeout@{LOCATION HERE}:LINE:COL promise callback*promiseThen@{LOCATION HERE}:LINE:COL */ const [type, name] = rawName.split("*"); const result = Stacklyn._parseFunctionname(name, {rawName}); if (type.includes("setTimeout handler")) { result.flags.push("TIMEOUT_HANDLER"); } else if (type.includes("promise callback")) { result.flags.push("PROMISE_CALLBACK"); } result.flags.push("ASYNC"); callerFunc = result; } else if (isSafariFunc) { env = { host: "Safari", format: "JavaScriptCore", type: "browser" }; const type = rawName.split(" ")[0]; callerFunc = { name: null, anonymous: true, flags: [type.toUpperCase()] }; } else { callerFunc = Stacklyn._parseFunctionname(rawName, {rawName}); } } else { // no name callerFunc = { name: null, anonymous: true }; } // new Function(...)(); calls always rename the function to "anonymous". // so this aims to properly flag them if (rawName === "anonymous" && parsedLocation?.eval.type === "Function") { callerFunc.anonymous = true; } return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: evalChain ? evalChain : parsedLocation }, extra: { environment: env } }); } /** Parse an IE / Edge (Legacy) stack frame * @param {string} frame The stack frame string * @returns {object} Stack frame object */ static parseIE(frame) { let callerFunc, parsedLocation; const locPlaceholders = ["eval code", "Function code", "Unknown script code"]; const match = frame.match(/^ {3}at\s+(.*?)\s+\((.*?)\)$/); if (!match) return; const [, rawName, sourceURL] = match; if (["Global code", "Anonymous function"].some(name => rawName.includes(name))) { callerFunc = { name: null, anonymous: true, flags: [(rawName.split(" ")[0]).toUpperCase()] }; } else { callerFunc = Stacklyn.parseFunctionName(rawName, {rawName}); } if (sourceURL === "native code") { parsedLocation = { sourceURL: null, fileName: null, anonymous: true, type: "native" }; } else if (locPlaceholders.some(path => sourceURL.includes(path))) { let type = sourceURL.split(" code")[0].toLowerCase(); if (type === "unknown script") { type === "unknown"; } const { line, column } = Stacklyn._extractLocation(sourceURL); parsedLocation = { sourceURL: null, fileName: null, line, column, anonymous: true, type }; } else { parsedLocation = Stacklyn._extractLocation(sourceURL); } return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: parsedLocation }, extra: { environment: { host: "Internet Explorer", format: "IE", type: "browser" } } }); } /** Parse an Opera error and return structured data * @param {object} error The error (must pass _isValidError) * @returns {object} Stack frame object */ static parseOpera(error) { if (!Stacklyn._isValidError(error)) return; const out = [], mode = Stacklyn._detectOperaMode(error), pairs = Stacklyn._getOperaPairs(Stacklyn._getOperaStack(error)); pairs.forEach(pair => { if (mode === "carakan") { out.push(Stacklyn.parseCarakan(pair.frame, pair.context)); } else if (mode === "linear-b") { out.push(Stacklyn.parseLinearB(pair.frame, pair.context)); } else { throw new Stacklyn.Error("Invalid Opera error provided for parsing"); } }); return out; } /** Parse an Opera Carakan stack frame * @param {string} frame The stack frame string * @param {string} context The specific line where the error occurred * @returns {object} Stack frame object */ static parseCarakan(frame, context) { let frameType = "thrown", callerFunc, rawName, otherSource; const prefixMap = { "Error thrown at ": "thrown", "Error created at ": "constructed", "Error initially occurred at ": "rethrown", "called via ToPrimitive() from ": "toPrimitive", "called via Function.prototype.apply() from ": "functionPrototypeApply", "called via Function.prototype.call() from ": "functionPrototypeCall", "called as bound function from ": "functionPrototypeBind", "called from ": "functionCall" }; Object.keys(prefixMap).some(prefix => frame.startsWith(prefix) && (frameType = prefixMap[prefix], true)); let [, rawNameTemp, sourceURL] = frame.match(/in (.+?) in (.+)/) || [null, null]; const [, line, column] = frame.match(/line (\d+), column (\d+)/) || [null]; if (!rawNameTemp) { [, otherSource, sourceURL] = frame.match(/[at|from] (.*?) in (.*)/); } if (rawNameTemp) { if (rawNameTemp.includes("<anonymous function:")) { rawName = rawNameTemp.replace("<anonymous function: ", "").replace(">", ""); } else if (rawNameTemp.includes("<anonymous function>")) { rawName = "!"; } callerFunc = rawName !== "!" ? { ...Stacklyn.parseFunctionName(rawName ?? rawNameTemp, {rawName: rawNameTemp}), anonymous: rawNameTemp.startsWith("<a") } : { name: null, anonymous: true }; } if (sourceURL.endsWith(":")) { sourceURL = sourceURL.slice(0,-1); } const parsedLocation = { context, sourceURL, fileName: sourceURL?.includes(" ") ? undefined : Stacklyn._getFilename(sourceURL), line: +line, column: +column, anonymous: !sourceURL || otherSource === "unknown location" || sourceURL.includes(" ") }; return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: parsedLocation }, extra: { type: frameType, environment: { host: "Opera", format: "carakan", type: "browser" } } }); } /** Parse an Opera Linear-b stack frame * @param {string} frame The stack frame string * @param {string} context The specific line where the error occurred * @returns {object} Stack frame object */ static parseLinearB(frame, context) { // I am sincerely sorry for using regex here // - doctoon const LINEARB_REGEXP = /^Line (\d+) of ([a-z]+)(?:#(\d+))? script(?: in (.*?))?(?:: In function (.*))?$/; const [, line, type, index, sourceURL, rawName] = frame.match(LINEARB_REGEXP); const parsedLocation = { context, sourceURL, fileName: Stacklyn._getFilename(sourceURL), line: +line, anonymous: !sourceURL, script: { type: type || "unknown", index: +index } }; const callerFunc = rawName ? Stacklyn.parseFunctionName(rawName, {rawName}) : { name: null, anonymous: !rawName }; return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: parsedLocation }, extra: { environment: { host: "Opera", format: "linear-b", type: "browser" } } }); } /** Parse an Espruino error and return structured data * @param {object} error The error (must pass _isValidError) * @returns {object} Stack frame object */ static parseEspruino(error) { if (!Stacklyn._isValidError(error)) return; function parseEspruinoPair(frame, context, caret) { let parsedLocation, callerFunc, rawName, sourceURL; const ESPRUINO_PARENS_REGEXP = /^ {4}at (.*?) \((.+)\)$/; const ESPRUINO_REGEXP = /^ {4}at (.+)$/; if (frame.includes("(") && frame.includes(")")) { const match = frame.match(ESPRUINO_PARENS_REGEXP); if (match) { [, rawName, sourceURL] = match; } } else { const match = frame.match(ESPRUINO_REGEXP); if (match) { [, sourceURL] = match; } } if (sourceURL) { const { line, column } = Stacklyn._extractLocation(sourceURL); const cleanPath = Stacklyn._cleanPath(sourceURL); parsedLocation = { sourceURL: cleanPath, fileName: Stacklyn._getFilename(cleanPath), line, column, anonymous: !cleanPath }; } if (rawName) { callerFunc = Stacklyn.parseFunctionName(rawName, { rawName }); } else { callerFunc = { name: null, anonymous: true }; } const l = parsedLocation; if (!l.fileName && l.line && l.column && callerFunc.name === "REPL") { callerFunc.flags.push("REPL"); } return Stacklyn._buildOutputObject({ frameInfo: { raw: frame, func: callerFunc, location: { context, caret, ...parsedLocation } }, extra: { environment: { host: "Microcontroller Unit", format: "Espruino", type: "interpreter" } } }); } return Stacklyn._getEspruinoPairs(error.stack).map(pair => parseEspruinoPair(pair.frame, pair.context, pair.caret) ); } /** Parse a function name (meant for internal use but helpful) * @param {string} name Function name * @param {object} options * @param {undefined} [options.alias=undefined] * @param {undefined} [options.rawName=undefined] * @returns {object} Function metadata */ static parseFunctionName(name, { alias = undefined, rawName = undefined }) { if (!name || !rawName) return; let parsed, args; // because bracket access is just unneccesary noise if (/\[.*?\]/.test(name)) { name = name.replace(/\[(.*?)\]/g, (_, inner) => "." + inner); } const match = name.match(/^(.+?)\((.*)\)$/); if (match) { try { args = new Function(`return [${match[2]}]`)(); } catch (_) { args = match[2].split(", "); } } // name cleaned of args const cleanName = match ? match[1] : name; if (cleanName.startsWith("./")) { // can someone PLEASE tell me where this comes from? parsed = { name: null, rawName, anonymous: true }; } else if (cleanName.includes(".")) { // direct assignment (e.g. obj.a = b)) const parts = cleanName.split("."), method = parts.pop(), name = parts.join("."); parsed = { name, method, rawName, alias, flags: ["DIRECT"], args, anonymous: !name }; } else if (["get ", "set ", "new ", "async "].some(prefix => cleanName.startsWith(prefix))) { // function with a prefix (e.g. "get getter") let [prefix, tempName] = cleanName.split(" "), method; if (tempName.includes(".")) { const parts = tempName.split("."); method = parts.pop(); tempName = parts.join("."); } const prefixMap = { get: "GETTER", set: "SETTER", new: "CONSTRUCTOR", async: "ASYNC" }; const prefixFlag = prefixMap[prefix] ?? prefix.toUpperCase(); parsed = { name: tempName, method, rawName, alias, prefix, flags: ["PREFIX", prefixFlag], args, anonymous: !tempName }; } else { // probably just a regular function/class/etc parsed = { name: cleanName, rawName, alias, flags: [], args, anonymous: !cleanName }; } if (parsed.name === rawName) { parsed.rawName = undefined; } if (args !== undefined) { parsed.flags.push("ARGS"); } if (cleanName === "eval") { parsed.flags.push("EVAL"); } return parsed; } // ====== END OF PARSING ====== \\ // ====== USEFUL UTILITIES ====== \\ /** Convert between stacktrace formats * @param {object[]} frames * @param {string} target * @returns {string} A string representing a stacktrace of the target format */ static convert(frames, target) { const out = []; const engineMap = { Carakan: ["Carakan"], Chakra: ["Edge (Legacy)", "Internet Explorer", "IE"], Espruino: ["Espruino"], LinearB: ["LinearB", "linear-b", "Linear B"], SpiderMonkey: ["Firefox", "Netscape", "Tor", "SpiderMonkey", "Mocha"], V8: ["Brave", "Chrome", "Chromium", "Edge", "Opera", "Opera GX", "Vivaldi", "Node.js", "V8"] }; const targetMap = { Carakan: { host: "Opera", format: "carakan", type: "browser" }, Chakra: { host: "Internet Explorer", format: "IE", type: "browser" }, Espruino: { host: "MCU Unit", format: "Espruino", type: "interpreter" }, LinearB: { host: "Opera", format: "linear-b", type: "browser" }, SpiderMonkey: { host: "Firefox", format: "SpiderMonkey", type: "browser" }, V8: { host: "Chromium", format: "V8", type: "browser" } }; const engine = Object.entries(engineMap).find(([, aliases]) => aliases.includes(target) )?.[0]; if (!engine || !targetMap[engine]) { throw new Stacklyn.Error(`invalid .convert() target: '${target}'`); } frames.forEach(frame => { frame.environment = targetMap[engine]; out.push(Stacklyn.stringify(frame)); }); return out.filter(Boolean).join("\n"); } /** Turn a parsed frame object into a stack string * @param {object|object[]} frame A frame object (or an object[]) from the parse() method * @returns {string|string[]} A string (or a string[]) representing a stacktrace of the original format */ static stringify(frame) { // safeguard for people who input the parsed output directly // (you WILL need to append the header yourself) if (Array.isArray(frame)) { // ah yes, frame.map(frame). return frame.map(f => Stacklyn.stringify(f)); } let out = ""; const args = frame.func.args ? `(${frame.func.args.join(", ")})` : ""; const functionName = (frame.func.name || frame.func.rawName || "") + (frame.func.method ? "."+frame.func.method : ""); function getLineCol(loc) { return loc.line ? (":" + loc.line) + (loc.column ? (":" + loc.column) : "") : ""; } const lineCol = getLineCol(frame.location); if (frame.environment.format === "V8") { // hello chromium my old friend // BUG: mitigation to eval traces convert improperly (issue #1) if (frame.location?.eval?.type) { return ""; } const location = frame.location.eval ? formatEvalOrigin(frame.location) : (frame.location.sourceURL + lineCol); const alias = frame.func.alias ? ` [as ${frame.func.alias}]` : ""; function formatEvalOrigin(location) { function recurse(evaloc) { // eval location but it sounds like some cool villain this way if (!evaloc.eval) { return `eval at ${evaloc.name} (${evaloc.sourceURL}${getLineCol(evaloc)})`; } return `eval at ${evaloc.name} (${recurse(evaloc.eval)})`; } return `${recurse(location)}, ${location.sourceURL}${getLineCol(location)}`; } out = " at " + (functionName ? `${functionName}${args}${alias} (${location})` : location); } else if (frame.environment.format === "SpiderMonkey") { // THE LIZARD HAS BEEN PARSED, STRINGIFIED, AND ACKNOWLEDGED! // BUG: eval traces convert improperly (issue #1) if (frame.location.eval && frame.location.sourceURL.includes("<anonymous>")) { return ""; } function formatEvalOrigin(location) { function recurse(evaloc) { // evaloc is back and he wants revenge if (!evaloc.eval) { return `${evaloc.sourceURL ?? evaloc.type}${getLineCol(evaloc)}`; } return `${evaloc.sourceURL ?? evaloc.type ?? "eval"} line ${evaloc.line} > ${recurse(evaloc.eval)}`; } return recurse(location); } const firstPart = (frame.func.name === "eval" ? "" : functionName) + args; let sourceURL; if (frame.location.type === "JSUrl") { sourceURL = "javascript:" + frame.location.inlineSource + lineCol; } else if (frame.location.eval) { sourceURL = formatEvalOrigin(frame.location); } else { sourceURL = (frame.location.sourceURL || "debugger eval code") + lineCol; } out += firstPart + "@" + sourceURL; } else if (frame.environment.format === "carakan") { // they really named an engine after ancient javascript const prefixMap = { thrown: "Error thrown at", constructed: "Error created at", rethrown: "Error initially occurred at", toPrimitive: "called via ToPrimitive() from", functionPrototypeApply: "called via Function.prototype.apply() from", functionPrototypeCall: "called via Function.prototype.call() from", functionPrototypeBind: "called as bound function from", functionCall: "called from" }; const displayName = frame.func.name ? `<anonymous function: ${frame.func.name}>` : "<anonymous function>"; const funcDisplay = (frame.func.anonymous ? displayName : functionName) + args; out = prefixMap[frame.type]; if (frame.location.anonymous && !frame.location.line) { out += " unknown location"; } if (frame.location.line) { out += ` line ${frame.location.line}, column ${frame.location.column}`; } out += ` in ${funcDisplay} in ${frame.location.sourceURL}:\n ${frame.location.context}`; } else if (frame.environment.format === "linear-b") { // more ancient scripture let indexStr; if (frame.location.script.type === "inline") { indexStr = "#" + frame.location.script.index; } const isEval = ["unknown", "function", "eval"].includes(frame.location.script.type); out = ` Line ${frame.location.line} of ${frame.location.script.type}${indexStr} script`; if (!isEval && !frame.location.anonymous) { out += ` in ${frame.location.sourceURL}`; } if (functionName) { out += `: In function ${functionName}${args}`; } out += `\n ${frame.location.context || "/* no source available */"}`; } else if (frame.environment.format === "IE") { // me when i copy V8's homework but change it a little so it's not that obvious let loc = typeMap[frame.location.type]; const typeMap = { eval: "eval code", function: "Function code", unknown: "Unknown script code" }; if (!frame.location.type) { loc = frame.location.sourceURL + lineCol; } out = ` at ${frame.func.rawName} (${loc})`; } else if (frame.environment.format === "Espruino") { // do i really have to do this const lineCol = `:${frame.location.line}:${frame.location.column}`; const sourceStr = `${frame.location.fileName || ""}${lineCol}`; out = ` at ${frame.func.name ? `${functionName} (${sourceStr})` : sourceStr}`; if (frame.location.context) { out += "\n "+frame.location.context; if (frame.location.caret) { // ooh official caret out += "\n"+frame.location.caret; } else { // make it up ourselves out += `\n${" ".repeat(frame.location.context.length+1)}^`; } } } return out; } /** Get an error object in an accessible form * @param {object} error The error to get non enumerable properties of * @returns {object} The error with the properties attached */ static serializeError(error) { // retain the prototype chain const prototype = error instanceof Error ? Object.getPrototypeOf(error) : Error.prototype; const out = Object.create(prototype); // explanation for each one is in the docs const props = [ "name", "message", "stack", "cause", "errors", "error", "suppressed", "toString", "code", "errno", "syscall", "address", "port", "path", "dest", "spawnargs", "fileName", "lineNumber", "columnNumber", "sourceURL", "line", "column", "number", "description", "arguments", "stacktrace", "opera#sourceloc" ]; props.forEach(propName => { const prop = error[propName]; if (prop || prop === false || prop === null) { out[propName] = typeof prop === "function" ? prop.call(error) : prop; } }); return out; } /** Get V8 call site info from an error * (call this before doing anything else with the error) * @param {Error} error The error to get callsites of * @returns {object[]} The callsite object with all properties directly accessible */ static getCallSites(error) { const originalPrepare = Error.prepareStackTrace; // store the original formatter try { Error.prepareStackTrace = (_, stack) => stack; // bye bye v8 formatter! (for a few milliseconds) const callSites = error.stack; // yay an object instead of boring strings if (!Array.isArray(callSites)) { return null; } function exclude(cs) { // may add manual exclusions later when it becomes possible return cs.getPosition?.() !== 0; } // format the 'this' function formatThis(that) { const strThis = that.toString(); const name = strThis.replace(/\[object (\w+)]/, "$1"); return name.toLowerCase(); } // build the output const out = callSites.filter(exclude).map(cs => ({ scope: formatThis(cs.getThis?.()), func: { name: cs.getFunctionName?.(), typeName: cs.getTypeName?.(), sourceCode: cs.getFunction?.()?.toString?.() || "", reference: cs.getFunction?.(), flags: { native: cs.isNative?.(), constructor: cs.isConstructor?.(), async: cs.isAsync?.(), topLevel: cs.isToplevel?.(), }, eval: { origin: cs.getEvalOrigin?.(), isEval: cs.isEval?.() }, promise: { all: cs.isPromiseAll?.(), index: cs.getPromiseIndex?.() } }, location: { sourceURL: cs.getScriptNameOrSourceURL?.(), scriptHash: cs.getScriptHash?.(), line: cs.getLineNumber?.(), column: cs.getColumnNumber?.(), position: cs.getPosition?.(), enclosingLine: cs.getEnclosingLineNumber?.(), enclosingColumn: cs.getEnclosingColumnNumber?.() } })); // pretty much what v8 actually does const stack = `${error.toString()}\n${ callSites.map(site => " at " + site.toString()).join("\n") }`; error.stack = stack; return { callSites: out, stack }; } catch (_) { return null; } finally { Error.prepareStackTrace = originalPrepare; } } // ====== ASYNC (use with caution) ======= \\ /** Apply sourcemaps to parsed frames * @param {object[]} frames Array of parsed frame objects from the .parse method. * @returns {Promise<object[]>} An object with available data retrieved from the source map. * @example const res = await Stacklyn.map(parsedFrames), frames = []; res.forEach(frame => frames.push(frame.raw)); console.log("Error: example toString header\n", frames.join("\n")); */ static async map(frames) { const out = []; for (let frame of frames) { let map; const { sourceURL = null, fileName = null, line = null, column = null } = frame.location; const mapURL = sourceURL + ".map"; try { map = await (await fetch(mapURL)).json(); } catch (error) { throw new Stacklyn.Error(`Could not fetch source map ${mapURL} (${error.toString()})\n Stacktrace:`); } const location = new Stacklyn._SourceMapper(map).originalPositionFor({ line, column }); // sadly this is the most amount of info we could map back frame.func.name = location.name || frame.func.name; frame.func.anonymous = !location.name; frame.location.sourceURL = location.source || sourceURL; frame.location.fileName = Stacklyn._getFilename(location.source) || fileName; frame.location.line = location.line || line; frame.location.column = location.column || column; frame.location.anonymous = !location.source || !sourceURL; frame.raw = Stacklyn.stringify(frame); frame = {...frame, sourcemapped: true}; out.push(frame); } return out; } /** * @param {object} frames Array of parsed frame objects from the .parse method. * @param {number} context Amount of lines above and below the actual line (default is 5, can be 0 if you only want one line) * @returns {Promise<object[]>} An array of frames with context info (cannot be a minified file due to limitations of source mapping atm!) * In more detail, the frame becomes: * { contextabove: string[], context: string, contextbelow: string[], ...frame } * @example const parsed = Stacklyn.parse(myError); const withContextInfo = await Stacklyn.enrich(parsed); */ static async enrich(frames, context = 5) { return (await Promise.all( frames.map(frame => Stacklyn._prependContext(frame, context)) )).filter(Boolean); } // ====== INTERNAL API ======= \\ // WARNING: since this is not meant for use by regular users, // the code here will often not be readable or not useful. // == PARSING HELPERS static _getOperaStack(error) { const Backtrace = error.message?.split(/\n(?:\s+)?Backtrace:(?:\s+)?(?:\n)?/)[1] || null; const stacktrace = error.message?.split(/\n(?:\s+)?stacktrace:(?:\s+)?(?:\n)?/)[1] || null; if (error.stacktrace?.includes("opera:config#UserPrefs")) { return Backtrace; } if (error.stacktrace === false) {throw new Stacklyn.Error( "The specified error came from somewhere" + "with stack traces disabled, enable " + "'opera:config#UserPrefs|Exceptions Have Stacktrace' if this is your error." );}; return error.stacktrace || stacktrace || Backtrace; } static _getOperaPairs(stack) { // BUG: if the context lines contain "\n", they get split incorrectly. // i have no idea how to make context aware context splitting // so this bug is staying for now const lines = stack.split("\n"), out = []; for (let i = 0; i < lines.length; i++) { const maybeContext = lines[i], maybeFrame = lines[i-1]; if (maybeContext?.startsWith(" ")) { const frame = maybeFrame?.startsWith(" Line ") ? maybeFrame.slice(2) : maybeFrame; const context = maybeContext.slice(4); out.push({ frame, context }); } } return out; } static _detectOperaMode(error) { let mode = "failed"; try { const operaStack = Stacklyn._getOperaStack(error); for (const line of operaStack.split("\n")) { const carakanPrefixes = [ "called via ", "called from ", "Error thrown ", "Error created ", "Error initially " ]; const isCarakan = carakanPrefixes.some(prefix => line.includes(prefix)); if (isCarakan) { mode = "carakan"; } else { mode = "linear-b"; } break; } } catch (_) { return mode; } return mode; } // extract location from a path like https://example.com/file.js:1:2 static _extractLocation(path, match = path.match(/:(\d+)(?::(\d+))?$/)) { return { line: match ? +match[1] : null, column: match ? +match[2] : null }; } // extract the filename from a path (feels redundant ik but this was used too much times it was enough to have a helper) static _getFilename(path) { return decodeURIComponent(path.split("/").pop()); } // clean a sourceURL of any unneeded junk static _cleanPath(path, mode = "full") { const cleaned = path.replace(/:\d+:\d+$/, ""); if (mode === "full") { // for logical paths return cleaned.replace(/\\/g, "/"); } else if (mode === "partial") { // for virtual paths (e.g. <anonymous>:4:2 or javascript:...:1:2) return cleaned.replace(/:\d+:\d+$/, ""); } } // build the object seen in the results static _buildOutputObject({ frameInfo, extra = {} }) { const out = { raw: frameInfo.raw, func: frameInfo.func, location: frameInfo.location, ...extra || undefined }; return out; } // async helper to prepend context to a frame static async _prependContext(frame, amount) { if (!frame.location || !frame.location.fileName || frame.location.anonymous) { return; } const fileContent = (await fetch(frame.location.sourceURL).then(res => res.text())); const lines = fileContent.split(/\r?\n/); const isMinified = ( /^\s*\/\/#\s*sourceMappingURL=.+/m.test(fileContent) || frame.location.fileName.includes(".min.") || fileContent.length / lines.length > 100 ); if (isMinified || lines.length < amount*2+1) return; return { contextabove: lines.slice(Math.max(0, frame.location.line - 1 - amount), frame.location.line - 1), context: lines[frame.location.line - 1] ?? "", contextbelow: lines.slice(frame.location.line, frame.location.line + amount), ...frame }; } static _getEspruinoPairs(stack) { const frames = [], contexts = [], carets = []; stack.split("\n").forEach(line => { if (line.startsWith(" at ")) { frames.push(line); } else if (line.includes(" ^")) { carets.push(line); } else { contexts.push(line); } }); return frames.map((frame, i) => ({ frame, context: contexts[i]?.startsWith(" ") ? contexts[i].slice(4) : contexts[i], caret: carets[i] })); } static _isValidError(error) {