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:
parent
20e5eb941c
commit
1b08a5eb06
20 changed files with 535 additions and 18 deletions
5
.github/workflows/deploy-demo.yml
vendored
5
.github/workflows/deploy-demo.yml
vendored
|
|
@ -37,6 +37,10 @@ env:
|
|||
# `wrangler pages deploy` a PREVIEW deploy and demo.pcbjam.com won't update.
|
||||
# Direct-Upload projects default to "production".
|
||||
PAGES_PROD_BRANCH: production
|
||||
# Better Stack error-tracking DSN. Unset ⇒ this build reports nothing.
|
||||
# Keep in sync with release.yml — the demo is built from BOTH workflows, and
|
||||
# setting it in only one silently ships a demo with no error reporting.
|
||||
ERRORS_DSN: ${{ secrets.ERRORS_DSN }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
# The publish scripts shell wrangler; no repo dep — fetch it on demand.
|
||||
|
|
@ -100,6 +104,7 @@ jobs:
|
|||
--cdn "$CDN" --lib-tag "$LIB_TAG"
|
||||
${MODELS_TAG:+--models-tag "$MODELS_TAG"}
|
||||
--plausible "https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js"
|
||||
${ERRORS_DSN:+--errors-dsn "$ERRORS_DSN" --errors-env demo}
|
||||
|
||||
# 4) Ensure the Pages project exists (first deploy creates it; no-op after).
|
||||
# Its production branch must equal PAGES_PROD_BRANCH or deploys land as
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -48,6 +48,12 @@ env:
|
|||
# Mgmt app origin: non-editor routes on the editor host redirect here
|
||||
# (standalone-hardening 0006). The demo build never sets this.
|
||||
EDITOR_APP_BASE: https://app.pcbjam.com
|
||||
# Better Stack error-tracking DSN (Sentry wire format). Unset ⇒ builds report
|
||||
# nothing, so this is safe to leave empty. Held as a secret rather than a
|
||||
# literal: this repo is public, and although the token becomes visible in the
|
||||
# shipped bundle anyway, keeping it out of git makes it rotatable without a
|
||||
# commit. Keep in sync with deploy-demo.yml.
|
||||
ERRORS_DSN: ${{ secrets.ERRORS_DSN }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
WRANGLER_CMD: npx --yes wrangler@4
|
||||
|
|
@ -136,6 +142,7 @@ jobs:
|
|||
--cdn "$CDN" --lib-tag "$LIB_TAG"
|
||||
${MODELS_TAG:+--models-tag "$MODELS_TAG"}
|
||||
--plausible "https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js"
|
||||
${ERRORS_DSN:+--errors-dsn "$ERRORS_DSN" --errors-env demo}
|
||||
|
||||
- name: Ensure Pages project exists
|
||||
run: >
|
||||
|
|
@ -183,6 +190,7 @@ jobs:
|
|||
node scripts/deploy/build-editor.mjs --tag "$RELEASE_TAG"
|
||||
--cdn "$CDN" --api-base "$EDITOR_API_BASE" --app-base "$EDITOR_APP_BASE"
|
||||
--plausible "https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js"
|
||||
${ERRORS_DSN:+--errors-dsn "$ERRORS_DSN" --errors-env production}
|
||||
${MODELS_TAG:+--models-tag "$MODELS_TAG"}
|
||||
|
||||
- name: Ensure Pages project exists
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ function parseArgs(argv) {
|
|||
// Plausible pa-*.js script URL. Off unless given (or VITE_PLAUSIBLE_SRC
|
||||
// is already in the environment, which passes straight through).
|
||||
plausible: null,
|
||||
// Better Stack error-tracking DSN (Sentry wire format). Omitted ⇒ no error
|
||||
// reporting. The demo reports under its own environment: anonymous traffic
|
||||
// on arbitrary hardware with no backend fails differently from the signed-in
|
||||
// editor, and mixing them would drown the editor's real regressions.
|
||||
errorsDsn: null,
|
||||
errorsEnv: "demo",
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const next = () => argv[++i];
|
||||
|
|
@ -51,6 +57,8 @@ function parseArgs(argv) {
|
|||
case "--landing": a.landing = next(); break;
|
||||
case "--waitlist": a.waitlist = next(); break;
|
||||
case "--plausible": a.plausible = next(); break;
|
||||
case "--errors-dsn": a.errorsDsn = next(); break;
|
||||
case "--errors-env": a.errorsEnv = next(); break;
|
||||
default: throw new Error(`unknown arg: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +136,10 @@ function main() {
|
|||
VITE_WAITLIST_URL: a.waitlist,
|
||||
// Plausible analytics: explicit --plausible wins, else any env-provided value.
|
||||
...(a.plausible ? { VITE_PLAUSIBLE_SRC: a.plausible } : {}),
|
||||
// Error tracking. The env tag rides along only when a DSN is given.
|
||||
...(a.errorsDsn
|
||||
? { VITE_ERRORS_DSN: a.errorsDsn, VITE_ERRORS_ENV: a.errorsEnv }
|
||||
: {}),
|
||||
};
|
||||
|
||||
console.log(`build-demo: tag=${a.tag} cdn=${a.cdn}`);
|
||||
|
|
@ -139,6 +151,7 @@ function main() {
|
|||
console.log(` VITE_MODELS_MANIFEST_URL=${env.VITE_MODELS_MANIFEST_URL ?? "(unset — 3D models off)"}`);
|
||||
console.log(` VITE_LANDING_URL=${env.VITE_LANDING_URL} VITE_WAITLIST_URL=${env.VITE_WAITLIST_URL}`);
|
||||
console.log(` VITE_PLAUSIBLE_SRC=${env.VITE_PLAUSIBLE_SRC || "(off)"}`);
|
||||
console.log(` VITE_ERRORS_DSN=${env.VITE_ERRORS_DSN ? `(set, env=${env.VITE_ERRORS_ENV})` : "(off)"}`);
|
||||
|
||||
// Keep the dev-only WASM symlink out of the bundle (it'd copy 100s of MB into
|
||||
// dist/; the CDN serves it). In CI it isn't present, so this is a no-op there.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ function parseArgs(argv) {
|
|||
// kicad-packages3D snapshot (libs/kicad-models/<tag>/); omitted ⇒ 3D models off.
|
||||
modelsTag: null,
|
||||
plausible: null,
|
||||
// Better Stack error-tracking DSN (Sentry wire format). Omitted ⇒ no error
|
||||
// reporting from this build.
|
||||
errorsDsn: null,
|
||||
errorsEnv: "production",
|
||||
// Companion mgmt app origin; set ⇒ non-editor routes redirect there
|
||||
// (standalone-hardening 0006). Omitted ⇒ every route renders locally.
|
||||
appBase: null,
|
||||
|
|
@ -48,6 +52,8 @@ function parseArgs(argv) {
|
|||
case "--yjs-endpoint": a.yjsEndpoint = next(); break;
|
||||
case "--models-tag": a.modelsTag = next(); break;
|
||||
case "--plausible": a.plausible = next(); break;
|
||||
case "--errors-dsn": a.errorsDsn = next(); break;
|
||||
case "--errors-env": a.errorsEnv = next(); break;
|
||||
case "--app-base": a.appBase = next(); break;
|
||||
default: throw new Error(`unknown arg: ${argv[i]}`);
|
||||
}
|
||||
|
|
@ -109,6 +115,11 @@ function main() {
|
|||
VITE_GIT_SHA: gitSha(repoRoot),
|
||||
VITE_REPO_URL: a.repo,
|
||||
...(a.plausible ? { VITE_PLAUSIBLE_SRC: a.plausible } : {}),
|
||||
// Error tracking. The env tag rides along only when a DSN is given, so a
|
||||
// DSN-less build cannot report under a production label.
|
||||
...(a.errorsDsn
|
||||
? { VITE_ERRORS_DSN: a.errorsDsn, VITE_ERRORS_ENV: a.errorsEnv }
|
||||
: {}),
|
||||
// Non-editor surfaces bounce to the mgmt app (mirror of the closed repo's
|
||||
// VITE_STANDALONE_URL pointing the other way).
|
||||
...(a.appBase ? { VITE_APP_URL: a.appBase } : {}),
|
||||
|
|
@ -123,6 +134,7 @@ function main() {
|
|||
console.log(` VITE_MODELS_MANIFEST_URL=${env.VITE_MODELS_MANIFEST_URL ?? "(unset — 3D models off)"}`);
|
||||
console.log(` VITE_APP_TAG=${env.VITE_APP_TAG} VITE_GIT_SHA=${env.VITE_GIT_SHA || "(none)"}`);
|
||||
console.log(` VITE_PLAUSIBLE_SRC=${env.VITE_PLAUSIBLE_SRC || "(off)"}`);
|
||||
console.log(` VITE_ERRORS_DSN=${env.VITE_ERRORS_DSN ? `(set, env=${env.VITE_ERRORS_ENV})` : "(off)"}`);
|
||||
console.log(` VITE_APP_URL=${env.VITE_APP_URL || "(unset — no non-editor redirect)"}`);
|
||||
|
||||
// Keep the dev-only WASM symlink out of the bundle (CDN serves it).
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@ function main() {
|
|||
env.VITE_DOC_SOURCE = "api";
|
||||
env.VITE_LOCAL_PROJECTS = "idb";
|
||||
|
||||
// --- Never report errors from a local demo run, even if the developer has a
|
||||
// production DSN sitting in their environment.
|
||||
delete env.VITE_ERRORS_DSN;
|
||||
|
||||
// --- Projects: the read-only example gallery (the demo.pcbjam.com experience).
|
||||
// Default: build it locally and serve it same-origin. --content-tag <tag>
|
||||
// pins the live CDN gallery instead. --no-gallery falls back to local-folder
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Cookie Policy
|
||||
description: How PCBJam uses cookies and similar technologies, and the choices you have.
|
||||
updated: 2026-07-27
|
||||
updated: 2026-08-03
|
||||
---
|
||||
|
||||
This Cookie Policy explains how **PCBJam** ("PCBJam", "we", "us", "our") uses cookies and similar technologies — such as browser local storage, IndexedDB and cache storage — when you visit **pcbjam.com** or use the PCBJam application (the "Service"), and the choices you have.
|
||||
|
|
@ -101,6 +101,7 @@ We do **not** use Google Analytics, advertising pixels, or any cross-site tracki
|
|||
| **Paddle** (Paddle.com Market Limited and affiliates) | Merchant of Record / payments | Checkout, security and fraud-prevention cookies; their own non-essential cookies (with consent where required, via Paddle's own controls). Independent controller. | [Paddle Privacy Policy](https://www.paddle.com/legal/privacy); Paddle's cookie controls appear within its checkout. |
|
||||
| **Plausible** (Plausible Insights OÜ) | Cookieless analytics | Nothing — no cookies and no device storage (see Section 5). | [Plausible Data Policy](https://plausible.io/data-policy) · [Plausible Privacy](https://plausible.io/privacy) |
|
||||
| **Cloudflare** (Cloudflare, Inc.) | Hosting, CDN and network security | At most strictly-necessary hosting/security cookies. | [Cloudflare Privacy](https://www.cloudflare.com/privacypolicy/) |
|
||||
| **Better Stack** | Error and crash diagnostics for the editor | Nothing — no cookies. The reporting code runs in your browser only when the application errors, and sends the fault report directly; it sets no cookie and stores no identifier on your device. | [Better Stack Privacy](https://betterstack.com/legal/privacy) |
|
||||
|
||||
We are not responsible for the privacy practices of these third parties; please review their notices. We update this list as our integrations change.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Open-Source Licenses & Source Code
|
||||
description: The open-source software PCBJam is built on, the licences that apply, and how to get the corresponding source code.
|
||||
updated: 2026-06-18
|
||||
updated: 2026-08-03
|
||||
---
|
||||
|
||||
## The short version (summary)
|
||||
|
|
@ -25,6 +25,7 @@ PCBJam is a combined work. The table below lists its principal open-source compo
|
|||
| **wxWidgets** (base) | Cross-platform GUI toolkit that KiCad uses | **wxWindows Library Licence v3.1** (LGPL v2+ with a binary-distribution exception) |
|
||||
| **wxWidgets — WebAssembly port** | The browser/WASM platform layer, derived from [ahilss/wxWidgets-wasm](https://github.com/ahilss/wxWidgets-wasm) | **GNU Lesser General Public License, version 2 (LGPL v2)** — *without* the wxWindows binary exception |
|
||||
| Other bundled libraries | Various supporting libraries used by KiCad | Their respective licences (Apache-2.0, MIT, BSD-3-Clause, Boost, CC0, ISC, CC-BY-SA-4.0, and others) |
|
||||
| Browser application libraries | The JavaScript/TypeScript libraries the editor's own interface is built from — including React, Yjs (collaborative editing), Radix UI, and the Sentry SDK (used to report crashes to our error-tracking provider; see the [Privacy Policy](/privacy)) | Predominantly **MIT**, with some Apache-2.0 and BSD. Exact versions are pinned in `web/pnpm-lock.yaml` in the source repository |
|
||||
|
||||
The combined application is conveyed to you under the **GPLv3**. The wxWidgets components are GPL-compatible: the base toolkit's licence is explicitly compatible with GPL'd applications, and the LGPL v2 WebAssembly-port files may be combined into a GPLv3 work under the LGPL's terms.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Privacy Policy
|
||||
description: How PCBJam handles your personal data.
|
||||
updated: 2026-07-27
|
||||
updated: 2026-08-03
|
||||
---
|
||||
|
||||
## 1. A quick summary
|
||||
|
|
@ -146,6 +146,7 @@ We do **not** sell your personal data. We share it only with the following categ
|
|||
| **Paddle** | Payments / Merchant of Record | **Independent controller** (for payment data) | [Paddle Privacy](https://www.paddle.com/legal/privacy) |
|
||||
| **Resend** | Sending transactional and marketing email | **Processor** | [Resend DPA](https://resend.com/legal/dpa) · [Subprocessors](https://resend.com/legal/subprocessors) |
|
||||
| **Cloudflare** | Hosting this website, storing your project files (R2 object storage), hosting application/account data, and network delivery & security (CDN, DNS, WAF) | **Processor** | [Cloudflare DPA](https://www.cloudflare.com/cloudflare-customer-dpa/) · [GDPR hub](https://www.cloudflare.com/trust-hub/gdpr/) |
|
||||
| **Better Stack** | Error and crash diagnostics from the editor — when the application fails in your browser, the error message, technical diagnostic context (browser, device capabilities, the file type being edited, the app's own internal log), and your account identifier are sent so we can find and fix the fault. **Processed in the EU.** Not used for analytics, profiling, or marketing. | **Processor** | [Better Stack DPA](https://betterstack.com/legal/dpa) · [Privacy](https://betterstack.com/legal/privacy) |
|
||||
| **Google (Google Workspace)** | Our business email and support correspondence | **Processor** | [Google Cloud DPA](https://cloud.google.com/terms/data-processing-addendum) |
|
||||
| **Professional advisers & authorities** | Lawyers, accountants, auditors; courts, regulators, and law-enforcement where legally required | Controller / as required | — |
|
||||
| **A successor entity** | If we are involved in a merger, acquisition, financing, or sale of assets, your data may transfer to the successor under this policy | As required | — |
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ VITE_WASM_ASSET_BASE_URL=/wasm
|
|||
# Override the artifact source dir the dev symlink points at (default:
|
||||
# <repo>/tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh).
|
||||
# WASM_SRC_DIR=
|
||||
# Error tracking (Better Stack) — Sentry-format DSN; unset ⇒ nothing reported.
|
||||
# See standalone/.env.example for the full note.
|
||||
# VITE_ERRORS_DSN=
|
||||
|
||||
# --- example backend ---
|
||||
# Absolute or relative path to a single KiCad project folder to serve.
|
||||
|
|
|
|||
65
web/pnpm-lock.yaml
generated
65
web/pnpm-lock.yaml
generated
|
|
@ -101,6 +101,9 @@ importers:
|
|||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.1
|
||||
version: 1.2.4(@types/react@18.3.29)(react@18.3.1)
|
||||
'@sentry/browser':
|
||||
specifier: ^10.69.0
|
||||
version: 10.69.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.11
|
||||
version: 5.100.14(react@18.3.1)
|
||||
|
|
@ -997,6 +1000,34 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sentry/browser-utils@10.69.0':
|
||||
resolution: {integrity: sha512-e/u1Abj0zRPwR/deGZAP3GOULrsx67/XXnM5Skniqs4uxTsdNtPek1Nef0tpxwaQJYxwh6pWdhswLPPbbPOgBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@10.69.0':
|
||||
resolution: {integrity: sha512-8391tnm96YbR7b8SYfEA/NEIZuyb2r3SZrtAT0bhZtjlujcYWjo7gugQvk8sWLU9cAa/euD00eJoIoJvNfpd7Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/conventions@0.16.0':
|
||||
resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@sentry/core@10.69.0':
|
||||
resolution: {integrity: sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/feedback@10.69.0':
|
||||
resolution: {integrity: sha512-qrGz5Qaw93/IhMjlFN6uIaXeHwgHDaKGa6FkTAP6PonpkvSbGGqan6xfsENxzj9HUVoli1lZ6tMRDnt2qtSPhg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/replay-canvas@10.69.0':
|
||||
resolution: {integrity: sha512-VF6nXvSninHcc7dC1Zme0RjkC7VgRMCixs6jKaQX5zTNeqTW3dZGSefSOVv+ZteRi3hJvVORq985VjUC9Z/0+A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/replay@10.69.0':
|
||||
resolution: {integrity: sha512-uRhmNhtFGPOlM0iniVmWKAX3KVXI0le41yYK/iKdPjinT9jA3ZrmykO/Fv1v/KI5znOtwa9D6eHRnDTTMRxFrg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/query-core@5.100.14':
|
||||
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
|
||||
|
||||
|
|
@ -2589,6 +2620,40 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.61.0':
|
||||
optional: true
|
||||
|
||||
'@sentry/browser-utils@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/conventions': 0.16.0
|
||||
'@sentry/core': 10.69.0
|
||||
|
||||
'@sentry/browser@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/browser-utils': 10.69.0
|
||||
'@sentry/conventions': 0.16.0
|
||||
'@sentry/core': 10.69.0
|
||||
'@sentry/feedback': 10.69.0
|
||||
'@sentry/replay': 10.69.0
|
||||
'@sentry/replay-canvas': 10.69.0
|
||||
|
||||
'@sentry/conventions@0.16.0': {}
|
||||
|
||||
'@sentry/core@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/conventions': 0.16.0
|
||||
|
||||
'@sentry/feedback@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.69.0
|
||||
|
||||
'@sentry/replay-canvas@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.69.0
|
||||
'@sentry/replay': 10.69.0
|
||||
|
||||
'@sentry/replay@10.69.0':
|
||||
dependencies:
|
||||
'@sentry/browser-utils': 10.69.0
|
||||
'@sentry/core': 10.69.0
|
||||
|
||||
'@tanstack/query-core@5.100.14': {}
|
||||
|
||||
'@tanstack/react-query@5.100.14(react@18.3.1)':
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
140
web/standalone/src/lib/error-reporting.ts
Normal file
140
web/standalone/src/lib/error-reporting.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
4
web/standalone/src/vite-env.d.ts
vendored
4
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -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). */
|
||||
|
|
|
|||
115
web/standalone/src/wasm/terminal-error.test.ts
Normal file
115
web/standalone/src/wasm/terminal-error.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
89
web/standalone/src/wasm/terminal-error.ts
Normal file
89
web/standalone/src/wasm/terminal-error.ts
Normal 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 ?? "");
|
||||
}
|
||||
|
|
@ -9,7 +9,9 @@
|
|||
"VITE_API_BASE_URL",
|
||||
"VITE_LOCAL_PROJECTS",
|
||||
"VITE_ALLOW_USER_OVERRIDE",
|
||||
"VITE_WASM_ASSET_BASE_URL"
|
||||
"VITE_WASM_ASSET_BASE_URL",
|
||||
"VITE_ERRORS_DSN",
|
||||
"VITE_ERRORS_ENV"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue