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>
386 lines
18 KiB
TypeScript
386 lines
18 KiB
TypeScript
export const API_BASE_URL =
|
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050";
|
|
|
|
// Where the KiCad WASM artifacts are served from (no trailing slash).
|
|
// dev / same-origin: "/wasm" (flat layout, served from public/wasm by Vite).
|
|
// prod CDN: VITE_WASM_ROOT, e.g. "https://cdn.pcbjam.com/wasm".
|
|
// A cross-origin CDN works because boot.ts loads the pthread worker through a
|
|
// same-origin blob shim (see wasm/boot.ts) and the CDN sets CORP/ACAO.
|
|
// VITE_WASM_ASSET_BASE_URL is the legacy name and is still honored.
|
|
export const WASM_ROOT = (
|
|
import.meta.env.VITE_WASM_ROOT ??
|
|
import.meta.env.VITE_WASM_ASSET_BASE_URL ??
|
|
"/wasm"
|
|
).replace(/\/+$/, "");
|
|
|
|
// Per-release WASM manifest file under WASM_ROOT (e.g. "manifest-2.7.7.json").
|
|
// When set, the standalone resolves each tool's versioned, content-addressed
|
|
// folder (WASM_ROOT/<tool>/<ver>/) from it AT RUNTIME — see wasm/wasm-assets.ts,
|
|
// so a tool can be repointed after a bad deploy without rebuilding the app.
|
|
// Unset ⇒ flat layout directly under WASM_ROOT (dev / same-origin). The manifest
|
|
// is fetched uncached; the tool folders it points at are immutable + long-cached.
|
|
export const WASM_MANIFEST_FILE = import.meta.env.VITE_WASM_MANIFEST || null;
|
|
|
|
/** @deprecated Use WASM_ROOT + resolveWasmBase(). Kept for back-compat. */
|
|
export const WASM_ASSET_BASE_URL = WASM_ROOT;
|
|
|
|
// --- build identity (version badge + GPLv3 corresponding-source pointer) -------
|
|
// The standalone is GPLv3; the badge surfaces the build's tag + a link to the
|
|
// exact source. The repo commit pins the kicad + wxwidgets submodule revisions,
|
|
// so APP_GIT_SHA → github.com/.../commit/<sha> is our corresponding-source
|
|
// pointer (mirrors site/src/components/Footer.astro's BUILD_SHA). All three are
|
|
// injected at build time by scripts/deploy/build-demo.mjs; unset in a plain dev
|
|
// checkout (badge then shows "dev" → repo root).
|
|
|
|
/** Release tag for this build (e.g. "2.7.7"); shown in the version badge. */
|
|
export const APP_TAG = import.meta.env.VITE_APP_TAG || null;
|
|
|
|
/** Source commit this build was made from; the badge links to it as the GPLv3
|
|
* corresponding-source pointer. */
|
|
export const APP_GIT_SHA = import.meta.env.VITE_GIT_SHA || null;
|
|
|
|
/** Public source repository for the GPL editor (no trailing slash). */
|
|
export const REPO_URL = (
|
|
import.meta.env.VITE_REPO_URL || "https://github.com/PCBJam/pcbjam"
|
|
).replace(/\/+$/, "");
|
|
|
|
/** Marketing / landing page (no trailing slash). The version badge links here so
|
|
* someone in the editor can reach the product page. */
|
|
export const LANDING_URL = (
|
|
import.meta.env.VITE_LANDING_URL || "https://pcbjam.com"
|
|
).replace(/\/+$/, "");
|
|
|
|
/**
|
|
* The companion management app's origin (no trailing slash), e.g.
|
|
* "https://app.pcbjam.com". When set (the backed editor deploy), non-editor
|
|
* surfaces — home, project overview, mgmt-only paths — redirect there; see
|
|
* lib/redirect.ts (standalone-hardening 0006). Unset (dev / demo) keeps every
|
|
* route local.
|
|
*/
|
|
export const APP_URL =
|
|
(import.meta.env.VITE_APP_URL || "").replace(/\/+$/, "") || null;
|
|
|
|
/**
|
|
* Where the in-editor waitlist form POSTs. The demo is a fully static deploy with
|
|
* no backend, so it cross-posts to the landing site's serverless endpoint (which
|
|
* sends CORS for this origin). Same JSON contract as site/src/pages/api/waitlist.ts.
|
|
* Targets the canonical www host. The apex now serves the same Pages project
|
|
* rather than redirecting, so either would work — but keep www: it is the
|
|
* canonical host, and a CORS preflight cannot follow a redirect, so pointing at
|
|
* a host that might ever redirect is a latent breakage.
|
|
*/
|
|
export const WAITLIST_URL =
|
|
import.meta.env.VITE_WAITLIST_URL || "https://www.pcbjam.com/api/waitlist";
|
|
|
|
/**
|
|
* Plausible analytics (privacy-friendly, cookieless). Off unless a script URL is
|
|
* set (plain dev/checkout stays untracked). `PLAUSIBLE_SRC` is the shared site's
|
|
* pa-*.js script URL — the pa- id keys the dashboard, no data-domain needed.
|
|
* Point it at a self-hosted/proxied copy — e.g. on cdn.pcbjam.com — if
|
|
* plausible.io won't load under the demo's COEP `require-corp`. See main.tsx
|
|
* for the injection.
|
|
*/
|
|
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.
|
|
* "static" — a read-only example gallery published to a CDN as a
|
|
* manifest + file bytes (no backend), e.g. the
|
|
* demo.pcbjam.com gallery. Editor saves download to local.
|
|
* Needs VITE_PROJECT_MANIFEST_URL. See lib/project-source.ts.
|
|
*/
|
|
export type ProjectSourceKind = "remote" | "static";
|
|
export const PROJECT_SOURCE_KIND: ProjectSourceKind =
|
|
import.meta.env.VITE_PROJECT_SOURCE === "static" ? "static" : "remote";
|
|
|
|
/** Full URL of the static gallery manifest, e.g.
|
|
* "https://cdn.pcbjam.com/content/2.7.7/manifest.json". Required for "static". */
|
|
export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || null;
|
|
|
|
/**
|
|
* When "idb", loaded folders import into a browser-local (IndexedDB) project
|
|
* with its own /p/:slug URL — editable, persistent across visits, exported via
|
|
* Download .zip / per-file — instead of the in-page File System Access flow.
|
|
* The local store is layered (composite) alongside the configured remote/gallery
|
|
* source. Off by default (plain dev keeps disk write-back); build-demo.mjs turns
|
|
* it on for the demo. See lib/idb-project-store.ts + lib/project-source.ts.
|
|
*/
|
|
export const LOCAL_PROJECTS_ENABLED =
|
|
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
|
|
|
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
|
import { sessionIdentity } from "@/lib/session-identity";
|
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
|
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
|
import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-source";
|
|
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
|
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
|
import type { LibsSource } from "@/wasm/libs/source";
|
|
import {
|
|
withSpikeWritableFpLib,
|
|
withSpikeWritableLib,
|
|
} from "@/wasm/libs/spike-writable";
|
|
import { staticLibsSource } from "@/wasm/libs/static-source";
|
|
import {
|
|
syncedLibsSource,
|
|
syncedScopeLibsSource,
|
|
} from "@/wasm/libs/synced-source";
|
|
|
|
/**
|
|
* Which Yjs collab provider this deployment uses (one active per env), and its
|
|
* endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps
|
|
* the cross-tab-only behavior with no backend. Built here at the composition
|
|
* root and passed into `startKicadCollab`, so `wasm/collab` stays env-agnostic.
|
|
*/
|
|
export function yjsProviderConfig(): ProviderConfig {
|
|
const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind;
|
|
const token = import.meta.env.VITE_YJS_TOKEN;
|
|
return {
|
|
kind,
|
|
endpoint: import.meta.env.VITE_YJS_ENDPOINT,
|
|
params: token ? { token } : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Where a backend project's DOCUMENT content lives (per deployment, not per
|
|
* route — /p/<project> URLs behave the same either way):
|
|
*
|
|
* "api" — file bytes are fetched from the REST backend and a user save is
|
|
* uploaded back to it (the Y.Doc, when collab is on, mirrors the file).
|
|
* "ydoc" — the collab room is the source of truth: when it holds the document
|
|
* it is materialized client-side (docToFile) instead of fetching the
|
|
* file, and saves stay in MEMFS (the provider persists the doc). The
|
|
* REST backend still serves project metadata + sibling files, and the
|
|
* file fetch remains the first-open fallback that seeds the room.
|
|
*/
|
|
export type DocSource = "api" | "ydoc";
|
|
|
|
export function docSourceConfig(): DocSource {
|
|
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
|
|
}
|
|
|
|
/**
|
|
* Which library source backs the editor's symbol chooser (env `VITE_LIBS_SOURCE`):
|
|
* "remote" (default) — fetch from the backend at `API_BASE_URL` over the
|
|
* shared contract (origins served by the registry, or the
|
|
* GPL example backend).
|
|
* "synced" — remote listing + per-lib r2-idb-sync stacks (IDB cache,
|
|
* realtime peer updates + editor reload). Also switches
|
|
* `libsSourceForLib` to the single-lib synced source.
|
|
* "static" — built-in offline example symbols (no backend).
|
|
* "off" — disable libs (empty sym-lib-table).
|
|
*/
|
|
/**
|
|
* `?user=`/`?libowner=` identity overrides are honored only when the BUILD
|
|
* opts in (VITE_ALLOW_USER_OVERRIDE=1 — dev servers and e2e harnesses set it);
|
|
* production builds never do, so a user can't pick an arbitrary identity via
|
|
* the URL (collab-presence 0009).
|
|
*/
|
|
const USER_OVERRIDE_ALLOWED =
|
|
import.meta.env.VITE_ALLOW_USER_OVERRIDE === "1";
|
|
|
|
/**
|
|
* The current user slug — sent on every request via USER_HEADER and doubling
|
|
* as the personal scope slug. Precedence (collab-presence 0009):
|
|
* `?user=`/`?libowner=` when the build allows overrides (e2e isolation) →
|
|
* the authenticated session user (lib/session-identity.ts, resolved from
|
|
* /api/me during tool boot) → `VITE_USER`/`VITE_LIBS_OWNER` → a stable local
|
|
* default.
|
|
*/
|
|
export function userSlug(): string {
|
|
if (USER_OVERRIDE_ALLOWED && typeof window !== "undefined") {
|
|
const q = new URLSearchParams(window.location.search);
|
|
const p = q.get("user") ?? q.get("libowner");
|
|
if (p) return p;
|
|
}
|
|
const session = sessionIdentity();
|
|
if (session) return session.slug;
|
|
return (
|
|
import.meta.env.VITE_USER ?? import.meta.env.VITE_LIBS_OWNER ?? "local-user"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The local user's presence identity (collab-presence 0001/0009): id is the
|
|
* slug (session slug once /api/me resolves), name is the session display name
|
|
* (or email) when the session identity is the active one, else the slug
|
|
* verbatim; color is the deterministic palette hash — the live room replaces
|
|
* it with the nth-in-room claim (presence.ts).
|
|
*/
|
|
export function presenceUser(): PresenceUser {
|
|
const slug = userSlug();
|
|
const session = sessionIdentity();
|
|
const name = session && session.slug === slug ? session.name : slug;
|
|
return { id: slug, name, color: colorForUser(slug) };
|
|
}
|
|
|
|
/** Author identity stamped onto comments. */
|
|
export interface CommentAuthor {
|
|
/** Slug — the IDENTITY key (colors, "is this mine?"). Never a display name. */
|
|
id: string;
|
|
/** Display name; falls back to the slug when no session identity is loaded. */
|
|
name: string;
|
|
/** Only present for a real authenticated session. */
|
|
email?: string;
|
|
}
|
|
|
|
/**
|
|
* Who to attribute a new comment to. Same identity as `presenceUser()`, plus the
|
|
* email, and kept separate because comments DENORMALIZE these at write time
|
|
* (comments-wire.ts) whereas presence re-broadcasts them live.
|
|
*/
|
|
export function commentAuthor(): CommentAuthor {
|
|
const slug = userSlug();
|
|
const session = sessionIdentity();
|
|
const mine = session && session.slug === slug ? session : null;
|
|
return { id: slug, name: mine?.name ?? slug, email: mine?.email };
|
|
}
|
|
|
|
/**
|
|
* DEV-TIME presence style tuner (collab-presence): VITE_PRESENCE_TUNER=1 mounts
|
|
* a floating panel that live-patches the wasm overlay style
|
|
* (kicadCollabSetStyle) — shapes, widths, alphas, label placement, palettes —
|
|
* so we can pick the shipped look. Off (and tree-shaken) in normal builds.
|
|
*/
|
|
export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1";
|
|
|
|
/**
|
|
* The active scope (first URL segment) for API calls. Mirrors how `userSlug()`
|
|
* reads the URL, so the source layer scopes requests without threading scope
|
|
* through every signature. Falls back to `?scope=` / `VITE_SCOPE` / the personal
|
|
* scope (the user slug). Client-only scopes (e.g. `@local`) are routed by the
|
|
* project source and never sent to a backend.
|
|
*/
|
|
export function currentScope(): string {
|
|
if (typeof window !== "undefined") {
|
|
const seg = window.location.pathname.split("/").filter(Boolean)[0];
|
|
if (seg && seg !== "projects" && seg !== "libs") {
|
|
return decodeURIComponent(seg);
|
|
}
|
|
const q = new URLSearchParams(window.location.search).get("scope");
|
|
if (q) return q;
|
|
}
|
|
return import.meta.env.VITE_SCOPE ?? userSlug();
|
|
}
|
|
|
|
/** Full URL of the CDN libs top manifest (required for VITE_LIBS_SOURCE=cdn),
|
|
* e.g. https://cdn.pcbjam.com/libs/kicad/9.0.0/manifest.json. The full default
|
|
* KiCad symbol+footprint set, served read-only as version-pinned static origins
|
|
* (IDB-cached). See wasm/libs/cdn-source.ts + docs/features/r2-idb-sync. */
|
|
export const CDN_LIBS_MANIFEST_URL =
|
|
import.meta.env.VITE_LIBS_MANIFEST_URL || null;
|
|
|
|
/** Full URL of the CDN 3D-models top manifest, e.g.
|
|
* https://cdn.pcbjam.com/libs/kicad-models/10.0.0/manifest.json. Bodies are
|
|
* fetched lazily per board (sparse layers) and cached in IDB — never bulk
|
|
* synced. Unset ⇒ the 3D viewer renders bare boards (no component models).
|
|
* See wasm/libs/models-source.ts + docs/features/3d-models. */
|
|
export const CDN_MODELS_MANIFEST_URL =
|
|
import.meta.env.VITE_MODELS_MANIFEST_URL || null;
|
|
|
|
/** The 3D model source for a tool boot (null ⇒ models disabled). One instance
|
|
* per call — WasmTool keeps a single instance per boot like the libs source. */
|
|
export function modelsSourceConfig(): Model3dSource | null {
|
|
return CDN_MODELS_MANIFEST_URL
|
|
? cdnModelsSource(CDN_MODELS_MANIFEST_URL)
|
|
: null;
|
|
}
|
|
|
|
export function libsSourceConfig(projectId?: string): LibsSource | null {
|
|
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
|
// "local" is the placeholder id for launches with no real backend project
|
|
// (local folder, tool grid, lib-scoped open). It is NOT a project on the
|
|
// backend, so don't send it as the project header — a registry server would
|
|
// scope its lib resolution (project-pinned mirrors) to a non-existent project
|
|
// and return nothing. Real backend projects pass their uuid and keep scoping.
|
|
const project = projectId && projectId !== "local" ? projectId : undefined;
|
|
const base =
|
|
kind === "off"
|
|
? null
|
|
: kind === "static"
|
|
? staticLibsSource()
|
|
: kind === "cdn"
|
|
? CDN_LIBS_MANIFEST_URL
|
|
? cdnLibsSource(CDN_LIBS_MANIFEST_URL)
|
|
: staticLibsSource() // misconfigured cdn ⇒ offline fallback
|
|
: kind === "synced"
|
|
? // Remote listing + per-lib sync stacks (IDB cache, realtime,
|
|
// editor reload on peer edits) — see syncedScopeLibsSource.
|
|
syncedScopeLibsSource(
|
|
remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project),
|
|
{
|
|
apiBase: API_BASE_URL,
|
|
scope: currentScope(),
|
|
user: userSlug(),
|
|
project,
|
|
log: (m) => console.log(m),
|
|
},
|
|
)
|
|
: remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project);
|
|
|
|
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
|
|
// editor save path works with no backend (a dev/test aid). The real remote
|
|
// write path (0004-C) needs no flag — boot ensures a user lib via createLib.
|
|
// 0009-S spike: `?fpwrite=1` does the same for a writable FOOTPRINT lib.
|
|
if (typeof window !== "undefined") {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("fpwrite") === "1") {
|
|
return withSpikeWritableFpLib(base, (m) => console.log(m));
|
|
}
|
|
if (params.get("libwrite") === "1") {
|
|
return withSpikeWritableLib(base, (m) => console.log(m));
|
|
}
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* The libs source for a single backend library opened scoped to itself
|
|
* (`/l/<libId>/<tool>`). With `VITE_LIBS_SOURCE=synced` this is the r2-idb-sync
|
|
* bridge (`syncedLibsSource`, per-lib IDB cache + realtime); otherwise it's the
|
|
* existing per-item network path wrapped in `scopedLibsSource`. Falls back to the
|
|
* network path when the lib can't be synced.
|
|
*/
|
|
export function libsSourceForLib(
|
|
libId: string,
|
|
projectId?: string,
|
|
): LibsSource | null {
|
|
const project = projectId && projectId !== "local" ? projectId : undefined;
|
|
if (import.meta.env.VITE_LIBS_SOURCE === "synced") {
|
|
return syncedLibsSource(libId, {
|
|
apiBase: API_BASE_URL,
|
|
scope: currentScope(),
|
|
user: userSlug(),
|
|
project,
|
|
log: (m) => console.log(m),
|
|
});
|
|
}
|
|
const base = libsSourceConfig(projectId);
|
|
return base ? scopedLibsSource(base, libId) : null;
|
|
}
|