Serve the standalone editor under a CMMS sub-path with no change to
@pcbjam/shared or the wasm/C++ side. No-op for a root deploy (BASE_URL='/').
- main.tsx: <BrowserRouter basename={import.meta.env.BASE_URL}>
- lib/base-path.ts (new): stripBase() for raw location.pathname reads,
hardNav() for the cross-document window.location.assign() navs (the
process-global wasm runtime means a tool switch / File->Quit is a full
page load, and @pcbjam/shared's projectPath() is origin-absolute)
- config.ts currentScope(): stripBase() before splitting for the scope segment
- WasmTool.tsx quit-path: stripBase() for the projects/libs segment test
- tool-navigation.ts (x2), NewFileDialog.tsx, ProjectView.tsx, quit-hook.ts:
window.location.assign(projectPath(...)) -> hardNav(...)
- .env.production: VITE_API_BASE_URL=/pcb-project-api.php, PROJECT_SOURCE=remote,
DOC_SOURCE=api, LIBS_SOURCE=off, WASM_ROOT=/vendor/pcbjam/wasm,
YJS_PROVIDER=broadcastchannel; APP_URL/USER/SCOPE unset
Build: vite build --base=/vendor/pcbjam/
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
429 lines
19 KiB
TypeScript
429 lines
19 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 { stripBase } from "@/lib/base-path";
|
|
import { sessionIdentity } from "@/lib/session-identity";
|
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
|
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
|
import {
|
|
cdnModelsSource,
|
|
registryModelsSource,
|
|
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") {
|
|
// stripBase: under the CMMS sub-path mount the scope is the first segment
|
|
// AFTER /vendor/pcbjam/, not "vendor". No-op for a root deploy.
|
|
const seg = stripBase(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.
|
|
*
|
|
* `VITE_MODELS_SOURCE` picks the backing:
|
|
* - "cdn" (default whenever VITE_MODELS_MANIFEST_URL is set — today's
|
|
* behavior): the published CDN blob layout.
|
|
* - "registry": the closed registry's kind='model3d' origin libs (chunked
|
|
* packages3D ingest), served as sparse sync layers; the boot payload's
|
|
* lib list + stack resolves feed it so a preloaded boot makes zero model
|
|
* listing/resolve requests.
|
|
*/
|
|
export function modelsSourceConfig(
|
|
preload?: LibsBootPreload,
|
|
): Model3dSource | null {
|
|
const mode =
|
|
import.meta.env.VITE_MODELS_SOURCE ??
|
|
(CDN_MODELS_MANIFEST_URL ? "cdn" : "off");
|
|
if (mode === "registry") {
|
|
return registryModelsSource({
|
|
apiBase: API_BASE_URL,
|
|
scope: currentScope(),
|
|
preloaded: preload,
|
|
});
|
|
}
|
|
return mode === "cdn" && CDN_MODELS_MANIFEST_URL
|
|
? cdnModelsSource(CDN_MODELS_MANIFEST_URL)
|
|
: null;
|
|
}
|
|
|
|
/** The boot payload's lib hand-over (see wasm/libs/synced-source.ts). */
|
|
export interface LibsBootPreload {
|
|
libs: import("@/wasm/libs/synced-source").PreloadedLibDto[];
|
|
stacks: Record<
|
|
string,
|
|
import("@pcbjam/shared").SyncStackDescriptor | null
|
|
>;
|
|
}
|
|
|
|
export function libsSourceConfig(
|
|
projectId?: string,
|
|
preload?: LibsBootPreload,
|
|
): 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,
|
|
preloaded: preload,
|
|
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;
|
|
}
|