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

4
.gitmodules vendored
View file

@ -6,3 +6,7 @@
path = wxwidgets
url = git@github.com:emergence-engineering/wxWidgets.git
branch = wasm-port
[submodule "web/pcbjam-shared"]
path = web/pcbjam-shared
url = git@github.com:emergence-engineering/pcbjam-shared.git
branch = main

View file

@ -1,29 +1,30 @@
# ---------------------------------------------------------------------------
# Copy to web/.env and adjust as needed. docker-compose and the server both
# read this file.
# This is a standalone, frontend-only workspace: the `standalone` editor app
# and an example `backend`. Each app reads its OWN .env — see:
# standalone/.env.example (Vite frontend)
# backend/.env.example (thin reference backend)
# Copy each to a sibling `.env`. The values below are only a convenience
# overview; turbo passes these through to the apps.
# ---------------------------------------------------------------------------
# --- Postgres (docker-compose) ---
POSTGRES_USER=kicad
POSTGRES_PASSWORD=kicad
POSTGRES_DB=kicad_web
# Non-default host port to avoid colliding with a local Postgres on 5432.
POSTGRES_PORT=54329
# --- Server ---
PORT=3050
# Connection string MUST match the POSTGRES_* values + port above.
DATABASE_URL=postgres://kicad:kicad@localhost:54329/kicad_web
# Where uploaded project file bytes are stored by the local-disk FileStorage.
STORAGE_ROOT=./.data/storage
# Owner namespace used while there is no auth (seeded on first migrate).
DEFAULT_OWNER_SLUG=default
# Browser origin allowed to call the API (the Vite dev server, non-default port).
CORS_ORIGIN=http://localhost:3048
# --- Frontend (Vite, must be VITE_-prefixed) ---
VITE_API_BASE_URL=http://localhost:3050
# SAME-ORIGIN: artifacts are smart-copied into public/wasm on `dev` and served by
# Vite at /wasm. Required because KiCad WASM pthread workers can't be cross-origin.
# For prod, set to a CDN URL whose origin also satisfies the worker/COEP rules.
# --- standalone editor (Vite, must be VITE_-prefixed) ---
# Backend implementing the @pcbjam/shared contract (the example backend, or any
# conforming backend such as the closed app's server). Leave unset to use only
# the local-folder loader.
VITE_API_BASE_URL=http://localhost:3060
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required
# because KiCad WASM pthread workers cannot be created cross-origin; on `dev` the
# artifacts are symlinked into standalone/public/wasm and served by Vite at /wasm.
# For prod, set to an absolute URL whose origin also satisfies the COEP rules.
VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh).
# WASM_SRC_DIR=
# --- example backend ---
# Absolute or relative path to a single KiCad project folder to serve.
PROJECT_DIR=../../tests/fixtures/demo
# Port the backend listens on (the standalone's VITE_API_BASE_URL must match).
PORT=3060
# Browser origin allowed to call the backend (the Vite dev server).
CORS_ORIGIN=http://localhost:3048

View file

@ -1,116 +1,83 @@
# KiCad Web
# PCBJam Web — standalone editor (GPL)
Single web app to create/open KiCad projects, upload files, and open them in the
WASM tools (pcbnew / eeschema / calculator) by URL:
A self-contained, GPL web app that opens KiCad projects in the WASM tools
(pcbnew / eeschema / pl_editor / …) — from a local folder, or from any backend
that implements the MIT [`@pcbjam/shared`](./pcbjam-shared) contract. It opens a
tool by URL:
```
/p/<project>/<tool>/<file-path> e.g. /p/project5/pcbnew/nyak.kicad_pcb
/p/<project>/<tool>/<file-path> e.g. /p/demo/pcbnew/nyak.kicad_pcb
```
Design + decisions: [`../docs/features/web-init/0001-web-app-spec.md`](../docs/features/web-init/0001-web-app-spec.md).
This workspace contains **only** the generic editor and a thin reference
backend. All project-specific concerns (accounts, project management, uploads,
auth) live in the separate closed application, which reuses this editor by
hosting it standalone and redirecting to it (it must not link the GPL editor).
## Stack
- **Monorepo**: pnpm + turbo
- **Frontend**: Vite + React + TypeScript + shadcn/ui (`apps/frontend`)
- **Backend**: Fastify + ts-rest + Zod (`apps/server`)
- **DB**: Postgres + Drizzle (project/file metadata)
- **Storage**: pluggable `FileStorage` (local disk now, S3 later) (`packages/storage`)
- **Shared types**: ts-rest contract + Zod (`packages/contract`)
## Layout
```
web/
├── apps/
│ ├── frontend/ # Vite React app
│ └── server/ # Fastify API + WASM static + Drizzle
└── packages/
├── contract/ # ts-rest contract + Zod schemas (FE + BE share this)
└── storage/ # FileStorage interface + LocalDiskStorage
├── standalone/ # @pcbjam/standalone — the GPL editor (Vite + React)
├── backend/ # @pcbjam/backend-example — thin reference @pcbjam/shared impl
└── pcbjam-shared/ # @pcbjam/shared — the FE↔BE contract (git submodule, MIT)
```
- **Editor**: Vite + React + TypeScript. Boots a tool directly in the document
(no iframe), syncs the project tree into MEMFS, drives File→Open, and runs
same-tab collaboration over BroadcastChannel.
- **Example backend**: Fastify + ts-rest serving a single project off the local
filesystem (`PROJECT_DIR`). No DB, no auth, no uploads — the minimum the editor
needs, and a worked example of the contract.
## Quick start
```bash
cd web
cp .env.example .env # Postgres host port defaults to 54329 (non-default)
pnpm install
git submodule update --init web/pcbjam-shared # if not already populated
pnpm db:up # start Postgres (docker compose)
pnpm db:migrate # apply migrations + seed the default owner
cp standalone/.env.example standalone/.env
cp backend/.env.example backend/.env # PROJECT_DIR=../../tests/fixtures/demo
pnpm dev # turbo: server :3050 + frontend :3048
pnpm dev # turbo: backend :3060 + editor :3048
```
Open http://localhost:3048 — create a project, upload files (multi / folder /
.zip), then open a `.kicad_pcb` / `.kicad_sch` in its tool.
Open http://localhost:3048 — either **open a local folder** (no backend needed)
or open the backend's project. The editor can point at any conforming backend
via `VITE_API_BASE_URL`.
## WASM artifacts
The runtime artifacts (`<tool>.js/.wasm`, `wx.js`, `images.tar.gz`, plus the
`<tool>.html` harness pages) are build outputs, **not** committed here. The
complete set is synced into `tests/apps/kicad/` by
`tests/scripts/setup-kicad-wasm.sh` from repo-root `output/` (+ `wx.js` from
`wxwidgets/`; `output/` alone lacks `wx.js`). That script is a real **sync** —
it skips files already byte-identical at the destination, so re-running it does
not rewrite the multi-hundred-MB `.wasm`.
The runtime artifacts (`<tool>.js/.wasm`, `wx.js`, `images.tar.gz`, `<tool>.html`)
are build outputs, **not** committed. They are synced into `tests/apps/kicad/` by
`tests/scripts/setup-kicad-wasm.sh` (from repo-root `output/`).
**They must be served same-origin as the app.** Under the document's COEP/
**They must be served same-origin as the app.** Under the document's COEP /
cross-origin-isolation (set by the Vite dev server), KiCad WASM refuses to load
its glue/wasm from a different origin. So the app serves them from its own
origin with **no extra copy**: `pnpm dev` runs `scripts/link-wasm.mjs`, which
**symlinks** `apps/frontend/public/wasm → tests/apps/kicad`. Vite then serves
them at `/wasm` (same origin). `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`.
its glue/wasm from a different origin. `pnpm dev` runs `scripts/link-wasm.mjs`,
which **symlinks** `standalone/public/wasm → tests/apps/kicad`; Vite serves them
at `/wasm`. `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`.
- Point the symlink elsewhere with
`WASM_SRC_DIR=/path pnpm --filter @kicad-web/frontend link-wasm`.
- If the tool won't load, the target dir is probably empty — run
`WASM_SRC_DIR=/path pnpm --filter @pcbjam/standalone link-wasm`.
- If a tool won't load, the target dir is probably empty — run
`tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`.
The tool view (`WasmTool.tsx` + `src/wasm/boot.ts`) boots the tool **directly in
the React document** — no iframe. It replicates the proven harness HTML
(`tests/apps/kicad/<tool>.html`): builds the same global Emscripten `Module`
config and preRun steps (create canvas, write `images.tar.gz`, seed config), then
injects the same `wx.js` + `<tool>.js` artifacts into the page. It then syncs the
project tree into MEMFS and drives File→Open. The build is non-modularized
(global `Module`/`FS`) and pthread-based, so only **one** tool runs per page load;
switching tools requires a full navigation. `locateFile` resolves the wasm and
the pthread worker against `<base>` so they load regardless of the SPA route.
**prod**: point `VITE_WASM_ASSET_BASE_URL` at a CDN URL — but that origin must
itself satisfy the same-origin / COEP constraints (e.g. served under the app's
own origin/path).
- **prod**: point `VITE_WASM_ASSET_BASE_URL` at a URL whose origin also satisfies
the same-origin / COEP constraints.
## Scripts
| Command | What |
|---|---|
| `pnpm dev` | server + frontend (turbo) |
| `pnpm db:up` / `pnpm db:down` | start/stop Postgres |
| `pnpm db:generate` | generate Drizzle migration SQL from schema |
| `pnpm db:migrate` | apply migrations + seed default owner |
| `pnpm db:seed` | (re)seed the default owner |
| `pnpm typecheck` | typecheck all packages |
| `pnpm dev` | editor + example backend (turbo) |
| `pnpm build` | build all packages |
| `pnpm typecheck` | typecheck all packages |
## API (shared via `packages/contract`)
## Contract (`@pcbjam/shared`, MIT)
JSON (ts-rest): `GET/POST /api/projects`, `GET/DELETE /api/projects/:project`,
`GET /api/projects/:project/files`.
Binary (raw Fastify, response shapes still shared via Zod):
`POST /api/projects/:project/files` (multi-file + folder),
`POST /api/projects/:project/files/zip`,
`GET /api/projects/:project/files/*` (stream bytes).
## Status / next iteration
Working end-to-end: create / open / upload (files, folder, zip) / file
download / WASM static serving / project list & detail UI / URL routing.
Booting a tool syncs the **whole** project tree into MEMFS, then opens the
target file. The open step (`apps/frontend/src/wasm/open-flow.ts`) prefers a
programmatic hook (`Module.kicadOpenFile`) and falls back to EXPERIMENTAL UI
automation ported from the e2e tests — this needs in-browser validation against
built artifacts, and exposing a real embind open-entry-point is the intended
follow-up (spec §11.2). Lazy/partial MEMFS loading and save-back land together
in a later iteration (spec §§9, 12).
The editor reads from a backend over the shared contract:
`GET /api/projects`, `GET /api/projects/:project`,
`GET /api/projects/:project/files`, and the streamed
`GET /api/projects/:project/files/*` (raw bytes). Management/write operations and
ownership are **not** part of this contract — they belong to the closed app.

