feat(editor): report uncaught errors to Better Stack

The editor reported nothing when a session died. Evidence lived only in-tab —
an 800-line React array behind a "Show console" button — so diagnosis meant
asking a user to paste a screenshot.

Better Stack's Error Tracking ingests the Sentry wire protocol, so this runs
the stock @sentry/browser against a Better Stack DSN. Sentry.init installs its
own window error/unhandledrejection handlers, so uncaught main-thread errors
and the wasm traps that escape emscripten's DOM event handlers are captured
with no instrumentation at the throw sites. Not their JS tag: it has no
beforeSend or fingerprint hooks, its runtime spawns workers from cross-origin
CDN hosts (this page is COEP: require-corp), and it ships session replay on by
default — which on a CAD canvas records customers' board geometry.

@sentry/browser is imported in exactly one file so the vendor stays swappable,
mirroring how lib/analytics.ts isolates Plausible.

Also replaces the terminal-signature regex with a shared, unit-tested predicate
(wasm/terminal-error.ts) used by BOTH the fatal overlay and the reporter, so
they cannot disagree. The regex was a type check written as a string match and
had three live holes: `RuntimeError` was listed but never appears IN
`.message`; Chrome's bare "unreachable" and "null function" matched nothing
(the v0.1.20 prod log is exactly those); and narrowing "table index is out of
bounds" to `\bindex out of bounds` for Firefox in 197f317 silently stopped
matching Chrome's spelling. Checking the TYPE — every trap in this family is a
WebAssembly.RuntimeError — covers all engines and ends the spelling chase; the
message patterns remain as a fallback for paths that lose the Error object,
such as a worker ErrorEvent crossing the realm boundary with error: null.
197f317's pthread-worker tap, promote() and Firefox findings are kept as-is.

Notes:
- Off unless VITE_ERRORS_DSN is set AND VITE_ALLOW_USER_OVERRIDE !== "1" (dev
  servers and every Playwright harness set the latter, and production builds
  never do), so a production DSN in a local .env still cannot report. With no
  DSN the whole SDK is const-folded out: 1,193,080 vs 1,282,463 bytes of JS.
- browserApiErrors integration removed. It wraps setTimeout/rAF/addEventListener
  in try/catch, which is exactly how KiCad-on-Emscripten drives its main loop.
- Console breadcrumbs off (collab/debug.ts's clog fires per Yjs update and would
  evict the ring before any crash); dom/fetch/navigation breadcrumbs kept.
- beforeSend redacts token/apiKey/Bearer — collab/provider.ts puts the collab
  token in the y-partyserver URL, so a connection-failure string carries a live
  credential — and guards the cascade: one wedge produced 8 errors in prod, and
  after the first terminal event the rest are dropped into cascade_count.

Verified end to end against the real EU host from a cross-origin-isolated page:
POST /api/<id>/envelope/ -> 200, and 4 terminal throws produce 1 event
(control: 1 throw, same count).

Privacy policy 9, cookie policy 6 and the licenses page are updated: Better
Stack is disclosed as an EU processor, and the licenses page now describes the
browser app's own JS dependencies, which it never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-08-03 12:24:30 +02:00
commit 1b08a5eb06
20 changed files with 535 additions and 18 deletions

View file

@ -60,6 +60,20 @@ VITE_WASM_ROOT=/wasm
# plausible.io script won't load under COEP require-corp.
# VITE_PLAUSIBLE_SRC=https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js
# Error tracking (Better Stack). Unset ⇒ nothing is reported (dev/checkout
# default). Better Stack ingests the Sentry wire protocol, so this is a Sentry-
# format DSN and the app runs the stock @sentry/browser SDK against their host:
# https://<application-token>@<ingesting-host>/<application-id>
# The token is public once it ships in the bundle — that is inherent to browser
# error reporting, and it is write-only, so the exposure is quota abuse rather
# than data. Deploys pass it via scripts/deploy/build-{editor,demo}.mjs
# --errors-dsn; VITE_ERRORS_ENV tags which deploy an error came from.
# NOTE: reporting also stays off whenever VITE_ALLOW_USER_OVERRIDE=1 (dev servers
# and e2e harnesses set it), so a production DSN in a local .env still can't
# pollute the dashboard.
# VITE_ERRORS_DSN=
# VITE_ERRORS_ENV=production
# Where a backend project's DOCUMENT content lives ("api" default | "ydoc").
# Same /p/<project> URLs either way. "api": file bytes come from the REST
# backend and a user save (File->Save in the editor) is uploaded back to it.

