glitchgrab 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +65 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +65 -19
- package/dist/index.mjs.map +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nconst DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<{\n conversationId: string | null;\n question: string | null;\n options?: string[];\n report: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n degraded?: string | null;\n retryable?: boolean;\n}> {\n const offline = {\n conversationId: null,\n question: null,\n report: null,\n degraded:\n \"The assistant is unavailable — write your report below and send it as normal.\",\n };\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n error?: string;\n degrade?: boolean;\n disabled?: boolean;\n retryable?: boolean;\n data?: {\n conversationId?: string;\n question?: string | null;\n options?: string[];\n report?: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n };\n } | null;\n\n if (!response.ok || !envelope?.success) {\n // The server distinguishes \"cannot help\" (cap, rate limit, model down,\n // switched off) from a bug. Either way the dialog does the same thing —\n // but the reason is the server's to word, so pass it through.\n return {\n conversationId: null,\n question: null,\n report: null,\n degraded: envelope?.error ?? offline.degraded,\n // An hourly limit, not a cap: the dialog keeps the assistant button.\n retryable: envelope?.retryable === true,\n };\n }\n\n return {\n conversationId: envelope.data?.conversationId ?? null,\n question: envelope.data?.question ?? null,\n options: envelope.data?.options ?? [],\n report: envelope.data?.report ?? null,\n duplicate: envelope.data?.duplicate ?? null,\n solved: envelope.data?.solved ?? null,\n aboutGlitchgrab: envelope.data?.aboutGlitchgrab === true,\n degraded: null,\n };\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nconst DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<{\n conversationId: string | null;\n question: string | null;\n options?: string[];\n report: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n related?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n degraded?: string | null;\n retryable?: boolean;\n}> {\n const offline = {\n conversationId: null,\n question: null,\n report: null,\n degraded:\n \"The assistant is unavailable — write your report below and send it as normal.\",\n };\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n error?: string;\n degrade?: boolean;\n disabled?: boolean;\n retryable?: boolean;\n data?: {\n conversationId?: string;\n question?: string | null;\n options?: string[];\n report?: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n related?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n };\n } | null;\n\n if (!response.ok || !envelope?.success) {\n // The server distinguishes \"cannot help\" (cap, rate limit, model down,\n // switched off) from a bug. Either way the dialog does the same thing —\n // but the reason is the server's to word, so pass it through.\n return {\n conversationId: null,\n question: null,\n report: null,\n degraded: envelope?.error ?? offline.degraded,\n // An hourly limit, not a cap: the dialog keeps the assistant button.\n retryable: envelope?.retryable === true,\n };\n }\n\n return {\n conversationId: envelope.data?.conversationId ?? null,\n question: envelope.data?.question ?? null,\n options: envelope.data?.options ?? [],\n report: envelope.data?.report ?? null,\n duplicate: envelope.data?.duplicate ?? null,\n related: envelope.data?.related ?? null,\n solved: envelope.data?.solved ?? null,\n aboutGlitchgrab: envelope.data?.aboutGlitchgrab === true,\n degraded: null,\n };\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
package/dist/server.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nconst DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<{\n conversationId: string | null;\n question: string | null;\n options?: string[];\n report: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n degraded?: string | null;\n retryable?: boolean;\n}> {\n const offline = {\n conversationId: null,\n question: null,\n report: null,\n degraded:\n \"The assistant is unavailable — write your report below and send it as normal.\",\n };\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n error?: string;\n degrade?: boolean;\n disabled?: boolean;\n retryable?: boolean;\n data?: {\n conversationId?: string;\n question?: string | null;\n options?: string[];\n report?: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n };\n } | null;\n\n if (!response.ok || !envelope?.success) {\n // The server distinguishes \"cannot help\" (cap, rate limit, model down,\n // switched off) from a bug. Either way the dialog does the same thing —\n // but the reason is the server's to word, so pass it through.\n return {\n conversationId: null,\n question: null,\n report: null,\n degraded: envelope?.error ?? offline.degraded,\n // An hourly limit, not a cap: the dialog keeps the assistant button.\n retryable: envelope?.retryable === true,\n };\n }\n\n return {\n conversationId: envelope.data?.conversationId ?? null,\n question: envelope.data?.question ?? null,\n options: envelope.data?.options ?? [],\n report: envelope.data?.report ?? null,\n duplicate: envelope.data?.duplicate ?? null,\n solved: envelope.data?.solved ?? null,\n aboutGlitchgrab: envelope.data?.aboutGlitchgrab === true,\n degraded: null,\n };\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nconst DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<{\n conversationId: string | null;\n question: string | null;\n options?: string[];\n report: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n related?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n degraded?: string | null;\n retryable?: boolean;\n}> {\n const offline = {\n conversationId: null,\n question: null,\n report: null,\n degraded:\n \"The assistant is unavailable — write your report below and send it as normal.\",\n };\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n error?: string;\n degrade?: boolean;\n disabled?: boolean;\n retryable?: boolean;\n data?: {\n conversationId?: string;\n question?: string | null;\n options?: string[];\n report?: string | null;\n duplicate?: { number: number; title: string; url: string; status?: string } | null;\n related?: { number: number; title: string; url: string; status?: string } | null;\n solved?: string | null;\n aboutGlitchgrab?: boolean;\n };\n } | null;\n\n if (!response.ok || !envelope?.success) {\n // The server distinguishes \"cannot help\" (cap, rate limit, model down,\n // switched off) from a bug. Either way the dialog does the same thing —\n // but the reason is the server's to word, so pass it through.\n return {\n conversationId: null,\n question: null,\n report: null,\n degraded: envelope?.error ?? offline.degraded,\n // An hourly limit, not a cap: the dialog keeps the assistant button.\n retryable: envelope?.retryable === true,\n };\n }\n\n return {\n conversationId: envelope.data?.conversationId ?? null,\n question: envelope.data?.question ?? null,\n options: envelope.data?.options ?? [],\n report: envelope.data?.report ?? null,\n duplicate: envelope.data?.duplicate ?? null,\n related: envelope.data?.related ?? null,\n solved: envelope.data?.solved ?? null,\n aboutGlitchgrab: envelope.data?.aboutGlitchgrab === true,\n degraded: null,\n };\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|