View file

@ -1,108 +0,0 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Files, FolderUp, Loader2, Package } from "lucide-react";
import { uploadFiles, uploadZip, type UploadItem } from "@/lib/api";
import { Button } from "@/components/ui/button";
export function UploadDropzone({ slug }: { slug: string }) {
const qc = useQueryClient();
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const filesRef = React.useRef<HTMLInputElement>(null);
const folderRef = React.useRef<HTMLInputElement>(null);
const zipRef = React.useRef<HTMLInputElement>(null);
// `webkitdirectory` isn't in the React input typings; set it imperatively.
React.useEffect(() => {
if (folderRef.current) {
folderRef.current.setAttribute("webkitdirectory", "");
folderRef.current.setAttribute("directory", "");
}
}, []);
const refresh = () => qc.invalidateQueries({ queryKey: ["project", slug] });
const run = async (fn: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await fn();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files;
if (!list || list.length === 0) return;
const items: UploadItem[] = Array.from(list).map((file) => ({
// folder picker → webkitRelativePath; multi-file picker → name
path:
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
file.name,
file,
}));
void run(() => uploadFiles(slug, items));
e.target.value = "";
};
const onZip = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
void run(() => uploadZip(slug, file));
e.target.value = "";
};
return (
<div className="rounded-lg border border-dashed p-6">
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() => filesRef.current?.click()}
>
<Files /> Upload files
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => folderRef.current?.click()}
>
<FolderUp /> Upload folder
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => zipRef.current?.click()}
>
<Package /> Upload .zip
</Button>
{busy && (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="animate-spin" /> uploading…
</span>
)}
</div>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<input
ref={filesRef}
type="file"
multiple
hidden
onChange={onFiles}
/>
<input ref={folderRef} type="file" multiple hidden onChange={onFiles} />
<input
ref={zipRef}
type="file"
accept=".zip,application/zip"
hidden
onChange={onZip}
/>
</div>
);
}

View file

