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:
parent
b672c54189
commit
b6a77ee93c
80 changed files with 671 additions and 2992 deletions
10
web/backend/.env.example
Normal file
10
web/backend/.env.example
Normal 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
23
web/backend/package.json
Normal 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
175
web/backend/src/server.ts
Normal 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
11
web/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"verbatimModuleSyntax": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in a new issue