feat(web): checkpoint web app init

- frontend (Vite/React) + server (Hono/Drizzle) scaffold under web/
- eeschema WASM embind kicadOpenFile hook + programmatic open-flow
- skip KiCad first-run setup wizard by seeding default config in preRun
- dev: auto-sync output/ WASM artifacts into tests/apps/kicad via link-wasm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-01 16:30:29 +02:00
commit 735e5aa8e9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
70 changed files with 7775 additions and 13 deletions

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KiCad Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,40 @@
{
"name": "@kicad-web/frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/link-wasm.mjs && vite",
"link-wasm": "node scripts/link-wasm.mjs",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@kicad-web/contract": "workspace:*",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.62.11",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,104 @@
#!/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
// (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
// glue/wasm from a different origin.
//
// No bytes are duplicated; the link points straight at the synced dir.
// Override the target with WASM_SRC_DIR (absolute, or relative to cwd).
import { spawnSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
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, "../../../..");
const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh");
const targetAbs = process.env.WASM_SRC_DIR
? path.resolve(process.cwd(), process.env.WASM_SRC_DIR)
: path.join(repoRoot, "tests/apps/kicad");
// Sync freshly-built artifacts from output/ into tests/apps/kicad before
// linking, so `npm run dev` always serves the latest build instead of whatever
// was synced last. Skipped when WASM_SRC_DIR overrides the target: the setup
// script only writes to tests/apps/kicad, so running it would be a no-op there
// (and would mislead by syncing a dir we're not even linking to).
// Non-fatal: a missing/empty build must not block the dev server from starting.
// The setup script exits non-zero when no artifacts exist anywhere; we warn and
// fall through to the symlink, which already degrades gracefully (see below).
function syncArtifacts() {
if (process.env.WASM_SRC_DIR) {
console.log("[link-wasm] WASM_SRC_DIR set — skipping output/ sync");
return;
}
const res = spawnSync("bash", [setupScript], { stdio: "inherit" });
if (res.error) {
console.warn(
`[link-wasm] could not run setup-kicad-wasm.sh: ${res.error.message}`,
);
} else if (res.status !== 0) {
console.warn(
`[link-wasm] setup-kicad-wasm.sh exited ${res.status} — ` +
`serving whatever is already in ${path.relative(repoRoot, targetAbs)}`,
);
}
}
// Relative link so it stays valid if the repo moves.
const targetRel = path.relative(publicDir, targetAbs);
async function statOrNull(p, { follow = true } = {}) {
try {
return follow ? await fs.stat(p) : await fs.lstat(p);
} catch {
return null;
}
}
async function main() {
syncArtifacts();
await fs.mkdir(publicDir, { recursive: true });
const link = await statOrNull(linkPath, { follow: false });
if (link?.isSymbolicLink()) {
const current = await fs.readlink(linkPath);
if (path.resolve(publicDir, current) === targetAbs) {
// already correct
} else {
await fs.rm(linkPath);
await fs.symlink(targetRel, linkPath, "dir");
}
} else if (link) {
// A real dir/file (e.g. an earlier copy) — remove and link.
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(targetRel, linkPath, "dir");
} else {
await fs.symlink(targetRel, linkPath, "dir");
}
const target = await statOrNull(targetAbs);
if (!target?.isDirectory()) {
console.warn(
`[link-wasm] target not found: ${targetAbs}\n` +
`[link-wasm] run tests/scripts/setup-kicad-wasm.sh (or set WASM_SRC_DIR). ` +
`App will run but tools won't load.`,
);
return;
}
const hasWx = await statOrNull(path.join(targetAbs, "wx.js"));
console.log(
`[link-wasm] public/wasm -> ${path.relative(repoRoot, targetAbs)}` +
(hasWx ? "" : " (warning: wx.js missing in target)"),
);
}
main().catch((err) => {
console.error("[link-wasm]", err);
process.exit(1);
});

View file

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

View file

@ -0,0 +1,108 @@
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

@ -0,0 +1,96 @@
import * as React from "react";
import type { ProjectFile, Tool } from "@kicad-web/contract";
import { ChevronDown, ChevronUp } from "lucide-react";
import { fetchFileBytes } from "@/lib/api";
import { WASM_ASSET_BASE_URL } from "@/lib/config";
import { driveProjectIntoTool, hookIframeConsole } from "@/wasm/kicad-runner";
/**
* Boots a KiCad tool by loading the proven harness HTML (/wasm/<tool>.html, the
* same file the e2e tests use) in a same-origin iframe, then injects the project
* tree into its MEMFS and drives File→Open. Same-origin is required: KiCad WASM
* refuses to load its glue/wasm from a different origin under COEP.
*/
export function WasmTool({
tool,
slug,
files,
targetPath,
}: {
tool: Tool;
slug: string;
files: ProjectFile[];
targetPath?: string;
}) {
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const startedRef = React.useRef(false);
const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false);
const base = WASM_ASSET_BASE_URL.replace(/\/$/, "");
const src = `${base}/${tool}.html`;
const onLoad = () => {
if (startedRef.current) return;
startedRef.current = true;
const win = iframeRef.current?.contentWindow as
| ToolWindow
| null
| undefined;
if (!win) {
setStatus("Error: iframe has no window");
return;
}
const append = (msg: string) =>
setLogs((prev) => [...prev.slice(-800), msg]);
hookIframeConsole(win, append);
void driveProjectIntoTool(win, {
tool,
slug,
files,
targetPath,
fetchBytes: (relPath) => fetchFileBytes(slug, relPath),
log: append,
onStatus: setStatus,
}).catch((err) => {
append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`);
});
};
return (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
<iframe
ref={iframeRef}
src={src}
title={`${tool} (${slug})`}
onLoad={onLoad}
className="absolute inset-0 h-full w-full border-0"
allow="cross-origin-isolated; fullscreen"
/>
{status && (
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded bg-black/70 px-3 py-2 font-mono text-xs text-white">
{status}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
onClick={() => setShowLog((s) => !s)}
>
{showLog ? <ChevronDown size={14} /> : <ChevronUp size={14} />} console
({logs.length})
</button>
{showLog && (
<pre className="max-h-64 overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
{logs.join("\n")}
</pre>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -0,0 +1,82 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,106 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
}
}

View file

@ -0,0 +1,132 @@
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

@ -0,0 +1,9 @@
export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050";
// Default is SAME-ORIGIN ("/wasm", served from public/wasm by Vite). KiCad WASM
// pthread workers cannot be created cross-origin, so dev must serve same-origin.
// Override with an absolute URL (e.g. a CDN) only if that origin is configured
// to also satisfy the worker/COEP constraints.
export const WASM_ASSET_BASE_URL =
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";

View file

@ -0,0 +1,18 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(1)} ${units[i]}`;
}

View file

@ -0,0 +1,21 @@
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// NOTE: deliberately NOT wrapped in <React.StrictMode>. StrictMode double-mounts
// components in dev, which tears down and recreates the WasmTool iframe and thus
// instantiates a 175–338 MB KiCad wasm twice — enough to OOM the tab. The tool
// view must instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>,
);

View file

@ -0,0 +1,98 @@
import { Link, useParams } from "react-router-dom";
import { EXTENSION_TOOL, type Tool } from "@kicad-web/contract";
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;
}
export function ProjectDetailPage() {
const { project: slug = "" } = useParams();
const { data, isLoading, error } = useProject(slug);
return (
<div className="container py-10">
<Button asChild variant="ghost" size="sm" className="mb-4">
<Link to="/">
<ArrowLeft /> All projects
</Link>
</Button>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && (
<p className="text-destructive">{(error as Error).message}</p>
)}
{data && (
<>
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">
{data.project.name}
</h1>
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
</div>
<div className="mb-6 flex flex-wrap gap-3">
{/* Full reload (anchor) so Emscripten boots into a clean page. */}
<a
className="text-sm underline underline-offset-4"
href={`/p/${slug}/calculator/`}
>
Open PCB Calculator
</a>
</div>
<div className="mb-8">
<UploadDropzone slug={slug} />
</div>
<h2 className="mb-3 text-lg font-medium">
Files ({data.files.length})
</h2>
<div className="divide-y rounded-lg border">
{data.files.map((f) => {
const tool = toolForPath(f.path);
return (
<div
key={f.id}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{f.path}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(f.size)}
</p>
</div>
{tool && (
<a
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
href={`/p/${slug}/${tool}/${f.path}`}
>
<ExternalLink size={14} /> Open in {tool}
</a>
)}
</div>
);
})}
{data.files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files yet — upload some above.
</div>
)}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,150 @@
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

@ -0,0 +1,41 @@
import { useParams } from "react-router-dom";
import { toolSchema } from "@kicad-web/contract";
import { useProject } from "@/lib/api";
import { WasmTool } from "@/components/WasmTool";
export function ToolPage() {
const params = useParams();
const slug = params.project ?? "";
const targetPath = params["*"] || undefined;
const parsedTool = toolSchema.safeParse(params.tool);
const { data, isLoading, error } = useProject(slug);
if (!parsedTool.success) {
return (
<div className="container py-10 text-destructive">
Unknown tool: {params.tool}
</div>
);
}
if (isLoading) {
return <div className="container py-10 text-muted-foreground">loading…</div>;
}
if (error || !data) {
return (
<div className="container py-10 text-destructive">
{(error as Error)?.message ?? "project not found"}
</div>
);
}
return (
<WasmTool
tool={parsedTool.data}
slug={slug}
files={data.files}
targetPath={targetPath}
/>
);
}

10
web/apps/frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_WASM_ASSET_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -0,0 +1,31 @@
import type { Tool } from "@kicad-web/contract";
/**
* KiCad config/version dir baked into the WASM build. The File→Open dialog
* starts in MEMFS_PROJECTS_DIR; we mirror each project under a subfolder of it.
*
* NOTE (spec §11.1): this path is KiCad-version dependent and confirmed by
* tests/kicad/load-pcb-probe.spec.ts. If the build's version dir changes, this
* must change too — a candidate for reading from the module at runtime later.
*/
export const KICAD_VERSION_DIR = "9.99";
export const MEMFS_PROJECTS_DIR = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
/** Where KiCad expects images.tar.gz (compiled-in KICAD_DATA path). */
export const RESOURCE_PATH =
"/workspace/build-wasm/sysroot/share/kicad/resources";
/** argv[0] each tool's DEBUG check expects (see tests/apps/kicad/pcbnew.html). */
export const TOOL_ARGV0: Record<Tool, string> = {
pcbnew: "/usr/bin/pcbnew",
eeschema: "/usr/bin/eeschema",
calculator: "/usr/bin/calculator",
};
export function memfsProjectDir(slug: string): string {
return `${MEMFS_PROJECTS_DIR}/${slug}`;
}
export function memfsFilePath(slug: string, relPath: string): string {
return `${memfsProjectDir(slug)}/${relPath}`;
}

53
web/apps/frontend/src/wasm/global.d.ts vendored Normal file
View file

@ -0,0 +1,53 @@
export {};
declare global {
interface EmscriptenFS {
mkdirTree(path: string): void;
writeFile(path: string, data: Uint8Array | string): void;
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
analyzePath(path: string): { exists: boolean };
}
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js.
interface WxElementInfo {
id: string;
typeName: string;
name: string;
label: string;
visible: boolean;
enabled: boolean;
screenX: number;
screenY: number;
centerX: number;
centerY: number;
width: number;
height: number;
}
interface WxElementRegistry {
findAll(filter?: {
visible?: boolean;
enabled?: boolean;
type?: string;
label?: string;
name?: string;
}): WxElementInfo[];
findByLabel(label: string, options?: Record<string, unknown>): WxElementInfo[];
findRenderedByLabel?(
label: string,
options?: Record<string, unknown>,
): WxElementInfo[];
}
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Module?: any;
FS?: EmscriptenFS;
wxElementRegistry?: WxElementRegistry;
}
// A real browsing-context window (iframe.contentWindow): the Window interface
// PLUS the global declarations (console, PointerEvent, document, …) that live
// on `typeof globalThis`, not on the bare Window interface.
type ToolWindow = Window & typeof globalThis;
}

View file

@ -0,0 +1,99 @@
import type { ProjectFile, Tool } from "@kicad-web/contract";
import { FILELESS_TOOLS } from "@kicad-web/contract";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { openFileInTool } from "./open-flow";
export interface DriveOptions {
tool: Tool;
slug: string;
files: ProjectFile[];
targetPath?: string;
fetchBytes: (relPath: string) => Promise<Uint8Array>;
log: (msg: string) => void;
onStatus: (text: string) => void;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs: number,
intervalMs = 200,
): Promise<T | null> {
const deadline = performance.now() + timeoutMs;
for (;;) {
const v = fn();
if (v) return v as T;
if (performance.now() >= deadline) return null;
await sleep(intervalMs);
}
}
/**
* Forward the iframe's console (where the harness routes Module.print/printErr
* and KiCad logs) into our on-page log panel, preserving the original output.
*/
export function hookIframeConsole(win: ToolWindow, log: (msg: string) => void): void {
const wrap = (level: "log" | "info" | "warn" | "error") => {
const orig = win.console[level].bind(win.console);
win.console[level] = (...args: unknown[]) => {
try {
log(args.map((a) => (typeof a === "string" ? a : String(a))).join(" "));
} catch {
/* ignore logging errors */
}
orig(...args);
};
};
(["log", "info", "warn", "error"] as const).forEach(wrap);
}
function getFS(win: ToolWindow): EmscriptenFS {
const fs = win.FS ?? win.Module?.FS;
if (!fs) throw new Error("Emscripten FS not available in iframe");
return fs as EmscriptenFS;
}
/** Mirror the whole project tree into the iframe's MEMFS (sync-whole-tree). */
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
const fs = getFS(win);
fs.mkdirTree(memfsProjectDir(opts.slug));
for (const file of opts.files) {
const dest = memfsFilePath(opts.slug, file.path);
const dir = dest.slice(0, dest.lastIndexOf("/"));
fs.mkdirTree(dir);
const bytes = await opts.fetchBytes(file.path);
fs.writeFile(dest, bytes);
opts.log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
}
}
/**
* Drive a project into an already-booting tool harness (loaded in a same-origin
* iframe at /wasm/<tool>.html). Waits for the Emscripten FS, syncs the project
* tree into MEMFS, then auto-opens the target file.
*/
export async function driveProjectIntoTool(
win: ToolWindow,
opts: DriveOptions,
): Promise<void> {
const { log, onStatus } = opts;
onStatus("Waiting for runtime…");
const fsReady = await waitFor(
() => !!(win.FS && typeof win.FS.writeFile === "function"),
90000,
);
if (!fsReady) throw new Error("runtime did not initialize (no FS) in 90s");
onStatus("Loading project files…");
await syncProjectToMemfs(win, opts);
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…");
const abs = memfsFilePath(opts.slug, opts.targetPath);
const result = await openFileInTool(win, abs, { log });
log(`[open] result: ${result}`);
}
onStatus("");
}