@ -1,132 +0,0 @@
import {
contract,
type Project,
type ProjectFile,
type ProjectWithFiles,
type UploadResponse,
} from "@kicad-web/contract";
import { initClient } from "@ts-rest/core";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { API_BASE_URL } from "./config";
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
baseHeaders: {},
});
// --- queries ---
export function useProjects() {
return useQuery({
queryKey: ["projects"],
queryFn: async (): Promise<Project[]> => {
const res = await client.listProjects();
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
});
}
export function useProject(slug: string) {
return useQuery({
queryKey: ["project", slug],
queryFn: async (): Promise<ProjectWithFiles> => {
const res = await client.getProject({ params: { project: slug } });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
},
});
}
// --- mutations ---
export function useCreateProject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: {
name: string;
slug?: string;
}): Promise<Project> => {
const res = await client.createProject({ body: input });
if (res.status === 409) throw new Error("a project with that slug exists");
if (res.status !== 201) throw new Error("failed to create project");
return res.body;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }),
});
}
export function useDeleteProject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (slug: string): Promise<void> => {
const res = await client.deleteProject({
params: { project: slug },
body: {},
});
if (res.status !== 200) throw new Error("failed to delete project");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }),
});
}
// --- raw binary endpoints (not in the ts-rest contract) ---
export interface UploadItem {
/** project-relative path; folders use webkitRelativePath */
path: string;
file: File;
}
export async function uploadFiles(
slug: string,
items: UploadItem[],
): Promise<ProjectFile[]> {
const form = new FormData();
for (const item of items) {
// Field name carries the relative path (server reads part.fieldname).
form.append(item.path, item.file, item.file.name);
}
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
return ((await res.json()) as UploadResponse).files;
}
export async function uploadZip(
slug: string,
zip: File,
): Promise<ProjectFile[]> {
const form = new FormData();
form.append("zip", zip, zip.name);
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/zip`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`zip upload failed: ${res.status}`);
return ((await res.json()) as UploadResponse).files;
}
export function fileBytesUrl(slug: string, relPath: string): string {
const encoded = relPath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
}
export async function fetchFileBytes(
slug: string,
relPath: string,
): Promise<Uint8Array> {
const res = await fetch(fileBytesUrl(slug, relPath));
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
}

View file

@ -1,150 +0,0 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { useCreateProject, useDeleteProject, useProjects } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function CreateProjectDialog() {
const create = useCreateProject();
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [slug, setSlug] = React.useState("");
const submit = async () => {
if (!name.trim()) return;
await create.mutateAsync({
name: name.trim(),
slug: slug.trim() || undefined,
});
setName("");
setSlug("");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus /> New project
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a project</DialogTitle>
<DialogDescription>
A project holds a tree of KiCad files you can open in the browser.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
placeholder="My board"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug (optional)</Label>
<Input
id="slug"
value={slug}
placeholder="auto-generated from name"
onChange={(e) => setSlug(e.target.value)}
/>
</div>
{create.error && (
<p className="text-sm text-destructive">
{(create.error as Error).message}
</p>
)}
</div>
<DialogFooter>
<Button onClick={submit} disabled={create.isPending}>
{create.isPending && <Loader2 className="animate-spin" />} Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ProjectsPage() {
const { data: projects, isLoading, error } = useProjects();
const del = useDeleteProject();
return (
<div className="container py-10">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-sm text-muted-foreground">
Create a project, upload KiCad files, open them in the browser.
</p>
</div>
<CreateProjectDialog />
</div>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && (
<p className="text-destructive">
Could not load projects: {(error as Error).message}
</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects?.map((p) => (
<Card key={p.id}>
<CardHeader>
<CardTitle>{p.name}</CardTitle>
<CardDescription>/p/{p.slug}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (confirm(`Delete project "${p.name}"?`)) {
del.mutate(p.slug);
}
}}
>
<Trash2 className="text-destructive" />
</Button>
</CardContent>
</Card>
))}
</div>
{projects && projects.length === 0 && !isLoading && (
<p className="text-muted-foreground">No projects yet. Create one above.</p>
)}
</div>
);
}

View file

@ -1,17 +0,0 @@
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
const here = path.dirname(fileURLToPath(import.meta.url));
// web/.env is two levels up from apps/server.
config({ path: path.resolve(here, "../../.env") });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
});

View file

@ -1,31 +0,0 @@
CREATE TABLE "owner" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "owner_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "project_file" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"path" text NOT NULL,
"size" bigint NOT NULL,
"content_type" text NOT NULL,
"storage_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_file_path_uq" UNIQUE("project_id","path")
);
--> statement-breakpoint
CREATE TABLE "project" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"owner_id" uuid NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_owner_slug_uq" UNIQUE("owner_id","slug")
);
--> statement-breakpoint
ALTER TABLE "project_file" ADD CONSTRAINT "project_file_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_owner_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."owner"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -1,222 +0,0 @@
{
"id": "921143e5-34f7-42f8-8c3b-28918d8201fd",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.owner": {
"name": "owner",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"owner_slug_unique": {
"name": "owner_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.project_file": {
"name": "project_file",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"storage_key": {
"name": "storage_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"project_file_project_id_project_id_fk": {
"name": "project_file_project_id_project_id_fk",
"tableFrom": "project_file",
"tableTo": "project",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"project_file_path_uq": {
"name": "project_file_path_uq",
"nullsNotDistinct": false,
"columns": [
"project_id",
"path"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.project": {
"name": "project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"project_owner_id_owner_id_fk": {
"name": "project_owner_id_owner_id_fk",
"tableFrom": "project",
"tableTo": "owner",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"project_owner_slug_uq": {
"name": "project_owner_slug_uq",
"nullsNotDistinct": false,
"columns": [
"owner_id",
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780307401097,
"tag": "0000_lucky_husk",
"breakpoints": true
}
]
}

View file

@ -1,37 +0,0 @@
{
"name": "@kicad-web/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@kicad-web/contract": "workspace:*",
"@kicad-web/storage": "workspace:*",
"@ts-rest/fastify": "^3.52.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"fastify": "^4.29.0",
"pg": "^8.13.1",
"unzipper": "^0.12.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/pg": "^8.11.10",
"@types/unzipper": "^0.10.10",
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View file

@ -1,32 +0,0 @@
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import Fastify, { type FastifyInstance } from "fastify";
import { env } from "./env.js";
import { apiPlugin } from "./routes/api.js";
import { fileRoutes } from "./routes/files.js";
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: true,
// Project files (whole KiCad trees) and zips can be large.
bodyLimit: 1024 * 1024 * 1024,
});
await app.register(cors, {
origin: env.CORS_ORIGIN === "*" ? true : env.CORS_ORIGIN.split(","),
});
await app.register(multipart, {
limits: {
fileSize: 1024 * 1024 * 1024, // 1 GiB per file
files: 5000, // a KiCad project can have many lib files
},
});
app.get("/health", async () => ({ ok: true }));
await app.register(apiPlugin);
await app.register(fileRoutes);
return app;
}

View file

@ -1,9 +0,0 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import { env } from "../env.js";
import * as schema from "./schema.js";
export const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
export const db = drizzle(pool, { schema });
export { schema };
export type Db = typeof db;

View file

@ -1,23 +0,0 @@
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db, pool } from "./index.js";
import { seedDefaultOwner, seedDemoProject } from "./seed.js";
const here = path.dirname(fileURLToPath(import.meta.url));
async function main() {
await migrate(db, {
migrationsFolder: path.resolve(here, "../../drizzle"),
});
const ownerId = await seedDefaultOwner();
console.log(`migrations applied; default owner: ${ownerId}`);
// Give a fresh install a ready-to-open demo project (idempotent).
await seedDemoProject();
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,62 +0,0 @@
import {
bigint,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core";
export const owners = pgTable("owner", {
id: uuid("id").primaryKey().defaultRandom(),
slug: text("slug").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const projects = pgTable(
"project",
{
id: uuid("id").primaryKey().defaultRandom(),
ownerId: uuid("owner_id")
.notNull()
.references(() => owners.id, { onDelete: "cascade" }),
slug: text("slug").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [unique("project_owner_slug_uq").on(t.ownerId, t.slug)],
);
export const projectFiles = pgTable(
"project_file",
{
id: uuid("id").primaryKey().defaultRandom(),
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
// POSIX project-relative path, e.g. "pcbnew/nyak.kicad_pcb".
path: text("path").notNull(),
size: bigint("size", { mode: "number" }).notNull(),
contentType: text("content_type").notNull(),
// Opaque key handed to FileStorage; decouples logical path from blob layout.
storageKey: text("storage_key").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [unique("project_file_path_uq").on(t.projectId, t.path)],
);
export type OwnerRow = typeof owners.$inferSelect;
export type ProjectRow = typeof projects.$inferSelect;
export type ProjectFileRow = typeof projectFiles.$inferSelect;

View file

@ -1,91 +0,0 @@
import * as fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { eq } from "drizzle-orm";
import { env } from "../env.js";
import { db, pool } from "./index.js";
import { owners } from "./schema.js";
import {
createProject,
getProjectRowBySlug,
writeProjectFile,
} from "../services/projects.js";
/** Ensure the default owner namespace exists (no-auth iteration). */
export async function seedDefaultOwner(): Promise<string> {
const existing = await db
.select()
.from(owners)
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
.limit(1);
if (existing[0]) return existing[0].id;
const inserted = await db
.insert(owners)
.values({ slug: env.DEFAULT_OWNER_SLUG })
.onConflictDoNothing()
.returning();
if (inserted[0]) return inserted[0].id;
// Lost a race; re-read.
const row = await db
.select()
.from(owners)
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
.limit(1);
if (!row[0]) throw new Error("failed to seed default owner");
return row[0].id;
}
/**
* Demo project bytes committed at <server>/seed-data/. Resolved relative to this
* module so it works both under tsx (src/db/) and compiled (dist/db/) — both are
* two levels below the package root, where seed-data lives.
*/
const SEED_DATA_DIR = new URL("../../seed-data/", import.meta.url);
const DEMO_SLUG = "demo";
const DEMO_NAME = "Demo Project";
/** Committed files → project-relative paths. One per openable tool. */
const DEMO_FILES = [
"demo.kicad_sch", // eeschema (Schematic Editor)
"demo.kicad_pcb", // pcbnew (PCB Editor)
"demo.kicad_wks", // pl_editor (Drawing Sheet Editor)
] as const;
/**
* Seed a ready-to-open demo project so a freshly cloned + migrated install has
* something to click on (and to exercise the tool wiring). Idempotent: skips if
* the "demo" project already exists. Reads bytes from the committed seed-data/
* dir, so it needs no submodule checkout at runtime.
*/
export async function seedDemoProject(): Promise<void> {
if (await getProjectRowBySlug(DEMO_SLUG)) {
console.log(`demo project "${DEMO_SLUG}" already exists — skipping`);
return;
}
const project = await createProject(DEMO_NAME, DEMO_SLUG);
const row = await getProjectRowBySlug(DEMO_SLUG);
if (!row) throw new Error("demo project vanished right after creation");
for (const name of DEMO_FILES) {
const data = await fs.readFile(fileURLToPath(new URL(name, SEED_DATA_DIR)));
await writeProjectFile({ project: row, rawPath: name, data });
console.log(` seeded ${DEMO_SLUG}/${name} (${data.length} bytes)`);
}
console.log(`seeded demo project "${project.slug}" with ${DEMO_FILES.length} files`);
}
// Allow running standalone: `pnpm db:seed`.
if (import.meta.url === `file://${process.argv[1]}`) {
seedDefaultOwner()
.then(async (id) => {
console.log(`seeded default owner: ${id}`);
await seedDemoProject();
return pool.end();
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -1,21 +0,0 @@
import { config } from "dotenv";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
const here = path.dirname(fileURLToPath(import.meta.url));
// web/.env lives three levels up from apps/server/src. Standard precedence:
// real environment variables win over .env (don't override).
config({ path: path.resolve(here, "../../../.env") });
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().int().positive().default(3050),
STORAGE_DRIVER: z.string().default("local"),
STORAGE_ROOT: z.string().default("./.data/storage"),
CORS_ORIGIN: z.string().default("http://localhost:3048"),
DEFAULT_OWNER_SLUG: z.string().default("default"),
});
export const env = envSchema.parse(process.env);
export type Env = typeof env;

View file

@ -1,47 +0,0 @@
import * as path from "node:path";
/**
* Normalize an arbitrary client-supplied relative path into a safe POSIX
* project-relative path. Strips leading slashes and any `..` traversal.
*/
export function sanitizeRelPath(input: string): string {
const posix = input.replace(/\\/g, "/");
const normalized = path.posix
.normalize(posix)
.replace(/^(\.\.(\/|$))+/, "")
.replace(/^\/+/, "");
if (!normalized || normalized === "." || normalized.startsWith("..")) {
throw new Error(`invalid file path: ${input}`);
}
return normalized;
}
export function slugify(name: string): string {
const base = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
return base || "project";
}
const TEXT_EXT = new Set([
".kicad_pcb",
".kicad_sch",
".kicad_pro",
".kicad_sym",
".kicad_mod",
".kicad_dru",
".kicad_wks",
".net",
".txt",
".csv",
".json",
]);
export function guessContentType(relPath: string): string {
const ext = path.posix.extname(relPath).toLowerCase();
if (TEXT_EXT.has(ext)) return "text/plain; charset=utf-8";
return "application/octet-stream";
}

View file

@ -1,51 +0,0 @@
import { contract } from "@kicad-web/contract";
import { initServer } from "@ts-rest/fastify";
import * as svc from "../services/projects.js";
const s = initServer();
export const apiRouter = s.router(contract, {
listProjects: async () => ({
status: 200,
body: await svc.listProjects(),
}),
createProject: async ({ body }) => {
try {
const project = await svc.createProject(body.name, body.slug);
return { status: 201 as const, body: project };
} catch (err) {
if (err instanceof svc.SlugConflictError) {
return { status: 409 as const, body: { message: err.message } };
}
throw err;
}
},
getProject: async ({ params }) => {
const result = await svc.getProjectWithFiles(params.project);
if (!result) {
return { status: 404 as const, body: { message: "project not found" } };
}
return { status: 200 as const, body: result };
},
deleteProject: async ({ params }) => {
const id = await svc.deleteProject(params.project);
if (!id) {
return { status: 404 as const, body: { message: "project not found" } };
}
return { status: 200 as const, body: { id } };
},
listFiles: async ({ params }) => {
const row = await svc.getProjectRowBySlug(params.project);
if (!row) {
return { status: 404 as const, body: { message: "project not found" } };
}
return { status: 200 as const, body: await svc.listFilesApi(row.id) };
},
});
/** Fastify plugin that mounts the ts-rest JSON API. */
export const apiPlugin = s.plugin(apiRouter);

View file

@ -1,117 +0,0 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { pipeline } from "node:stream/promises";
import type { ProjectFile } from "@kicad-web/contract";
import type { FastifyInstance } from "fastify";
import unzipper from "unzipper";
import { sanitizeRelPath } from "../lib/paths.js";
import * as svc from "../services/projects.js";
import { storage } from "../storage.js";
/**
* Binary file routes that don't round-trip cleanly through ts-rest:
* POST /api/projects/:project/files (multipart; multi-file + folder)
* POST /api/projects/:project/files/zip (multipart; one zip)
* GET /api/projects/:project/files/* (stream raw bytes)
*
* For multi-file / folder uploads the client sends each file as a part whose
* FIELD NAME is the project-relative path (folder uploads pass
* webkitRelativePath). The zip route unpacks entries preserving their paths.
*/
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// --- multi-file / folder upload ---
app.post("/api/projects/:project/files", async (req, reply) => {
const slug = (req.params as { project: string }).project;
const project = await svc.getProjectRowBySlug(slug);
if (!project) {
return reply.code(404).send({ message: "project not found" });
}
const written: ProjectFile[] = [];
for await (const part of req.parts()) {
if (part.type !== "file") continue;
// Field name carries the relative path; fall back to the filename.
const rawPath = part.fieldname || part.filename || "";
if (!rawPath) {
part.file.resume();
continue;
}
written.push(
await svc.writeProjectFile({
project,
rawPath,
data: part.file,
contentType: part.mimetype,
}),
);
}
return reply.code(201).send({ files: written });
});
// --- zip upload ---
app.post("/api/projects/:project/files/zip", async (req, reply) => {
const slug = (req.params as { project: string }).project;
const project = await svc.getProjectRowBySlug(slug);
if (!project) {
return reply.code(404).send({ message: "project not found" });
}
const zipPart = await req.file();
if (!zipPart) {
return reply.code(400).send({ message: "no zip file in request" });
}
const tmp = path.join(os.tmpdir(), `kicad-upload-${randomUUID()}.zip`);
const written: ProjectFile[] = [];
try {
await pipeline(zipPart.file, createWriteStream(tmp));
const directory = await unzipper.Open.file(tmp);
for (const entry of directory.files) {
if (entry.type !== "File") continue;
let relPath: string;
try {
relPath = sanitizeRelPath(entry.path);
} catch {
continue; // skip traversal / invalid entries
}
written.push(
await svc.writeProjectFile({
project,
rawPath: relPath,
data: entry.stream(),
size: entry.uncompressedSize,
}),
);
}
} finally {
await fs.rm(tmp, { force: true });
}
return reply.code(201).send({ files: written });
});
// --- download raw bytes ---
app.get("/api/projects/:project/files/*", async (req, reply) => {
const params = req.params as { project: string; "*": string };
const project = await svc.getProjectRowBySlug(params.project);
if (!project) {
return reply.code(404).send({ message: "project not found" });
}
let relPath: string;
try {
relPath = sanitizeRelPath(params["*"]);
} catch {
return reply.code(400).send({ message: "invalid path" });
}
const file = await svc.getFileRow(project.id, relPath);
if (!file) {
return reply.code(404).send({ message: "file not found" });
}
reply.header("Content-Type", file.contentType);
reply.header("Content-Length", file.size);
reply.header("Cache-Control", "no-cache");
return reply.send(storage.createReadStream(file.storageKey));
});
}

