export interface StackFrame { args?: unknown[]; columnNumber?: number; lineNumber?: number; fileName?: string; functionName?: string; source?: string; isServer?: boolean; isSymbolicated?: boolean; } export interface ParseOptions { slice?: number | [number, number]; allowEmpty?: boolean; includeInElement?: boolean; } const FIREFOX_SAFARI_STACK_REGEXP = /(^|@)\S+:\d+/; const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m; const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code\])?$/; const getNonStandardStacktrace = (error: unknown): string | null => { if (error && typeof error === "object") { const stacktrace = (error as Record)["stacktrace"]; return typeof stacktrace === "string" ? stacktrace : null; } return null; }; export const parseStack = (stackString: string, options?: ParseOptions): StackFrame[] => { if (options?.includeInElement !== false) { const lines = stackString.split("\n"); const frames: StackFrame[] = []; for (const rawLine of lines) { if (/^\s*at\s+/.test(rawLine)) { const parsed = parseV8OrIeString(rawLine, undefined)[0]; if (parsed) frames.push(parsed); } else if (/^\s*in\s+/.test(rawLine)) { const elementName = rawLine.replace(/^\s*in\s+/, "").replace(/\s*\(at .*\)$/, ""); frames.push({ functionName: elementName, source: rawLine }); } else if (rawLine.match(FIREFOX_SAFARI_STACK_REGEXP)) { const parsed = parseFFOrSafariString(rawLine, undefined)[0]; if (parsed) frames.push(parsed); } } return applySlice(frames, options); } if (stackString.match(CHROME_IE_STACK_REGEXP)) { return parseV8OrIeString(stackString, options); } return parseFFOrSafariString(stackString, options); }; export const extractLocation = ( urlLike: string, ): [string, string | undefined, string | undefined] => { if (!urlLike.includes(":")) return [urlLike, undefined, undefined]; // HACK: Chrome/V8 stack traces wrap location in parens: "(file.js:10:5)" // We need to strip these outer parens but preserve parens in paths (e.g., Next.js route groups like "(docs)") // Chrome format always ends with `:col)` where digit comes right before the closing paren const isWrappedLocation = urlLike.startsWith("(") && /:\d+\)$/.test(urlLike); const sanitizedResult = isWrappedLocation ? urlLike.slice(1, -1) : urlLike; const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/; const parts = regExp.exec(sanitizedResult); if (!parts) return [sanitizedResult, undefined, undefined]; return [parts[1], parts[2] || undefined, parts[3] || undefined] as const; }; const applySlice = (lines: T[], options?: ParseOptions): T[] => { if (options && options.slice != null) { if (Array.isArray(options.slice)) return lines.slice(options.slice[0], options.slice[1]); return lines.slice(0, options.slice); } return lines; }; export const parseV8OrIE = (error: Error, options?: ParseOptions): StackFrame[] => { return parseV8OrIeString(error.stack!, options); }; export const parseV8OrIeString = (stack: string, options?: ParseOptions): StackFrame[] => { const filteredLines = applySlice( stack.split("\n").filter((line) => { return !!line.match(CHROME_IE_STACK_REGEXP); }), options, ); return filteredLines.map((line): StackFrame => { let currentLine = line; if (currentLine.includes("(eval ")) { currentLine = currentLine .replace(/eval code/g, "eval") .replace(/(\(eval at [^()]*)|(,.*$)/g, ""); } let sanitizedLine = currentLine .replace(/^\s+/, "") .replace(/\(eval code/g, "(") .replace(/^.*?\s+/, ""); const locationMatch = sanitizedLine.match(/ (\(.+\)$)/); sanitizedLine = locationMatch ? sanitizedLine.replace(locationMatch[0], "") : sanitizedLine; const locationParts = extractLocation(locationMatch ? locationMatch[1] : sanitizedLine); const functionName = (locationMatch && sanitizedLine) || undefined; const fileName = ["eval", ""].includes(locationParts[0]) ? undefined : locationParts[0]; return { functionName, fileName, lineNumber: locationParts[1] ? +locationParts[1] : undefined, columnNumber: locationParts[2] ? +locationParts[2] : undefined, source: currentLine, }; }); }; export const parseFFOrSafari = (error: Error, options?: ParseOptions): StackFrame[] => { return parseFFOrSafariString(error.stack!, options); }; export const parseFFOrSafariString = (stack: string, options?: ParseOptions): StackFrame[] => { const filteredLines = applySlice( stack.split("\n").filter((line) => { return !line.match(SAFARI_NATIVE_CODE_REGEXP); }), options, ); return filteredLines.map((line): StackFrame => { let currentLine = line; if (currentLine.includes(" > eval")) currentLine = currentLine.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ":$1"); if (!currentLine.includes("@") && !currentLine.includes(":")) { return { functionName: currentLine, }; } else { const functionNameRegex = /(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/; const matches = currentLine.match(functionNameRegex); const functionName = matches && matches[1] ? matches[1] : undefined; const locationParts = extractLocation(currentLine.replace(functionNameRegex, "")); return { functionName, fileName: locationParts[0], lineNumber: locationParts[1] ? +locationParts[1] : undefined, columnNumber: locationParts[2] ? +locationParts[2] : undefined, source: currentLine, }; } }); }; export const parseOpera = (error: Error, options?: ParseOptions): StackFrame[] => { const nonStandardStacktrace = getNonStandardStacktrace(error); if ( !nonStandardStacktrace || (error.message.includes("\n") && error.message.split("\n").length > nonStandardStacktrace.split("\n").length) ) { return parseOpera9(error, options); } if (!error.stack) return parseOpera10(error, options); return parseOpera11(error, options); }; export const parseOpera9 = (error: Error, options?: ParseOptions): StackFrame[] => { const lineRegex = /Line (\d+).*script (?:in )?(\S+)/i; const messageLines = error.message.split("\n"); const parsedFrames: StackFrame[] = []; for (let i = 2, len = messageLines.length; i < len; i += 2) { const match = lineRegex.exec(messageLines[i]); if (match) { parsedFrames.push({ fileName: match[2], lineNumber: +match[1], source: messageLines[i], }); } } return applySlice(parsedFrames, options); }; export const parseOpera10 = (error: Error, options?: ParseOptions): StackFrame[] => { const lineRegex = /Line (\d+).*script (?:in )?(\S+)(?:: In function (\S+))?$/i; const nonStandardStacktrace = getNonStandardStacktrace(error); const stacktraceLines = (nonStandardStacktrace || "").split("\n"); const parsedFrames: StackFrame[] = []; for (let i = 0, len = stacktraceLines.length; i < len; i += 2) { const match = lineRegex.exec(stacktraceLines[i]); if (match) { parsedFrames.push({ functionName: match[3] || undefined, fileName: match[2], lineNumber: match[1] ? +match[1] : undefined, source: stacktraceLines[i], }); } } return applySlice(parsedFrames, options); }; export const parseOpera11 = (error: Error, options?: ParseOptions): StackFrame[] => { const filteredLines = applySlice( // @ts-expect-error missing stack property error.stack.split("\n").filter((line) => { return !!line.match(FIREFOX_SAFARI_STACK_REGEXP) && !line.match(/^Error created at/); }), options, ); return filteredLines.map((line) => { const tokens = line.split("@"); const locationParts = extractLocation(tokens.pop()!); const functionCall = tokens.shift() || ""; const functionName = functionCall.replace(//, "$2").replace(/\([^)]*\)/g, "") || undefined; let argsRaw: string | undefined; if (functionCall.match(/\(([^)]*)\)/)) argsRaw = functionCall.replace(/^[^(]+\(([^)]*)\)$/, "$1"); const args = argsRaw === undefined || argsRaw === "[arguments not available]" ? undefined : argsRaw.split(","); return { functionName, args, fileName: locationParts[0], lineNumber: locationParts[1] ? +locationParts[1] : undefined, columnNumber: locationParts[2] ? +locationParts[2] : undefined, source: line, }; }); };