View file

@ -0,0 +1,175 @@
/**
* Drive the tool (running in a same-origin iframe `win`) to open a file already
* written into its MEMFS.
*
* Two strategies, tried in order:
* 1. Programmatic hook — `win.Module.kicadOpenFile(path)` if the build exposes
* one. PREFERRED (spec §11.2): deterministic, no UI automation. Not present
* in the current build; adding it is a small embind change.
* 2. UI automation fallback — synthesize canvas mouse/keyboard events using
* win.wxElementRegistry coordinates (a browser port of
* tests/kicad/load-pcb.spec.ts). Inherently fragile; EXPERIMENTAL, needs
* in-browser validation.
*/
export interface OpenFlowOptions {
log: (msg: string) => void;
timeoutMs?: number;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs: number,
intervalMs = 200,
): Promise<T | null> {
const deadline = performance.now() + timeoutMs;
for (;;) {
const v = fn();
if (v) return v as T;
if (performance.now() >= deadline) return null;
await sleep(intervalMs);
}
}
function registry(win: ToolWindow): WxElementRegistry | undefined {
return win.wxElementRegistry;
}
function visible(
win: ToolWindow,
filter: { type?: string; name?: string; label?: string },
): WxElementInfo[] {
return registry(win)?.findAll({ ...filter, visible: true }) ?? [];
}
function canvasOf(win: ToolWindow): HTMLCanvasElement | null {
return (win.Module?.canvas as HTMLCanvasElement) ?? null;
}
/** Dispatch a full pointer+mouse click at page coordinates on the iframe canvas. */
function clickAt(win: ToolWindow, x: number, y: number): void {
const el = canvasOf(win);
if (!el) return;
const PE = win.PointerEvent ?? PointerEvent;
const ME = win.MouseEvent ?? MouseEvent;
const base = { clientX: x, clientY: y, bubbles: true, cancelable: true };
el.dispatchEvent(new PE("pointerdown", { ...base, pointerId: 1 }));
el.dispatchEvent(new ME("mousedown", { ...base, button: 0 }));
el.dispatchEvent(new PE("pointerup", { ...base, pointerId: 1 }));
el.dispatchEvent(new ME("mouseup", { ...base, button: 0 }));
el.dispatchEvent(new ME("click", { ...base, button: 0 }));
}
function typeText(win: ToolWindow, text: string): void {
const el = canvasOf(win) ?? win.document.body;
const KE = win.KeyboardEvent ?? KeyboardEvent;
for (const ch of text) {
const init = { key: ch, bubbles: true, cancelable: true } as KeyboardEventInit;
el.dispatchEvent(new KE("keydown", init));
el.dispatchEvent(new KE("keypress", init));
el.dispatchEvent(new KE("keyup", init));
}
}
function pressKey(win: ToolWindow, key: string): void {
const el = canvasOf(win) ?? win.document.body;
const KE = win.KeyboardEvent ?? KeyboardEvent;
const init = { key, bubbles: true, cancelable: true } as KeyboardEventInit;
el.dispatchEvent(new KE("keydown", init));
el.dispatchEvent(new KE("keyup", init));
}
function tryProgrammaticOpen(
win: ToolWindow,
absPath: string,
log: (m: string) => void,
): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = win.Module as any;
if (mod && typeof mod.kicadOpenFile === "function") {
const ok = mod.kicadOpenFile(absPath) === true;
log(`[open] Module.kicadOpenFile(${absPath}) -> ${ok}`);
return ok; // false → caller falls back to UI automation
}
return false;
}
export async function openFileInTool(
win: ToolWindow,
absPath: string,
opts: OpenFlowOptions,
): Promise<"programmatic" | "ui" | "failed"> {
const { log } = opts;
const timeoutMs = opts.timeoutMs ?? 60000;
// Both strategies need the editor frame up first. Crucially, the programmatic
// hook (Module.kicadOpenFile → OpenProjectFiles) requires a top window, and
// embind only registers the hook during runtime init — which lands AFTER the
// Emscripten FS is ready, i.e. after driveProjectIntoTool calls us. Probing
// the hook before the frame exists therefore always missed and fell back to
// UI automation (and the wizard's modal loop then crashed Asyncify). Waiting
// for a visible Frame guarantees the runtime is initialized, the hook is
// registered, and a top window exists — so we probe only after this point.
const ready = await waitFor(
() =>
visible(win, {}).some(
(e) => /Frame$/.test(e.typeName) || e.name.endsWith("Frame"),
),
timeoutMs,
);
if (!ready) {
log("[open] app frame never became visible");
return "failed";
}
// Strategy 1: programmatic hook (preferred — deterministic, no UI automation).
if (tryProgrammaticOpen(win, absPath, log)) return "programmatic";
// Strategy 2: UI automation fallback (EXPERIMENTAL, fragile).
log("[open] no programmatic hook; using EXPERIMENTAL UI automation");
const fileMenu =
registry(win)
?.findByLabel("File", {})
?.find((e) => e.visible) ??
visible(win, {}).find((e) => e.label === "File");
if (!fileMenu) {
log("[open] could not find File menu");
return "failed";
}
clickAt(win, fileMenu.centerX, fileMenu.centerY);
await sleep(400);
const openItem =
registry(win)?.findRenderedByLabel?.("Open...", {})?.[0] ??
registry(win)?.findByLabel("Open...", {})?.[0];
if (!openItem) {
log("[open] could not find Open... item");
return "failed";
}
clickAt(win, openItem.centerX, openItem.centerY);
const dlg = await waitFor(() => visible(win, { type: "wxFileDialog" })[0], 15000);
if (!dlg) {
log("[open] wxFileDialog never appeared");
return "failed";
}
await sleep(800);
const textInput = visible(win, { type: "wxTextCtrl" }).find(
(e) => e.name === "text",
);
if (!textInput) {
log("[open] filename text input not found");
return "failed";
}
clickAt(win, textInput.centerX, textInput.centerY);
await sleep(150);
typeText(win, absPath);
await sleep(150);
pressKey(win, "Enter");
log(`[open] typed path and pressed Enter: ${absPath}`);
return "ui";
}