View file

@ -1,12 +0,0 @@
import { buildApp } from "./app.js";
import { env } from "./env.js";
async function main() {
const app = await buildApp();
await app.listen({ port: env.PORT, host: "0.0.0.0" });
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,196 +0,0 @@
import type { Project, ProjectFile } from "@kicad-web/contract";
import type { Readable } from "node:stream";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import {
projectFiles,
projects,
type ProjectFileRow,
type ProjectRow,
} from "../db/schema.js";
import { env } from "../env.js";
import { owners } from "../db/schema.js";
import { guessContentType, sanitizeRelPath, slugify } from "../lib/paths.js";
import { fileStorageKey, projectStoragePrefix, storage } from "../storage.js";
export class SlugConflictError extends Error {
constructor(slug: string) {
super(`project slug already exists: ${slug}`);
this.name = "SlugConflictError";
}
}
let cachedOwnerId: string | null = null;
export async function getDefaultOwnerId(): Promise<string> {
if (cachedOwnerId) return cachedOwnerId;
const row = await db
.select()
.from(owners)
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
.limit(1);
if (!row[0]) {
throw new Error(
`default owner "${env.DEFAULT_OWNER_SLUG}" not found — run db:migrate`,
);
}
cachedOwnerId = row[0].id;
return cachedOwnerId;
}
function toApiProject(row: ProjectRow): Project {
return {
id: row.id,
ownerId: row.ownerId,
slug: row.slug,
name: row.name,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function toApiFile(row: ProjectFileRow): ProjectFile {
return {
id: row.id,
projectId: row.projectId,
path: row.path,
size: row.size,
contentType: row.contentType,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listProjects(): Promise<Project[]> {
const ownerId = await getDefaultOwnerId();
const rows = await db
.select()
.from(projects)
.where(eq(projects.ownerId, ownerId))
.orderBy(asc(projects.createdAt));
return rows.map(toApiProject);
}
async function slugExists(ownerId: string, slug: string): Promise<boolean> {
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.ownerId, ownerId), eq(projects.slug, slug)))
.limit(1);
return !!row[0];
}
export async function createProject(
name: string,
slug?: string,
): Promise<Project> {
const ownerId = await getDefaultOwnerId();
if (slug) {
if (await slugExists(ownerId, slug)) throw new SlugConflictError(slug);
} else {
const base = slugify(name);
slug = base;
for (let i = 2; await slugExists(ownerId, slug); i++) {
slug = `${base}-${i}`;
}
}
const inserted = await db
.insert(projects)
.values({ ownerId, slug, name })
.returning();
return toApiProject(inserted[0]!);
}
export async function getProjectRowBySlug(
slug: string,
): Promise<ProjectRow | null> {
const ownerId = await getDefaultOwnerId();
const row = await db
.select()
.from(projects)
.where(and(eq(projects.ownerId, ownerId), eq(projects.slug, slug)))
.limit(1);
return row[0] ?? null;
}
export async function listFiles(projectId: string): Promise<ProjectFileRow[]> {
return db
.select()
.from(projectFiles)
.where(eq(projectFiles.projectId, projectId))
.orderBy(asc(projectFiles.path));
}
export async function listFilesApi(projectId: string): Promise<ProjectFile[]> {
return (await listFiles(projectId)).map(toApiFile);
}
export async function getProjectWithFiles(slug: string): Promise<{
project: Project;
files: ProjectFile[];
} | null> {
const row = await getProjectRowBySlug(slug);
if (!row) return null;
const files = await listFiles(row.id);
return { project: toApiProject(row), files: files.map(toApiFile) };
}
export async function getFileRow(
projectId: string,
relPath: string,
): Promise<ProjectFileRow | null> {
const row = await db
.select()
.from(projectFiles)
.where(
and(eq(projectFiles.projectId, projectId), eq(projectFiles.path, relPath)),
)
.limit(1);
return row[0] ?? null;
}
export async function deleteProject(slug: string): Promise<string | null> {
const row = await getProjectRowBySlug(slug);
if (!row) return null;
// Cascade removes project_file rows; storage prefix removed explicitly.
await db.delete(projects).where(eq(projects.id, row.id));
await storage.deletePrefix(projectStoragePrefix(row.ownerId, row.id));
return row.id;
}
/**
* Stream/buffer a single file into storage and upsert its index row. Used by
* the upload routes (multi-file, folder, and zip entries).
*/
export async function writeProjectFile(opts: {
project: ProjectRow;
rawPath: string;
data: Uint8Array | Readable;
size?: number;
contentType?: string;
}): Promise<ProjectFile> {
const relPath = sanitizeRelPath(opts.rawPath);
const key = fileStorageKey(opts.project.ownerId, opts.project.id, relPath);
await storage.write(key, opts.data, { contentType: opts.contentType });
const size = opts.size ?? (await storage.stat(key)).size;
const contentType = opts.contentType ?? guessContentType(relPath);
const inserted = await db
.insert(projectFiles)
.values({
projectId: opts.project.id,
path: relPath,
size,
contentType,
storageKey: key,
})
.onConflictDoUpdate({
target: [projectFiles.projectId, projectFiles.path],
set: { size, contentType, storageKey: key, updatedAt: new Date() },
})
.returning();
return toApiFile(inserted[0]!);
}

View file

@ -1,16 +0,0 @@
import { createFileStorage } from "@kicad-web/storage";
import { env } from "./env.js";
export const storage = createFileStorage(env);
export function projectStoragePrefix(ownerId: string, projectId: string): string {
return `owners/${ownerId}/projects/${projectId}`;
}
export function fileStorageKey(
ownerId: string,
projectId: string,
relPath: string,
): string {
return `${projectStoragePrefix(ownerId, projectId)}/${relPath}`;
}

View file

@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"lib": ["ES2022"]
},
"include": ["src", "drizzle.config.ts"]
}

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"]
}

