refactor(web): split into GPL standalone editor + thin reference backend

Reduce web/ to a single generic editor app plus a minimal example backend,
moving all project-specific/app code out to the closed root repo:

- apps/frontend -> standalone (@pcbjam/standalone): the GPL editor. Strips the
  project-management UI (ProjectsPage/ProjectDetailPage/UploadDropzone and the
  write half of lib/api.ts). WasmTool now takes fetchBytes + assetBaseUrl as
  injected props (decoupled from the API client) so it can be driven by either
  a backend or a local folder. New HomePage (local-folder loader + backend
  project list) and read-only ProjectView. BroadcastChannel collab unchanged.
- backend (@pcbjam/backend-example): thin Fastify+ts-rest reference impl of the
  @pcbjam/shared contract — serves a single project off the local filesystem
  (PROJECT_DIR), no DB/auth/uploads.
- packages/contract -> web/pcbjam-shared (git submodule, MIT @pcbjam/shared).
- Remove apps/server, packages/storage, packages/contract, docker-compose
  (server + storage move to the closed root repo; preserved via subtree
  branches). Rewrite pnpm-workspace/turbo/.env for the frontend-only layout.
- Add tests/fixtures/demo so the editor + example backend run out of the box.

Standalone typechecks + builds; backend serves the contract end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-09 12:35:46 +02:00
commit b6a77ee93c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
80 changed files with 671 additions and 2992 deletions

10
web/backend/.env.example Normal file
View file

@ -0,0 +1,10 @@
# Thin reference backend. Copy to backend/.env.
# Absolute or relative path to the single KiCad project folder to serve.
PROJECT_DIR=../../tests/fixtures/demo
# Port to listen on (the standalone's VITE_API_BASE_URL must match this).
PORT=3060
# Browser origin allowed to call this backend (the Vite dev server).
CORS_ORIGIN=http://localhost:3048

23
web/backend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@pcbjam/backend-example",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@pcbjam/shared": "workspace:*",
"@ts-rest/fastify": "^3.52.1",
"fastify": "^4.29.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

175
web/backend/src/server.ts Normal file
View file

@ -0,0 +1,175 @@
// Thin REFERENCE backend for the @pcbjam/shared contract.
//
// It serves a SINGLE KiCad project straight off the local filesystem
// (PROJECT_DIR) with no database, no auth, and no uploads — just enough for the
// standalone editor to enumerate the project, read its file tree, and stream
// file bytes. It exists to (a) let the GPL editor run end-to-end on its own and
// (b) document the minimum a "real" backend must implement. Collaboration is
// browser-tab only (BroadcastChannel) and needs nothing from the server.
import { createReadStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import cors from "@fastify/cors";
import Fastify from "fastify";
import { initServer } from "@ts-rest/fastify";
import {
contract,
type Project,
type ProjectFile,
} from "@pcbjam/shared";
const PROJECT_DIR = path.resolve(
process.cwd(),
process.env.PROJECT_DIR ?? "./project",
);
const PORT = Number(process.env.PORT ?? 3060);
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:3048";
const TEXT_EXT = new Set([
".kicad_pcb",
".kicad_sch",
".kicad_pro",
".kicad_sym",
".kicad_mod",
".kicad_dru",
".kicad_wks",
".net",
".txt",
".csv",
".json",
]);
function guessContentType(relPath: string): string {
const ext = path.posix.extname(relPath).toLowerCase();
return TEXT_EXT.has(ext)
? "text/plain; charset=utf-8"
: "application/octet-stream";
}
/** Reject paths that escape PROJECT_DIR (traversal guard). */
function safeJoin(relPath: string): string {
const normalized = path.posix
.normalize(relPath.replace(/\\/g, "/"))
.replace(/^(\.\.(\/|$))+/, "")
.replace(/^\/+/, "");
const abs = path.resolve(PROJECT_DIR, normalized);
if (abs !== PROJECT_DIR && !abs.startsWith(PROJECT_DIR + path.sep)) {
throw new Error(`path escapes project: ${relPath}`);
}
return abs;
}
const SLUG = (path.basename(PROJECT_DIR) || "project")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "project";
// Stable-per-process ids (no DB; the editor only needs them to be unique).
const PROJECT_ID = randomUUID();
const fileIds = new Map<string, string>();
function fileId(relPath: string): string {
let id = fileIds.get(relPath);
if (!id) {
id = randomUUID();
fileIds.set(relPath, id);
}
return id;
}
async function walk(dir: string, prefix = ""): Promise<ProjectFile[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const out: ProjectFile[] = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue; // skip dotfiles
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...(await walk(abs, rel)));
} else if (entry.isFile()) {
const st = await fs.stat(abs);
out.push({
id: fileId(rel),
projectId: PROJECT_ID,
path: rel,
size: st.size,
contentType: guessContentType(rel),
createdAt: st.birthtime.toISOString(),
updatedAt: st.mtime.toISOString(),
});
}
}
return out;
}
async function project(): Promise<Project> {
const st = await fs.stat(PROJECT_DIR);
return {
id: PROJECT_ID,
slug: SLUG,
name: path.basename(PROJECT_DIR) || SLUG,
createdAt: st.birthtime.toISOString(),
updatedAt: st.mtime.toISOString(),
};
}
async function main(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 });
await app.register(cors, {
origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","),
});
app.get("/health", async () => ({ ok: true }));
const s = initServer();
const router = s.router(contract, {
listProjects: async () => ({ status: 200, body: [await project()] }),
getProject: async ({ params }) => {
if (params.project !== SLUG) {
return { status: 404 as const, body: { message: "project not found" } };
}
return {
status: 200 as const,
body: { project: await project(), files: await walk(PROJECT_DIR) },
};
},
listFiles: async ({ params }) => {
if (params.project !== SLUG) {
return { status: 404 as const, body: { message: "project not found" } };
}
return { status: 200 as const, body: await walk(PROJECT_DIR) };
},
});
await app.register(s.plugin(router));
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
app.get<{ Params: { project: string; "*": string } }>(
"/api/projects/:project/files/*",
async (req, reply) => {
if (req.params.project !== SLUG) {
return reply.code(404).send({ message: "project not found" });
}
let abs: string;
try {
abs = safeJoin(req.params["*"]);
} catch {
return reply.code(400).send({ message: "invalid path" });
}
const st = await fs.stat(abs).catch(() => null);
if (!st?.isFile()) {
return reply.code(404).send({ message: "file not found" });
}
reply.header("Content-Type", guessContentType(req.params["*"]));
reply.header("Content-Length", st.size);
return reply.send(createReadStream(abs));
},
);
await app.listen({ port: PORT, host: "0.0.0.0" });
app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

11
web/backend/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"],
"moduleResolution": "Bundler",
"noEmit": true,
"verbatimModuleSyntax": false
},
"include": ["src"]
}