import type { FrontendErrorCollectorScriptOptions } from "./types.js"; const DEFAULT_REPORTER = "openhandoff-frontend"; export function createFrontendErrorCollectorScript( options: FrontendErrorCollectorScriptOptions ): string { const config = { endpoint: options.endpoint, reporter: options.reporter ?? DEFAULT_REPORTER, includeConsoleErrors: options.includeConsoleErrors ?? true, includeFetchErrors: options.includeFetchErrors ?? true, }; return `(function () { if (typeof window === "undefined") { return; } if (window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__) { return; } var config = ${JSON.stringify(config)}; var sharedContext = window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ || {}; window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = sharedContext; function now() { return Date.now(); } function clampText(input, maxLength) { if (typeof input !== "string") { return null; } var value = input.trim(); if (!value) { return null; } if (value.length <= maxLength) { return value; } return value.slice(0, maxLength) + "...(truncated)"; } function currentRoute() { return location.pathname + location.search + location.hash; } function safeContext() { var copy = {}; for (var key in sharedContext) { if (!Object.prototype.hasOwnProperty.call(sharedContext, key)) { continue; } var candidate = sharedContext[key]; if ( candidate === null || candidate === undefined || typeof candidate === "string" || typeof candidate === "number" || typeof candidate === "boolean" ) { copy[key] = candidate; } } copy.route = currentRoute(); return copy; } function stringifyUnknown(input) { if (typeof input === "string") { return input; } if (input instanceof Error) { return input.stack || input.message || String(input); } try { return JSON.stringify(input); } catch { return String(input); } } var internalSendInFlight = false; function send(eventPayload) { var payload = { kind: eventPayload.kind || "window-error", message: clampText(eventPayload.message || "(no message)", 12000), stack: clampText(eventPayload.stack, 12000), source: clampText(eventPayload.source, 1024), line: typeof eventPayload.line === "number" ? eventPayload.line : null, column: typeof eventPayload.column === "number" ? eventPayload.column : null, url: clampText(eventPayload.url || location.href, 2048), timestamp: typeof eventPayload.timestamp === "number" ? eventPayload.timestamp : now(), context: safeContext(), extra: eventPayload.extra || {}, }; var body = JSON.stringify(payload); if (navigator.sendBeacon && body.length < 60000) { var blob = new Blob([body], { type: "application/json" }); navigator.sendBeacon(config.endpoint, blob); return; } if (internalSendInFlight) { return; } internalSendInFlight = true; fetch(config.endpoint, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", keepalive: true, body: body, }).catch(function () { return; }).finally(function () { internalSendInFlight = false; }); } window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__ = { setContext: function (nextContext) { if (!nextContext || typeof nextContext !== "object") { return; } for (var key in nextContext) { if (!Object.prototype.hasOwnProperty.call(nextContext, key)) { continue; } sharedContext[key] = nextContext[key]; } }, }; if (config.includeConsoleErrors) { var originalConsoleError = console.error.bind(console); console.error = function () { var message = ""; var values = []; for (var index = 0; index < arguments.length; index += 1) { values.push(stringifyUnknown(arguments[index])); } message = values.join(" "); send({ kind: "console-error", message: message || "console.error called", timestamp: now(), extra: { args: values.slice(0, 10) }, }); return originalConsoleError.apply(console, arguments); }; } window.addEventListener("error", function (event) { var target = event.target; var hasResourceTarget = target && target !== window && typeof target === "object"; if (hasResourceTarget) { var url = null; if ("src" in target && typeof target.src === "string") { url = target.src; } else if ("href" in target && typeof target.href === "string") { url = target.href; } send({ kind: "resource-error", message: "Resource failed to load", source: event.filename || null, line: typeof event.lineno === "number" ? event.lineno : null, column: typeof event.colno === "number" ? event.colno : null, url: url || location.href, stack: null, timestamp: now(), }); return; } var message = clampText(event.message, 12000) || "Unhandled window error"; var stack = event.error && event.error.stack ? String(event.error.stack) : null; send({ kind: "window-error", message: message, stack: stack, source: event.filename || null, line: typeof event.lineno === "number" ? event.lineno : null, column: typeof event.colno === "number" ? event.colno : null, url: location.href, timestamp: now(), }); }, true); window.addEventListener("unhandledrejection", function (event) { var reason = event.reason; var stack = reason && reason.stack ? String(reason.stack) : null; send({ kind: "unhandled-rejection", message: stringifyUnknown(reason), stack: stack, url: location.href, timestamp: now(), }); }); if (config.includeFetchErrors && typeof window.fetch === "function") { var originalFetch = window.fetch.bind(window); window.fetch = function () { var args = arguments; var requestUrl = null; if (typeof args[0] === "string") { requestUrl = args[0]; } else if (args[0] && typeof args[0].url === "string") { requestUrl = args[0].url; } return originalFetch.apply(window, args).then(function (response) { if (!response.ok && response.status >= 500) { send({ kind: "fetch-response-error", message: "Fetch returned HTTP " + response.status, url: requestUrl || location.href, timestamp: now(), extra: { status: response.status, statusText: response.statusText, }, }); } return response; }).catch(function (error) { send({ kind: "fetch-error", message: stringifyUnknown(error), stack: error && error.stack ? String(error.stack) : null, url: requestUrl || location.href, timestamp: now(), }); throw error; }); }; } })();`; }