View file

@ -1,26 +0,0 @@
# Local infrastructure for the KiCad web app.
#
# NOTE: the Postgres host port defaults to a non-default value (54329) so it
# does not collide with a Postgres already listening on the standard 5432.
# Override with POSTGRES_PORT in web/.env if 54329 is taken. The container
# always listens on 5432 internally; only the published host port changes.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kicad}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kicad}
POSTGRES_DB: ${POSTGRES_DB:-kicad_web}
ports:
- "${POSTGRES_PORT:-54329}:5432"
volumes:
- kicad_web_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kicad} -d ${POSTGRES_DB:-kicad_web}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
kicad_web_pgdata:

View file

@ -1,5 +1,5 @@
{
"name": "kicad-web",
"name": "pcbjam-web",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.33.0",
@ -10,12 +10,7 @@
"dev": "turbo run dev",
"build": "turbo run build",
"typecheck": "turbo run typecheck",
"lint": "turbo run lint",
"db:up": "docker compose up -d",
"db:down": "docker compose down",
"db:generate": "pnpm --filter @kicad-web/server db:generate",
"db:migrate": "pnpm --filter @kicad-web/server db:migrate",
"db:seed": "pnpm --filter @kicad-web/server db:seed"
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "^2.5.0",

View file

@ -1,19 +0,0 @@
{
"name": "@kicad-web/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ts-rest/core": "^3.52.1",
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -1,79 +0,0 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
createProjectBody,
errorBody,
projectFileSchema,
projectSchema,
projectWithFiles,
} from "./schemas.js";
export * from "./schemas.js";
const c = initContract();
/**
* JSON API surface shared by the Fastify server and the React client.
*
* NOTE: binary upload (`POST .../files`, `.../files/zip`) and file-byte
* download (`GET .../files/*`) are intentionally NOT in this ts-rest contract —
* multipart/streamed-binary do not round-trip cleanly through ts-rest. They are
* plain Fastify routes; their response shapes are still shared via the Zod
* schemas in ./schemas.ts (e.g. `uploadResponse`).
*/
export const contract = c.router(
{
listProjects: {
method: "GET",
path: "/api/projects",
responses: { 200: z.array(projectSchema) },
summary: "List all projects in the default owner namespace",
},
createProject: {
method: "POST",
path: "/api/projects",
body: createProjectBody,
responses: {
201: projectSchema,
409: errorBody,
400: errorBody,
},
summary: "Create a project",
},
getProject: {
method: "GET",
path: "/api/projects/:project",
pathParams: z.object({ project: z.string() }),
responses: {
200: projectWithFiles,
404: errorBody,
},
summary: "Get a project and its file tree",
},
deleteProject: {
method: "DELETE",
path: "/api/projects/:project",
body: c.type<Record<string, never>>(),
responses: {
200: z.object({ id: z.string().uuid() }),
404: errorBody,
},
summary: "Delete a project and all its files",
},
listFiles: {
method: "GET",
path: "/api/projects/:project/files",
pathParams: z.object({ project: z.string() }),
responses: {
200: z.array(projectFileSchema),
404: errorBody,
},
summary: "List the files in a project",
},
},
{
strictStatusCodes: true,
},
);
export type Contract = typeof contract;

View file

@ -1,95 +0,0 @@
import { z } from "zod";
/** WASM tools that can be selected by the `:tool` URL segment. */
export const TOOLS = [
"pcbnew",
"eeschema",
"calculator",
"pl_editor",
"symbol_editor",
"gerbview",
] as const;
export const toolSchema = z.enum(TOOLS);
export type Tool = z.infer<typeof toolSchema>;
/** Human-readable labels for tools (UI links, status text). */
export const TOOL_LABELS: Record<Tool, string> = {
pcbnew: "PCB Editor",
eeschema: "Schematic Editor",
calculator: "PCB Calculator",
pl_editor: "Drawing Sheet Editor",
symbol_editor: "Symbol Editor",
gerbview: "Gerber Viewer",
};
/** Default file-extension → tool mapping (the explicit URL segment always wins). */
export const EXTENSION_TOOL: Record<string, Tool> = {
".kicad_pcb": "pcbnew",
".kicad_sch": "eeschema",
".kicad_wks": "pl_editor",
};
/**
* Tools that do not take a file (booted standalone). The calculator has no file
* concept; the symbol editor opens libraries through its own UI (its frame does
* not implement OpenProjectFiles), so we boot it standalone rather than auto-open.
* The gerber viewer likewise opens gerber/drill files through its own File→Open
* UI — projects carry no gerber files to auto-open — so it boots standalone too.
*/
export const FILELESS_TOOLS: ReadonlySet<Tool> = new Set<Tool>([
"calculator",
"symbol_editor",
"gerbview",
]);
export const projectSlugSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z0-9][a-z0-9._-]*$/,
"slug must start alphanumeric and contain only lowercase letters, digits, '.', '_', '-'",
);
export const projectSchema = z.object({
id: z.string().uuid(),
ownerId: z.string().uuid(),
slug: z.string(),
name: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Project = z.infer<typeof projectSchema>;
export const projectFileSchema = z.object({
id: z.string().uuid(),
projectId: z.string().uuid(),
/** POSIX project-relative path, e.g. "pcbnew/nyak.kicad_pcb". */
path: z.string(),
size: z.number().int().nonnegative(),
contentType: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type ProjectFile = z.infer<typeof projectFileSchema>;
export const createProjectBody = z.object({
name: z.string().min(1).max(200),
slug: projectSlugSchema.optional(),
});
export type CreateProjectBody = z.infer<typeof createProjectBody>;
export const projectWithFiles = z.object({
project: projectSchema,
files: z.array(projectFileSchema),
});
export type ProjectWithFiles = z.infer<typeof projectWithFiles>;
/** Shared response shape for the (raw-Fastify) upload endpoints. */
export const uploadResponse = z.object({
files: z.array(projectFileSchema),
});
export type UploadResponse = z.infer<typeof uploadResponse>;
export const errorBody = z.object({ message: z.string() });
export type ErrorBody = z.infer<typeof errorBody>;

View file

@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View file

@ -1,16 +0,0 @@
{
"name": "@kicad-web/storage",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View file

@ -1,22 +0,0 @@
export type { FileStorage, StatResult } from "./types.js";
export { LocalDiskStorage } from "./local-disk.js";
import { LocalDiskStorage } from "./local-disk.js";
import type { FileStorage } from "./types.js";
/**
* Build the storage backend from environment. Today only `local` is wired; an
* `s3` driver slots in here later behind the same FileStorage interface.
*/
export function createFileStorage(env: {
STORAGE_DRIVER?: string;
STORAGE_ROOT?: string;
}): FileStorage {
const driver = env.STORAGE_DRIVER ?? "local";
switch (driver) {
case "local":
return new LocalDiskStorage(env.STORAGE_ROOT ?? "./.data/storage");
default:
throw new Error(`unknown STORAGE_DRIVER: ${driver}`);
}
}

View file

@ -1,98 +0,0 @@
import { createReadStream as fsCreateReadStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import type { FileStorage, StatResult } from "./types.js";
/**
* Stores blobs as files under a single root directory. `key` maps directly to a
* relative path inside the root; traversal outside the root is rejected.
*/
export class LocalDiskStorage implements FileStorage {
private readonly root: string;
constructor(root: string) {
this.root = path.resolve(root);
}
private resolve(key: string): string {
const normalized = path
.normalize(key)
.replace(/^(\.\.(\/|\\|$))+/, "")
.replace(/^[/\\]+/, "");
const full = path.resolve(this.root, normalized);
if (full !== this.root && !full.startsWith(this.root + path.sep)) {
throw new Error(`storage key escapes root: ${key}`);
}
return full;
}
async exists(key: string): Promise<boolean> {
try {
await fs.access(this.resolve(key));
return true;
} catch {
return false;
}
}
async read(key: string): Promise<Uint8Array> {
return new Uint8Array(await fs.readFile(this.resolve(key)));
}
createReadStream(key: string): Readable {
return fsCreateReadStream(this.resolve(key));
}
async stat(key: string): Promise<StatResult> {
const s = await fs.stat(this.resolve(key));
return { size: s.size };
}
async list(prefix: string): Promise<string[]> {
const base = this.resolve(prefix);
const out: string[] = [];
const walk = async (dir: string): Promise<void> => {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(abs);
} else if (entry.isFile()) {
out.push(path.relative(this.root, abs).split(path.sep).join("/"));
}
}
};
await walk(base);
return out;
}
async write(
key: string,
data: Uint8Array | Readable,
_opts?: { contentType?: string },
): Promise<void> {
const full = this.resolve(key);
await fs.mkdir(path.dirname(full), { recursive: true });
if (data instanceof Uint8Array) {
await fs.writeFile(full, data);
} else {
await pipeline(data, createWriteStream(full));
}
}
async delete(key: string): Promise<void> {
await fs.rm(this.resolve(key), { force: true });
}
async deletePrefix(prefix: string): Promise<void> {
await fs.rm(this.resolve(prefix), { recursive: true, force: true });
}
}

View file

@ -1,33 +0,0 @@
import type { Readable } from "node:stream";
export interface StatResult {
size: number;
contentType?: string;
}
/**
* Pluggable blob store for project file bytes.
*
* `key` is an opaque string the caller chose (see the server's storage-key
* scheme); only the implementation interprets it. The current iteration is
* read-heavy — the write half exists so save-back (a later iteration) needs no
* redesign.
*/
export interface FileStorage {
// --- read ---
exists(key: string): Promise<boolean>;
read(key: string): Promise<Uint8Array>;
createReadStream(key: string): Readable;
stat(key: string): Promise<StatResult>;
list(prefix: string): Promise<string[]>;
// --- write (used now by upload; save-back is a later iteration) ---
write(
key: string,
data: Uint8Array | Readable,
opts?: { contentType?: string },
): Promise<void>;
delete(key: string): Promise<void>;
/** Remove every key under a prefix (e.g. a whole project). */
deletePrefix(prefix: string): Promise<void>;
}

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src"]
}

1
web/pcbjam-shared Submodule

@ -0,0 +1 @@
Subproject commit b4993a47e897afc6e3a986ae1d598cc2011b3c13

1073
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
packages:
- "apps/*"
- "packages/*"
- "standalone"
- "backend"
- "pcbjam-shared"

View file

@ -0,0 +1,16 @@
# Standalone editor (Vite). Copy to standalone/.env. All vars must be VITE_-prefixed.
# Backend implementing the @pcbjam/shared contract (the example backend, or any
# conforming backend). Leave it pointing anywhere unreachable to use only the
# local-folder loader on the home page.
VITE_API_BASE_URL=http://localhost:3060
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required
# (KiCad WASM pthread workers cannot be created cross-origin). On `dev` the
# artifacts are symlinked into public/wasm and served by Vite at /wasm. For prod,
# set an absolute URL whose origin also satisfies the COEP/COOP rules.
VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# WASM_SRC_DIR=

View file

@ -1,5 +1,5 @@
{
"name": "@kicad-web/frontend",
"name": "@pcbjam/standalone",
"version": "0.0.0",
"private": true,
"type": "module",
@ -11,7 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@kicad-web/contract": "workspace:*",
"@pcbjam/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
// Make the WASM runtime artifacts available SAME-ORIGIN to the app without a
// third copy: symlink apps/frontend/public/wasm -> the synced artifact dir
// third copy: symlink standalone/public/wasm -> the synced artifact dir
// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then
// serves them at /wasm from the app's own origin — required because KiCad WASM
// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its
@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.resolve(scriptDir, "../public");
const linkPath = path.join(publicDir, "wasm");
const repoRoot = path.resolve(scriptDir, "../../../..");
// scriptDir = web/standalone/scripts → repo root is three levels up.
const repoRoot = path.resolve(scriptDir, "../../..");
const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh");
const targetAbs = process.env.WASM_SRC_DIR

View file

@ -1,13 +1,13 @@
import { Route, Routes } from "react-router-dom";
import { ProjectsPage } from "@/pages/ProjectsPage";
import { ProjectDetailPage } from "@/pages/ProjectDetailPage";
import { HomePage } from "@/pages/HomePage";
import { ProjectView } from "@/pages/ProjectView";
import { ToolPage } from "@/pages/ToolPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/p/:project" element={<ProjectDetailPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/p/:project" element={<ProjectView />} />
<Route path="/p/:project/:tool/*" element={<ToolPage />} />
</Routes>
);

View file

@ -1,10 +1,9 @@
import * as React from "react";
import type { ProjectFile, Tool } from "@kicad-web/contract";
import type { Tool } from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { fetchFileBytes } from "@/lib/api";
import { WASM_ASSET_BASE_URL } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { driveProjectIntoTool } from "@/wasm/kicad-runner";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
@ -79,11 +78,17 @@ export function WasmTool({
slug,
files,
targetPath,
fetchBytes,
assetBaseUrl,
}: {
tool: Tool;
slug: string;
files: ProjectFile[];
files: ToolFile[];
targetPath?: string;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */
assetBaseUrl?: string;
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false);
@ -91,7 +96,7 @@ export function WasmTool({
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false);
const base = WASM_ASSET_BASE_URL.replace(/\/$/, "");
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
React.useEffect(() => {
// Guard re-entry: the WASM runtime is process-global and must boot exactly
@ -117,7 +122,7 @@ export function WasmTool({
slug,
files,
targetPath,
fetchBytes: (relPath) => fetchFileBytes(slug, relPath),
fetchBytes,
log: append,
onStatus: setStatus,
});

View file

@ -0,0 +1,61 @@
import {
contract,
type Project,
type ProjectWithFiles,
} from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL } from "./config";
/**
* Read-only client over the shared contract. The standalone editor only ever
* READS projects from a backend (enumerate, get file tree, stream bytes) — it
* never creates/deletes/uploads. Those management concerns live in the closed
* application that hosts this editor.
*/
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
baseHeaders: {},
});
export function useProjects() {
return useQuery({
queryKey: ["projects"],
queryFn: async (): Promise<Project[]> => {
const res = await client.listProjects();
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
});
}
export function useProject(slug: string) {
return useQuery({
queryKey: ["project", slug],
queryFn: async (): Promise<ProjectWithFiles> => {
const res = await client.getProject({ params: { project: slug } });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
},
});
}
// --- raw file-byte download (streamed binary, not a ts-rest endpoint) ---
export function fileBytesUrl(slug: string, relPath: string): string {
const encoded = relPath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
}
export async function fetchFileBytes(
slug: string,
relPath: string,
): Promise<Uint8Array> {
const res = await fetch(fileBytesUrl(slug, relPath));
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
}

View file

@ -0,0 +1,187 @@
import * as React from "react";
import { Link } from "react-router-dom";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
TOOLS,
type Tool,
} from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api";
import { Button } from "@/components/ui/button";
import type { ToolFile } from "@/wasm/kicad-runner";
import { WasmTool } from "@/components/WasmTool";
/** A KiCad project picked from the local filesystem (no backend involved). */
interface LocalProject {
name: string;
files: ToolFile[];
fetchBytes: (relPath: string) => Promise<Uint8Array>;
defaultTool?: Tool;
defaultTarget?: string;
}
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
/** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
function buildLocalProject(fileList: FileList): LocalProject {
const map = new Map<string, File>();
const first = fileList[0];
const topPrefix =
first?.webkitRelativePath?.includes("/")
? first.webkitRelativePath.split("/")[0] + "/"
: "";
for (const f of Array.from(fileList)) {
const rel = f.webkitRelativePath || f.name;
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
}
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
let defaultTool: Tool | undefined;
let defaultTarget: string | undefined;
for (const { path } of files) {
const tool = toolForPath(path);
if (tool) {
defaultTool = tool;
defaultTarget = path;
break;
}
}
return {
name: topPrefix ? topPrefix.slice(0, -1) : "local",
files,
defaultTool,
defaultTarget,
fetchBytes: async (relPath) => {
const f = map.get(relPath);
if (!f) throw new Error(`local file not found: ${relPath}`);
return new Uint8Array(await f.arrayBuffer());
},
};
}
export function HomePage() {
const { data: projects, isLoading, error } = useProjects();
const inputRef = React.useRef<HTMLInputElement>(null);
const [local, setLocal] = React.useState<LocalProject | null>(null);
const [tool, setTool] = React.useState<Tool | "">("");
const [launched, setLaunched] = React.useState(false);
// <input webkitdirectory> is non-standard; set it imperatively.
React.useEffect(() => {
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
}, []);
// Once launched, the WASM runtime is process-global and one-shot for this page
// load — render the editor full-screen and nothing else (no going back).
if (launched && local && tool) {
const target = tool === local.defaultTool ? local.defaultTarget : undefined;
return (
<WasmTool
tool={tool}
slug="local"
files={local.files}
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
fetchBytes={local.fetchBytes}
/>
);
}
return (
<div className="container max-w-3xl py-10">
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
<p className="mb-8 text-sm text-muted-foreground">
Open KiCad files in the browser — from a backend, or straight from a
local folder.
</p>
{/* --- Local folder --- */}
<section className="mb-10 rounded-lg border p-5">
<h2 className="mb-1 flex items-center gap-2 text-lg font-medium">
<FolderOpen size={18} /> Open a local folder
</h2>
<p className="mb-4 text-sm text-muted-foreground">
No upload — files stay in your browser. Pick a folder containing a
KiCad project.
</p>
<input
ref={inputRef}
type="file"
multiple
className="block text-sm"
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
const proj = buildLocalProject(fl);
setLocal(proj);
setTool(proj.defaultTool ?? "");
}}
/>
{local && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{local.files.length} files
</span>
<select
className="rounded-md border px-2 py-1.5 text-sm"
value={tool}
onChange={(e) => setTool(e.target.value as Tool)}
>
<option value="">Select a tool…</option>
{TOOLS.map((t) => (
<option key={t} value={t}>
{TOOL_LABELS[t]}
</option>
))}
</select>
<Button disabled={!tool} onClick={() => setLaunched(true)}>
Open editor
</Button>
</div>
)}
</section>
{/* --- Backend projects --- */}
<section>
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && (
<p className="text-sm text-muted-foreground">
No backend reachable ({(error as Error).message}). Use a local folder
above, or configure VITE_API_BASE_URL.
</p>
)}
<div className="divide-y rounded-lg border">
{projects?.map((p) => (
<div
key={p.id}
className="flex items-center justify-between px-4 py-3"
>
<div>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
</div>
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
</div>
))}
{projects && projects.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
The backend has no projects.
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -4,21 +4,24 @@ import {
FILELESS_TOOLS,
TOOL_LABELS,
type Tool,
} from "@kicad-web/contract";
} from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
import { useProject } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { UploadDropzone } from "@/components/UploadDropzone";
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
const ext = path.slice(dot).toLowerCase();
return EXTENSION_TOOL[ext] ?? null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
export function ProjectDetailPage() {
/**
* Read-only view of a backend project: list its files and open them in a tool.
* The editor is GPL and intentionally has no create/delete/upload — those live
* in the closed application that hosts this editor.
*/
export function ProjectView() {
const { project: slug = "" } = useParams();
const { data, isLoading, error } = useProject(slug);
@ -26,7 +29,7 @@ export function ProjectDetailPage() {
<div className="container py-10">
<Button asChild variant="ghost" size="sm" className="mb-4">
<Link to="/">
<ArrowLeft /> All projects
<ArrowLeft /> Home
</Link>
</Button>
@ -35,9 +38,7 @@ export function ProjectDetailPage() {
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && (
<p className="text-destructive">{(error as Error).message}</p>
)}
{error && <p className="text-destructive">{(error as Error).message}</p>}
{data && (
<>
@ -45,12 +46,14 @@ export function ProjectDetailPage() {
<h1 className="text-2xl font-semibold tracking-tight">
{data.project.name}
</h1>
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
<p className="text-sm text-muted-foreground">
/p/{data.project.slug}
</p>
</div>
<div className="mb-6 flex flex-wrap gap-3">
{/* Standalone (file-less) tools — launched without a target file.
Full reload (anchor) so Emscripten boots into a clean page. */}
{/* File-less tools — launched without a target file. Full reload
(anchor) so Emscripten boots into a clean page. */}
{[...FILELESS_TOOLS].map((tool) => (
<a
key={tool}
@ -62,10 +65,6 @@ export function ProjectDetailPage() {
))}
</div>
<div className="mb-8">
<UploadDropzone slug={slug} />
</div>
<h2 className="mb-3 text-lg font-medium">
Files ({data.files.length})
</h2>
@ -96,7 +95,7 @@ export function ProjectDetailPage() {
})}
{data.files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files yet — upload some above.
No files in this project.
</div>
)}
</div>

View file

@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { toolSchema } from "@kicad-web/contract";
import { useProject } from "@/lib/api";
import { toolSchema } from "@pcbjam/shared";
import { fetchFileBytes, useProject } from "@/lib/api";
import { WasmTool } from "@/components/WasmTool";
export function ToolPage() {
@ -36,6 +36,7 @@ export function ToolPage() {
slug={slug}
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
/>
);
}

View file

@ -1,4 +1,4 @@
import type { Tool } from "@kicad-web/contract";
import type { Tool } from "@pcbjam/shared";
import {
KICAD_CONFIG_DIR,
RESOURCE_PATH,

View file

@ -1,4 +1,4 @@
import type { Tool } from "@kicad-web/contract";
import type { Tool } from "@pcbjam/shared";
/**
* KiCad config/version dir baked into the WASM build. The File→Open dialog

View file

@ -1,12 +1,21 @@
import type { ProjectFile, Tool } from "@kicad-web/contract";
import { FILELESS_TOOLS } from "@kicad-web/contract";
import type { Tool } from "@pcbjam/shared";
import { FILELESS_TOOLS } from "@pcbjam/shared";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { openFileInTool } from "./open-flow";
/**
* The only thing the editor needs to know about a file to sync it into MEMFS:
* its project-relative POSIX path. Both the contract loader (whose ProjectFile
* is a superset of this) and the local-folder loader satisfy it.
*/
export interface ToolFile {
path: string;
}
export interface DriveOptions {
tool: Tool;
slug: string;
files: ProjectFile[];
files: ToolFile[];
targetPath?: string;
fetchBytes: (relPath: string) => Promise<Uint8Array>;
log: (msg: string) => void;

View file

@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",

View file

@ -1,12 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": [
"DATABASE_URL",
"PORT",
"STORAGE_ROOT",
"STORAGE_DRIVER",
"PROJECT_DIR",
"CORS_ORIGIN",
"DEFAULT_OWNER_SLUG",
"WASM_SRC_DIR",
"VITE_API_BASE_URL",
"VITE_WASM_ASSET_BASE_URL"