View file

@ -23,6 +23,7 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@sentry/browser": "^10.69.0",
"@tanstack/react-query": "^5.62.11",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",

View file

@ -67,6 +67,7 @@ import {
type ToolFile,
} from "@/wasm/kicad-runner";
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type {
KicadCollabHandle,
@ -1227,14 +1228,22 @@ export function WasmTool({
// genuinely terminal signatures promote to the fatal overlay; ordinary app
// errors must not hijack a working editor.
React.useEffect(() => {
// NOTE: matched against `e.error.message`, which is BARE — Firefox's first
// trap is literally "index out of bounds" (no "RuntimeError", no "table")
// and slipped through the original pattern; the v0.1.19 prod log opens with
// exactly that message.
const terminal = (msg: string) =>
/RuntimeError|\babort(ed)?\b|\bindex out of bounds|indirect call signature|memory access out of bounds|unreachable executed|null function or function signature/i.test(
msg,
);
// The predicate lives in wasm/terminal-error.ts (unit-tested there) and is
// shared with the error reporter, so the overlay and Better Stack can never
// disagree about what "terminal" means.
//
// It checks the error's TYPE first — every trap in this family is a
// `WebAssembly.RuntimeError` whatever the engine calls it — with the message
// patterns kept only as a fallback for the paths that lose the Error object
// (a worker ErrorEvent crosses the realm boundary with `error: null`).
//
// That ends the per-engine spelling chase this check kept losing. Matching
// the message alone had three live holes: `RuntimeError` was listed but
// never appears IN `.message`; Chrome's bare "unreachable" and "null
// function" (the v0.1.20 prod log) matched nothing; and narrowing
// "table index is out of bounds" to `\bindex out of bounds` for Firefox's
// spelling silently stopped matching Chrome's. The type check covers all
// of them, and the fallback pattern is now a superset of the old one.
// Promote to the fatal screen AND pop the console open: the log panel is
// the only account of what was loading, so a fatal must never leave it
// collapsed behind a mystery blue screen.
@ -1254,13 +1263,13 @@ export function WasmTool({
showFatalScreen(msg);
};
const onError = (e: ErrorEvent) => {
const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? "");
if (!terminal(msg)) return;
const msg = errorMessage(e.error, e.message);
if (!isTerminalError(e.error, msg)) return;
promote("window error", msg);
};
const onRejection = (e: PromiseRejectionEvent) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
if (!terminal(msg)) return;
const msg = errorMessage(e.reason);
if (!isTerminalError(e.reason, msg)) return;
promote("unhandled rejection", msg);
};
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
@ -1272,8 +1281,8 @@ export function WasmTool({
// from this realm, so every one gets an error tap.
const NativeWorker = window.Worker;
const onWorkerError = (e: ErrorEvent) => {
const msg = String(e.message ?? "");
if (!terminal(msg)) return;
const msg = errorMessage(e.error, e.message);
if (!isTerminalError(e.error, msg)) return;
promote("worker error", msg);
};
const PatchedWorker = function (

View file

@ -82,6 +82,29 @@ export const WAITLIST_URL =
*/
export const PLAUSIBLE_SRC = import.meta.env.VITE_PLAUSIBLE_SRC || null;
/**
* Error tracking (Better Stack). Off unless a DSN is set, so a plain dev
* checkout and any deploy that doesn't set the var report nothing.
*
* The DSN is in SENTRY wire format because Better Stack ingests that protocol —
* we run the stock @sentry/browser SDK against their host. Named by role rather
* than by vendor precisely because the two differ: `VITE_SENTRY_DSN` would
* imply Sentry receives the data (it doesn't) and `VITE_BETTERSTACK_DSN` would
* imply a Better Stack SDK (there isn't one). See lib/error-reporting.ts.
*
* Format: https://<application-token>@<ingesting-host>/<application-id>
*
* NOTE: this token ships in the client bundle and is public — that is inherent
* to browser error reporting and true of Sentry's own DSNs. It is write-only
* (it cannot read anything back), so the exposure is quota abuse, not data.
*/
export const ERRORS_DSN = import.meta.env.VITE_ERRORS_DSN || null;
/** Environment tag on reported errors. Deploys set it explicitly; a build that
* forgot to should be obvious in the dashboard rather than silently blending
* into production. */
export const ERRORS_ENV = import.meta.env.VITE_ERRORS_ENV || "development";
/**
* Where the standalone reads PROJECTS from (env VITE_PROJECT_SOURCE):
* "remote" (default) — the @pcbjam/shared REST backend at API_BASE_URL.

View file

@ -0,0 +1,140 @@
import * as Sentry from "@sentry/browser";
import { APP_GIT_SHA, APP_TAG, ERRORS_DSN, ERRORS_ENV } from "./config";
import { isTerminalSerializedError } from "@/wasm/terminal-error";
/**
* Error reporting to Better Stack.
*
* Better Stack's Error Tracking ingests the SENTRY wire protocol, so we run the
* stock @sentry/browser SDK pointed at a Better Stack DSN. No Better Stack SDK
* is involved (they don't publish one for browsers; their alternative is a
* remote <script> tag with no beforeSend/fingerprint hooks and session replay on
* by default — which on a CAD canvas would record customers' board geometry).
*
* THIS IS THE ONLY FILE THAT IMPORTS @sentry/browser. Everything else stays
* vendor-neutral, so swapping providers is a one-file change. Same isolation
* rule lib/analytics.ts applies to Plausible.
*
* What this captures, and what it doesn't
* --------------------------------------
* Sentry.init installs its own window `error` / `unhandledrejection` handlers,
* so uncaught main-thread errors are captured with no instrumentation at the
* throw sites. That is deliberately the whole feature: the errors that actually
* kill sessions are wasm traps escaping emscripten's DOM event handlers
* (`dynCall_iiii` → trap → window.onerror), which this sees for free.
*
* It does NOT see inside Web Workers — occ-worker.js / ngspice-worker.js and the
* KiCad pthread workers are separate realms with their own global scope, and
* every console.error in this app lives in one of them. Forwarding those needs a
* postMessage bridge; deferred until the data says those workers actually fail.
*
* Bundle cost: ZERO when no DSN is configured. `import.meta.env.VITE_ERRORS_DSN`
* is const-folded at build time, so the guard below is statically false and
* Rollup drops the whole @sentry/browser import — measured 1,193,080 vs
* 1,282,463 bytes of JS (~26 KB gzipped) with and without. Dev checkouts and the
* e2e preview build therefore ship none of it.
*/
/** Reports per session — a crash loop must not bill as a thousand events. */
const MAX_EVENTS_PER_SESSION = 20;
let sent = 0;
/**
* Set once a terminal wasm error has been reported. After a trap the module is
* wedged, so every subsequent DOM event re-enters dead wasm and throws again — a
* single production incident produced 8 identical-looking errors from one root
* cause. Reporting each would triple-count the issue and read as 8 problems.
*/
let wasmDead = false;
let cascade = 0;
/** Query params whose values are credentials. `?token=` in particular is live:
* wasm/collab/provider.ts passes the collab token in the y-partyserver URL, so
* any connection-failure message containing that URL carries a real secret. */
const SECRET_PARAM = /([?&](?:token|apiKey|api_key|access_token|key|secret|sig|signature|password)=)[^&\s"']+/gi;
const BEARER = /(Bearer\s+)[A-Za-z0-9._-]+/gi;
function redact(text: string): string {
return text.replace(SECRET_PARAM, "$1[redacted]").replace(BEARER, "$1[redacted]");
}
/**
* Initialize error reporting. Idempotent; a hard no-op unless all three hold:
*
* 1. a DSN is configured (unset in dev checkouts and in any deploy that
* doesn't pass one);
* 2. we're in a browser (vitest imports this module transitively);
* 3. the build does NOT allow `?user=` overrides — VITE_ALLOW_USER_OVERRIDE=1
* is set by the dev server and every Playwright harness and NEVER by a
* production build (see config.ts). Using it as a negative gate means a
* developer who copies a production .env into a local checkout still can't
* pollute the dashboard.
*/
export function initErrorReporting(): void {
if (!ERRORS_DSN || typeof window === "undefined") return;
if (import.meta.env.VITE_ALLOW_USER_OVERRIDE === "1") return;
Sentry.init({
dsn: ERRORS_DSN,
environment: ERRORS_ENV,
// Same identity the version badge shows, which is also our GPLv3
// corresponding-source pointer — so an error and the exact source it came
// from share one key.
release: `pcbjam@${APP_TAG ?? "dev"}+${APP_GIT_SHA?.slice(0, 7) ?? "local"}`,
// The defaults are MERGED with an `integrations: [...]` array, so removing
// one requires the callback form.
integrations: (defaults) => [
...defaults.filter(
(i) =>
// BrowserApiErrors wraps setTimeout / setInterval /
// requestAnimationFrame / addEventListener in try/catch to attach
// better stack traces. KiCad-on-Emscripten drives its main loop
// through exactly those (emscripten_async_call → setTimeout, and the
// GAL refresh timer re-arms in a tight loop), so wrapping them adds a
// closure per tick and changes stack identity in the middle of an
// Asyncify-transformed call graph. Not worth it for prettier traces.
i.name !== "BrowserApiErrors" && i.name !== "Breadcrumbs",
),
// Keep breadcrumbs — clicks, navigation and fetches are exactly the "what
// was the user doing" trail that makes a lone stack trace actionable — but
// drop CONSOLE capture. wasm/collab/debug.ts's clog is on by default in
// production and fires on every Yjs update, so console breadcrumbs would
// evict the whole ring with collab chatter before any crash happens.
Sentry.breadcrumbsIntegration({ console: false }),
],
beforeSend(event) {
if (sent >= MAX_EVENTS_PER_SESSION) return null;
const ex = event.exception?.values?.[0];
const message = ex?.value ?? event.message ?? "";
// The live Error is gone by now — classify from the serialized frame,
// whose `type` is the original constructor name. Matching on the message
// alone would miss any trap whose wording we didn't anticipate.
const terminal = isTerminalSerializedError(ex?.type, message);
// One wedge, one event. Later traps are the same corpse being kicked.
if (terminal && wasmDead) {
cascade++;
return null;
}
if (terminal) {
wasmDead = true;
event.tags = { ...event.tags, wasm_terminal: "true" };
}
if (cascade > 0) {
event.extra = { ...event.extra, cascade_count: cascade };
}
if (ex?.value) ex.value = redact(ex.value);
if (event.message) event.message = redact(event.message);
// The SDK attaches the page URL automatically; scrub it too — editor URLs
// are project paths and may carry query credentials.
if (event.request?.url) event.request.url = redact(event.request.url);
sent++;
return event;
},
});
}

View file

@ -3,10 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { initAnalytics } from "./lib/analytics";
import { initErrorReporting } from "./lib/error-reporting";
import { initTheme } from "./lib/theme";
import { installFatalScreenListeners } from "./wasm/fatal-screen";
import "./index.css";
// Error tracking (Better Stack), only when VITE_ERRORS_DSN is set. FIRST and
// synchronous, so its window handlers are live before anything else runs —
// including the fatal-screen floor below and WasmTool's 175–338 MB boot, where
// the crashes worth catching happen.
initErrorReporting();
// The React-independent blue-screen floor: installed before React mounts so a
// wasm trap can never end in a white page, even if React unmounts itself
// (which it did, three prod releases in a row — see async/16).

View file

@ -32,6 +32,10 @@ interface ImportMetaEnv {
readonly VITE_YJS_TOKEN?: string;
/** Plausible pa-*.js script URL; unset ⇒ no tracking (dev default). */
readonly VITE_PLAUSIBLE_SRC?: string;
/** Error-tracking DSN (Sentry wire format, pointed at Better Stack); unset ⇒ no reporting (dev default). */
readonly VITE_ERRORS_DSN?: string;
/** Environment tag on reported errors: "production" | "demo" | "preview". Defaults to "development". */
readonly VITE_ERRORS_ENV?: string;
/** Management app origin (e.g. https://app.pcbjam.com); set ⇒ non-editor routes redirect there (lib/redirect.ts). */
readonly VITE_APP_URL?: string;
/** "1" ⇒ honor `?user=`/`?libowner=` identity overrides (dev/e2e harnesses only — NEVER production builds). */

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { errorMessage, isTerminalError, isTerminalSerializedError } from "./terminal-error";
/**
* The strings below are the REAL ones observed on editor.pcbjam.com — a wedged
* pcbnew session where every key/focus/resize event re-entered dead wasm. The
* previous inline regex matched none of them, which is why the fatal overlay
* never appeared.
*/
describe("isTerminalError", () => {
it("matches a WebAssembly.RuntimeError by type, whatever the message says", () => {
// The type is the load-bearing signal: `.message` is just "unreachable",
// which the old regex (looking for "unreachable executed") missed.
expect(isTerminalError(new WebAssembly.RuntimeError("unreachable"), "unreachable")).toBe(true);
expect(isTerminalError(new WebAssembly.RuntimeError("null function"), "null function")).toBe(
true,
);
// Even a message we have no pattern for is terminal if the type says so.
expect(isTerminalError(new WebAssembly.RuntimeError("something new"), "something new")).toBe(
true,
);
});
it("matches an Error named RuntimeError that crossed a realm boundary", () => {
const err = new Error("unreachable");
err.name = "RuntimeError";
expect(isTerminalError(err, "unreachable")).toBe(true);
});
it("falls back to the message when the Error object is unavailable", () => {
// Worker ErrorEvents arrive with `error: null` — message is all we get.
expect(isTerminalError(null, "RuntimeError: unreachable")).toBe(true);
expect(isTerminalError(null, "null function or function signature mismatch")).toBe(true);
expect(isTerminalError(null, "table index is out of bounds")).toBe(true);
expect(isTerminalError(null, "memory access out of bounds")).toBe(true);
expect(isTerminalError(null, "indirect call signature mismatch")).toBe(true);
expect(isTerminalError(null, "Aborted(RuntimeError: unreachable)")).toBe(true);
expect(isTerminalError(null, "abort")).toBe(true);
});
it("does NOT match ordinary app errors — a working editor must not be hijacked", () => {
expect(isTerminalError(new TypeError("x is not a function"), "x is not a function")).toBe(false);
expect(isTerminalError(new Error("failed to fetch"), "failed to fetch")).toBe(false);
expect(isTerminalError(null, "ResizeObserver loop completed with undelivered notifications"))
.toBe(false);
expect(isTerminalError(undefined, "")).toBe(false);
});
/**
* Engine-spelling matrix. This predicate replaced a regex that was chasing
* per-engine wording and losing: 197f317 added Firefox's forms and in doing so
* narrowed "table index is out of bounds" to `\bindex out of bounds`, which
* stopped matching Chrome's. Every row below is a real production spelling —
* they must ALL hold, in both engines, message-only (no Error object).
*/
it.each([
["unreachable", "Chrome — v0.1.20 prod"],
["null function", "Chrome — v0.1.20 prod"],
["null function or function signature mismatch", "Chrome — full form"],
["unreachable executed", "Chrome — older V8 wording"],
["index out of bounds", "Firefox — v0.1.19 prod, what 197f317 fixed"],
["table index is out of bounds", "Chrome — regressed by 197f317"],
["memory access out of bounds", "both"],
["indirect call signature mismatch", "both"],
])("matches the trap spelling %j (%s)", (msg) => {
expect(isTerminalError(null, msg)).toBe(true);
});
it("does not match 'abort' inside an unrelated word", () => {
// \b guards the alternative — "aborting" is fine, "collaborator" must not be.
expect(isTerminalError(null, "collaborator joined")).toBe(false);
});
});
/**
* Regression: the reporter classifies from a SERIALIZED event, where the live
* Error is gone. An earlier version fell back to the message alone there, so a
* RuntimeError with unanticipated wording was treated as an ordinary error and
* the cascade guard never armed — one wedged session then reported N times.
* Caught by a real end-to-end test, not by the unit tests, hence this block.
*/
describe("isTerminalSerializedError", () => {
it("trusts the serialized type even when the message matches nothing", () => {
expect(isTerminalSerializedError("RuntimeError", "test from local")).toBe(true);
expect(isTerminalSerializedError("RuntimeError", "some wording we never saw")).toBe(true);
});
it("still matches known wording when the type is absent or generic", () => {
expect(isTerminalSerializedError(undefined, "unreachable")).toBe(true);
expect(isTerminalSerializedError("Error", "null function")).toBe(true);
expect(isTerminalSerializedError("Error", "memory access out of bounds")).toBe(true);
});
it("does not match ordinary serialized errors", () => {
expect(isTerminalSerializedError("TypeError", "x is not a function")).toBe(false);
expect(isTerminalSerializedError("Error", "failed to fetch")).toBe(false);
expect(isTerminalSerializedError(undefined, "")).toBe(false);
});
});
describe("errorMessage", () => {
it("prefers the Error's message", () => {
expect(errorMessage(new Error("boom"), "ignored")).toBe("boom");
});
it("stringifies a non-Error rejection reason", () => {
expect(errorMessage("plain string reason")).toBe("plain string reason");
expect(errorMessage(42)).toBe("42");
});
it("uses the fallback when there is no error value", () => {
expect(errorMessage(null, "event.message")).toBe("event.message");
expect(errorMessage(undefined)).toBe("");
});
});

View file

@ -0,0 +1,89 @@
/**
* Is this error terminal — i.e. did the wasm instance just die?
*
* A wasm trap ("unreachable", "null function", "table index is out of bounds")
* or an abort() leaves the module wedged: every later entry into wasm trips the
* same fault, so the editor is unusable even though the page is still up. The
* fatal overlay (components/WasmTool.tsx) and the error reporter both key off
* this predicate, so they can never disagree about what "terminal" means.
*
* Why a TYPE check and not only a message match
* ---------------------------------------------
* This lived inline in WasmTool as a regex over `err.message`, and it silently
* stopped matching. Two reasons, both instructive:
*
* 1. It listed `RuntimeError` as an alternative — but that is the error's
* *type*, which never appears in `.message`. `String(err)` would contain
* it; `err.message` does not.
* 2. It listed `unreachable executed`, which was V8's old wording. Current
* Chrome throws `RuntimeError: unreachable`, and `null function` was never
* covered at all.
*
* Net effect in production: a wedged editor rendered a black canvas with no
* overlay and no trace dump, which is exactly what the handler existed to
* prevent. So the primary signal is now the type — `WebAssembly.RuntimeError`
* is only ever thrown by a wasm trap — and the string patterns are a fallback
* for the paths that surface a trap as a plain Error or a bare string (worker
* `ErrorEvent`s cross the realm boundary with `error: null`, leaving only a
* message).
*
* Widening to the type cannot "hijack a working editor" (the concern the
* original guarded against): in this app a wasm trap always means the instance
* is gone.
*/
/**
* Message fragments that indicate a dead wasm instance, for the paths where the
* Error object itself is unavailable. Kept deliberately narrow — an ordinary
* app-level TypeError must not match, or the overlay would fire over a working
* editor.
*/
const TERMINAL_MESSAGE =
/\babort(ed)?\b|out of bounds|indirect call signature|null function|unreachable/i;
/**
* @param err the caught value — `ErrorEvent.error`, `PromiseRejectionEvent.reason`,
* or whatever a catch block received. May be null/undefined.
* @param msg the best available message string (callers pass `err.message` when
* `err` is an Error, else the event's own `message` field).
*/
export function isTerminalError(err: unknown, msg: string): boolean {
// Primary signal: only a wasm trap produces one of these.
if (typeof WebAssembly !== "undefined" && err instanceof WebAssembly.RuntimeError) {
return true;
}
// Same check by name, for realms where the instanceof fails (an error that
// crossed a worker/iframe boundary carries a different intrinsic).
if (err instanceof Error && err.name === "RuntimeError") return true;
return TERMINAL_MESSAGE.test(msg);
}
/**
* The same decision as `isTerminalError`, for an error that has already been
* SERIALIZED and no longer exists as a live object — i.e. a reporter payload,
* where the exception frame carries the constructor name and the message as two
* plain strings.
*
* This exists because the object-based check above is unavailable there, and
* falling back to the message alone reintroduces exactly the bug this module
* was written to fix: a trap whose wording we didn't anticipate is silently
* reclassified as an ordinary error. `type` is the serialized `err.name`, so
* checking it is the faithful equivalent of the `instanceof` test.
*/
export function isTerminalSerializedError(
type: string | undefined,
value: string,
): boolean {
if (type === "RuntimeError") return true;
return TERMINAL_MESSAGE.test(value);
}
/**
* Normalize a caught value to the message string `isTerminalError` expects.
* Mirrors what the window handlers did inline, so both callers agree.
*/
export function errorMessage(err: unknown, fallback?: string): string {
if (err instanceof Error) return err.message;
if (err != null) return String(err);
return String(fallback ?? "");
}