cmms-embed: sub-path mount support (/vendor/pcbjam/)

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>
This commit is contained in:
AI_Assistant 2026-09-03 22:49:31 +10:00
commit 84dd40702c
9 changed files with 112 additions and 8 deletions

View file

@ -0,0 +1,28 @@
# CMMS embed (cmms-embed branch). Consumed by `vite build` (mode=production).
# Pair with `vite build --base=/vendor/pcbjam/`. All values are public (they ship
# in the bundle) — no secrets here.
# The PHP adapter. Relative so it resolves against the CMMS origin. The adapter
# handles PATH_INFO, so /api/scopes/... and /api/me land as
# /pcb-project-api.php/api/scopes/... and /pcb-project-api.php/api/me.
VITE_API_BASE_URL=/pcb-project-api.php
# Projects come from the REST adapter above; file bytes fetched + saves PUT back.
VITE_PROJECT_SOURCE=remote
VITE_DOC_SOURCE=api
# No library backend for v1 — the editor boots with empty sym/fp-lib-tables.
VITE_LIBS_SOURCE=off
# Flat WASM layout, served static by Apache under the mount (no per-tool subdir,
# VITE_WASM_MANIFEST intentionally unset). Files: kicad_editor.{js,wasm}, wx.js,
# wx-dom.js, images.tar.gz.
VITE_WASM_ROOT=/vendor/pcbjam/wasm
# Cross-tab only; no collab server.
VITE_YJS_PROVIDER=broadcastchannel
# VITE_APP_URL deliberately UNSET — with it set, every non-editor route would
# redirect away from the mount (lib/redirect.ts).
# VITE_USER / VITE_SCOPE deliberately UNSET — identity comes from
# /pcb-project-api.php/api/me, scope from the URL path segment after the mount.

View file

@ -7,6 +7,7 @@ import {
} from "@pcbjam/shared";
import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
@ -124,7 +125,7 @@ export function NewFileDialog({
// A home-created project is browser-local (@local scope); an in-project new
// file keeps the current scope. The tool is inferred from the file's ext.
const scope = homeMode ? LOCAL_SCOPE : currentScope();
window.location.assign(projectPath(scope, slug, finalName));
hardNav(projectPath(scope, slug, finalName));
} catch (e) {
setBusy(false);
setError(e instanceof Error ? e.message : String(e));

View file

@ -20,6 +20,7 @@ import {
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
import { stripBase } from "@/lib/base-path";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
import { useThemeValue } from "@/lib/theme";
@ -629,7 +630,10 @@ export function WasmTool({
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
// management app, so quit goes straight there (one hop instead of letting
// App.tsx's 0006 redirect bounce it).
const segments = win.location.pathname.split("/").filter(Boolean);
// stripBase: segments are relative to the mount, so segments[1] is
// "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
// (no mount prefix) — installQuitHook's hardNav() re-adds it.
const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
const exitPath =
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
const removeQuitHook = installQuitHook(win, {

View file

@ -6,6 +6,8 @@
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way.
import { hardNav } from "@/lib/base-path";
let activeQuitHook: (() => void) | undefined;
let quitHandled = false;
@ -93,7 +95,9 @@ export function installQuitHook(
// wasm stack has unwound.
setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
win.location.assign(opts.exitUrl);
// hardNav: re-add the CMMS mount prefix to the app-absolute exit path
// (a full APP_URL redirect target passes through untouched).
hardNav(opts.exitUrl, win);
}, 0);
};

View file

@ -7,6 +7,7 @@ import {
toolSchema,
type Tool,
} from "@pcbjam/shared";
import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { memfsProjectDir } from "@/wasm/constants";
@ -175,7 +176,7 @@ export function installToolNavigationHook(
await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
hardNav(url, win);
} catch (e) {
pendingCreate = null;
opts.log(
@ -196,7 +197,7 @@ export function installToolNavigationHook(
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
hardNav(url, win);
return true;
};

View file

@ -0,0 +1,57 @@
/**
* Sub-path mount support (cmms-embed fork).
*
* The CMMS serves this SPA under `/vendor/pcbjam/`, built with
* `vite build --base=/vendor/pcbjam/` so `import.meta.env.BASE_URL` is that
* prefix (always a leading + trailing slash; "/" for a root deploy).
*
* `<BrowserRouter basename={import.meta.env.BASE_URL}>` already makes
* `<Link>` / `navigate()` / `useLocation()` mount-relative. This module covers
* the two things the router can't:
* 1. code that reads `window.location.pathname` raw (currentScope, WasmTool's
* quit-path), and
* 2. the hard cross-document `window.location.assign()` navigations the
* WASM runtime is process-global, so a tool switch / File→Quit / new file
* is a full page load, not a router transition, and the app paths handed
* to it (`@pcbjam/shared` `projectPath()` etc.) are origin-absolute.
*
* Nothing here touches `@pcbjam/shared` it also compiles for the backend,
* where `import.meta` does not exist.
*/
/** `import.meta.env.BASE_URL` without its trailing slash ("" for a root deploy). */
export const BASE_PREFIX = import.meta.env.BASE_URL.replace(/\/$/, "");
/**
* `pathname` with the mount prefix removed; the result always starts with "/".
* A pathname outside the mount (or a root deploy) is returned unchanged.
*/
export function stripBase(pathname: string): string {
if (
BASE_PREFIX &&
(pathname === BASE_PREFIX || pathname.startsWith(`${BASE_PREFIX}/`))
) {
const rest = pathname.slice(BASE_PREFIX.length);
return rest === "" ? "/" : rest;
}
return pathname;
}
/**
* `window.location.assign()` to an app-absolute path (e.g. from
* `projectPath()`), re-adding the mount prefix. A full URL (has a scheme or is
* protocol-relative) passes through untouched so an `APP_URL` redirect target
* is never corrupted.
*
* `win` is injectable for the same reason the tool-navigation / quit hooks take
* a window: test seams and the wx-port's frame window.
*/
export function hardNav(
appPath: string,
win: { location: { assign(url: string): void } } = window,
): void {
const url = /^([a-z][a-z0-9+.-]*:)?\/\//i.test(appPath)
? appPath
: BASE_PREFIX + appPath;
win.location.assign(url);
}

View file

@ -133,6 +133,7 @@ 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";
@ -282,7 +283,11 @@ export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1
*/
export function currentScope(): string {
if (typeof window !== "undefined") {
const seg = window.location.pathname.split("/").filter(Boolean)[0];
// 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);
}

View file

@ -38,7 +38,10 @@ const queryClient = new QueryClient({
// instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{/* basename = the vite `base` (import.meta.env.BASE_URL) so the CMMS
sub-path mount (/vendor/pcbjam/) is transparent to <Link>/navigate();
the hard location.assign() navs use hardNav() (lib/base-path.ts). */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</QueryClientProvider>,

View file

@ -17,6 +17,7 @@ import {
Trash2,
} from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { hardNav } from "@/lib/base-path";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip";
@ -108,7 +109,7 @@ export function ProjectView() {
// scoped with a full reload; document tools create a templated file first.
const launchTool = (tool: Tool) => {
if (FILELESS_TOOLS.has(tool)) {
window.location.assign(projectToolPath(scope, slug, tool));
hardNav(projectToolPath(scope, slug, tool));
} else {
setNewFileTool(tool);
}