View file

@ -0,0 +1,53 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [tailwindcssAnimate],
};

View file

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"moduleResolution": "Bundler",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View file

@ -0,0 +1,59 @@
import * as fs from "node:fs";
import * as path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite";
/**
* Serve /wasm/*.gz as opaque bytes WITHOUT `Content-Encoding: gzip`.
*
* Vite's static (sirv) sets `Content-Encoding: gzip` for `.gz` files. The
* browser then transparently decompresses the response, so the harness's
* `fetch('images.tar.gz')` receives the DECOMPRESSED tar — and KiCad's gunzip
* of it fails with "Can't read from inflate stream: incorrect header check".
* We must hand the browser the raw gzip bytes, so we serve them ourselves with
* no Content-Encoding. Runs before the public-dir middleware.
*/
function serveWasmGzRaw(): Plugin {
const publicDir = path.resolve(__dirname, "public");
return {
name: "serve-wasm-gz-raw",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (!/^\/wasm\/.+\.gz$/.test(url)) return next();
const filePath = path.join(publicDir, decodeURIComponent(url));
fs.stat(filePath, (err, st) => {
if (err || !st.isFile()) return next();
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Length", st.size);
// Intentionally NO Content-Encoding so fetch() yields raw gzip bytes.
fs.createReadStream(filePath).pipe(res);
});
});
},
};
}
export default defineConfig({
plugins: [serveWasmGzRaw(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 3048,
// KiCad WASM is cross-origin-isolated (COOP/COEP); same-origin /wasm assets
// load fine. Keep these so SharedArrayBuffer/threads are available.
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
preview: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
});

View file

@ -0,0 +1,17 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,222 @@
{
"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

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

View file

@ -0,0 +1,37 @@
{
"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

@ -0,0 +1,32 @@
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

@ -0,0 +1,9 @@
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

@ -0,0 +1,21 @@
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 } 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}`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,62 @@
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

@ -0,0 +1,43 @@
import { eq } from "drizzle-orm";
import { env } from "../env.js";
import { db, pool } from "./index.js";
import { owners } from "./schema.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;
}
// Allow running standalone: `pnpm db:seed`.
if (import.meta.url === `file://${process.argv[1]}`) {
seedDefaultOwner()
.then((id) => {
console.log(`seeded default owner: ${id}`);
return pool.end();
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -0,0 +1,21 @@
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

@ -0,0 +1,47 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,117 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,196 @@
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

@ -0,0 +1,16 @@
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

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