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:
parent
e28f473040
commit
735e5aa8e9
70 changed files with 7775 additions and 13 deletions
|
|
@ -79,9 +79,11 @@ case "$APP_NAME" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Use branch name as Docker Compose project name for isolated containers/volumes
|
# Use branch name as Docker Compose project name for isolated containers/volumes.
|
||||||
|
# Honor a pre-set COMPOSE_PROJECT_NAME so a build can target an existing volume
|
||||||
|
# (e.g. reuse another branch's already-provisioned deps).
|
||||||
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD | tr '/' '-' | tr '[:upper:]' '[:lower:]')
|
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD | tr '/' '-' | tr '[:upper:]' '[:lower:]')
|
||||||
export COMPOSE_PROJECT_NAME="kicad-wasm-${BRANCH_NAME}"
|
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-kicad-wasm-${BRANCH_NAME}}"
|
||||||
echo "Using Docker project: ${COMPOSE_PROJECT_NAME}"
|
echo "Using Docker project: ${COMPOSE_PROJECT_NAME}"
|
||||||
echo "Building app: ${APP_NAME}"
|
echo "Building app: ${APP_NAME}"
|
||||||
|
|
||||||
|
|
|
||||||
323
features/web-init/0001-web-app-spec.md
Normal file
323
features/web-init/0001-web-app-spec.md
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
# 0001 — KiCad-WASM Web App Spec
|
||||||
|
|
||||||
|
Status: **Refined spec, ready for `implement plan`**
|
||||||
|
Branch: `feature/web-init`
|
||||||
|
Scope of this iteration: **create a project, open a project, upload files, open a file in a WASM tool via URL.**
|
||||||
|
|
||||||
|
This document is the agreed design after a clarification pass. Decisions that were
|
||||||
|
explicitly chosen by the user are marked **[decided]**. Items intentionally pushed to a
|
||||||
|
later iteration are marked **[later]**. Sensible defaults that were *not* explicitly
|
||||||
|
discussed are marked **[default]** and are safe to change during planning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
A single web application that lets a user create/open KiCad projects, upload files into
|
||||||
|
them, and open a file in the matching WASM tool (pcbnew / eeschema / calculator) by
|
||||||
|
visiting a URL such as:
|
||||||
|
|
||||||
|
```
|
||||||
|
/p/project5/pcbnew/nyak.kicad_pcb
|
||||||
|
```
|
||||||
|
|
||||||
|
The WASM apps already exist as `<tool>.js` + `<tool>.wasm` pairs (see `output/` and
|
||||||
|
`tests/apps/kicad/`). They boot into an Emscripten harness and read files from MEMFS.
|
||||||
|
This web app wraps that: it manages projects + files server-side, and on a tool URL it
|
||||||
|
boots the right WASM app and feeds it the project's files via `FS.writeFile`, then drives
|
||||||
|
File→Open on the target file.
|
||||||
|
|
||||||
|
Non-goals this iteration: editing/saving back, collaboration, auth/login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Key decisions (summary table)
|
||||||
|
|
||||||
|
| Area | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | React + TypeScript + Vite + shadcn/ui **[decided]** |
|
||||||
|
| Backend | Fastify + ts-rest + Zod **[decided]** |
|
||||||
|
| Shared types | `packages/contract` (ts-rest contract + Zod) imported by FE client & BE router **[decided]** |
|
||||||
|
| Realtime | None now; pick stack that allows Hocuspocus/WSS later, no WS endpoints yet **[decided]** |
|
||||||
|
| URL semantics | `/p/:project/:tool/*filepath` — `tool` selects the WASM app, `*filepath` is auto-opened **[decided]** |
|
||||||
|
| Auth/tenancy | No auth now, but data model namespaced by an owner id for later multi-user **[decided]** |
|
||||||
|
| Metadata store | Postgres now, accessed via Drizzle (drizzle-zod shares schemas) **[decided]** |
|
||||||
|
| File blob storage | Pluggable `FileStorage` interface; local-disk impl now, S3 later **[decided]** |
|
||||||
|
| Open behavior | Sync **whole project tree** into MEMFS, then auto-open target **[decided]**; lazy/partial load **[later]** |
|
||||||
|
| Upload | Individual files (multi), folder (preserve structure), and `.zip` of a project **[decided]** |
|
||||||
|
| WASM artifact delivery | Served from a **configurable base location** (URL/dir): local Fastify static from `output/` in dev, public S3 URL in prod **[decided]** |
|
||||||
|
| Save-back / sync | Read-only open now; write interface defined but unused. Save-back + lazy load land together **[later]** |
|
||||||
|
| Monorepo | pnpm + turbo workspace under `web/` **[decided]** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Repository / monorepo layout **[decided: under `web/`]**
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── package.json # pnpm workspace root
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
├── turbo.json
|
||||||
|
├── .env.example
|
||||||
|
├── docker-compose.yml # local Postgres (and later: minio for S3 parity)
|
||||||
|
├── apps/
|
||||||
|
│ ├── frontend/ # Vite + React + TS + shadcn
|
||||||
|
│ └── server/ # Fastify + ts-rest + Drizzle
|
||||||
|
└── packages/
|
||||||
|
├── contract/ # ts-rest contract + Zod schemas (shared)
|
||||||
|
├── storage/ # FileStorage interface + local-disk impl (+ S3 later)
|
||||||
|
└── config/ # shared tsconfig / eslint / env parsing [default]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: isolates the JS/TS app from the C++/WASM build repo at root (`kicad/`,
|
||||||
|
`wxwidgets/`, `scripts/`, `docker/`). The web app consumes WASM artifacts produced by the
|
||||||
|
existing build, it does not build them.
|
||||||
|
|
||||||
|
Root `.gitignore` should ignore `web/**/node_modules`, `web/**/dist`, build caches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Domain model
|
||||||
|
|
||||||
|
### 4.1 Entities (Postgres, via Drizzle) **[decided: Postgres + Drizzle]**
|
||||||
|
|
||||||
|
```
|
||||||
|
owner -- namespace for "no auth now, multi-user later"
|
||||||
|
id uuid pk
|
||||||
|
slug text unique -- e.g. "default" now; becomes real users later
|
||||||
|
created_at timestamptz
|
||||||
|
|
||||||
|
project
|
||||||
|
id uuid pk
|
||||||
|
owner_id uuid fk -> owner.id
|
||||||
|
slug text -- URL segment, unique within owner (e.g. "project5")
|
||||||
|
name text -- human display name
|
||||||
|
created_at timestamptz
|
||||||
|
updated_at timestamptz
|
||||||
|
unique(owner_id, slug)
|
||||||
|
|
||||||
|
project_file -- index of files; bytes live in FileStorage
|
||||||
|
id uuid pk
|
||||||
|
project_id uuid fk -> project.id
|
||||||
|
path text -- POSIX-relative within project, e.g. "pcbnew/nyak.kicad_pcb"
|
||||||
|
size bigint
|
||||||
|
content_type text
|
||||||
|
storage_key text -- opaque key handed to FileStorage
|
||||||
|
created_at timestamptz
|
||||||
|
updated_at timestamptz
|
||||||
|
unique(project_id, path)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Owner namespace [decided]**: every project belongs to an `owner`. This iteration uses a
|
||||||
|
single seeded owner (`slug = "default"`); the URL omits owner (`/p/:project/...`) and the
|
||||||
|
server resolves it to the default owner. Adding real auth later = populate `owner` per
|
||||||
|
user and prefix routes, **no schema migration needed**.
|
||||||
|
- `project_file.path` is the canonical project-relative path. The `storage_key` decouples
|
||||||
|
the logical path from however the blob backend names things (so renames/S3 layout are free).
|
||||||
|
|
||||||
|
drizzle-zod derives Zod schemas from these tables; those Zod schemas feed the ts-rest
|
||||||
|
contract so DB ↔ API ↔ client share one source of truth.
|
||||||
|
|
||||||
|
### 4.2 What "project" means at the byte level
|
||||||
|
|
||||||
|
A project is a directory tree of files (`.kicad_pro`, `.kicad_pcb`, `.kicad_sch`,
|
||||||
|
`fp-lib-table`, `sym-lib-table`, footprint/symbol lib dirs, etc.). `project_file` rows
|
||||||
|
enumerate the tree; bytes live behind `FileStorage`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Storage abstraction **[decided: pluggable, local now, S3 later]**
|
||||||
|
|
||||||
|
`packages/storage` exposes a single interface. The whole iteration is **read-heavy**; write
|
||||||
|
methods exist so save-back **[later]** needs no redesign.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface FileStorage {
|
||||||
|
// read path
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
read(key: string): Promise<Uint8Array>;
|
||||||
|
createReadStream(key: string): NodeJS.ReadableStream; // for large files
|
||||||
|
stat(key: string): Promise<{ size: number; contentType?: string }>;
|
||||||
|
list(prefix: string): Promise<string[]>; // keys under a prefix
|
||||||
|
|
||||||
|
// write path (used now only by upload; save-back is [later])
|
||||||
|
write(key: string, data: Uint8Array | NodeJS.ReadableStream, opts?: { contentType?: string }): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementations:
|
||||||
|
- `LocalDiskStorage` **[now]** — rooted at a configurable dir (`STORAGE_ROOT`), `key` maps
|
||||||
|
to a path under it. Streams to/from disk.
|
||||||
|
- `S3Storage` **[later]** — same interface over an S3-compatible bucket. `docker-compose`
|
||||||
|
can run MinIO for local S3 parity when we get there.
|
||||||
|
|
||||||
|
Storage key scheme **[default]**: `owners/<owner_id>/projects/<project_id>/<project_file.path>`.
|
||||||
|
Opaque to callers — only `FileStorage` interprets it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. WASM artifact delivery **[decided: configurable base location]**
|
||||||
|
|
||||||
|
The big artifacts (`pcbnew.wasm` ~350 MB, `eeschema.wasm` ~180 MB, `calculator.wasm`,
|
||||||
|
their `.js` glue, `wx.js`, `images.tar.gz`) are **app binaries, not user data** — kept
|
||||||
|
separate from `FileStorage`.
|
||||||
|
|
||||||
|
- The frontend resolves every artifact URL from a single configurable base:
|
||||||
|
`WASM_ASSET_BASE_URL` **[decided requirement]**.
|
||||||
|
- **dev**: points at the Fastify server, which serves the artifacts statically from a
|
||||||
|
configurable dir (default `../../output` relative to the server, i.e. repo `output/`).
|
||||||
|
- **prod**: points at a public S3/CDN URL. No code change — just env.
|
||||||
|
- An artifact URL is composed as `${WASM_ASSET_BASE_URL}/${tool}.js` (and the glue then
|
||||||
|
fetches the sibling `.wasm` / `worker.js` / `images.tar.gz` from the same base). The
|
||||||
|
Emscripten `locateFile` hook must be wired to this base so `.wasm`/`.worker.js` resolve
|
||||||
|
correctly regardless of origin.
|
||||||
|
- **Cross-origin caveat [important]**: KiCad WASM uses threads (`.worker.js` present),
|
||||||
|
which needs `SharedArrayBuffer` → the **document** must be served with
|
||||||
|
`Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`.
|
||||||
|
When artifacts come from a different origin (S3/CDN), they must be served with
|
||||||
|
`Cross-Origin-Resource-Policy: cross-origin` (or CORS) so they load under COEP. The
|
||||||
|
Fastify static route sets COOP/COEP/CORP in dev; the prod bucket/CDN must set CORP/CORS.
|
||||||
|
Verify against the existing harness behavior in `tests/apps/kicad/`.
|
||||||
|
|
||||||
|
Artifacts are **not committed to git** (they're build outputs). The build pipeline
|
||||||
|
(`docker/build.sh` → `output/`) remains the source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. URL & routing **[decided]**
|
||||||
|
|
||||||
|
Frontend routes (client-side router) **[default: react-router]**:
|
||||||
|
|
||||||
|
| Route | View |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Project list + "Create project" |
|
||||||
|
| `/p/:project` | Project detail: file tree, upload, "open in tool" actions |
|
||||||
|
| `/p/:project/:tool/*filepath` | **Tool view**: boots `:tool` WASM app, auto-opens `*filepath` |
|
||||||
|
|
||||||
|
- `:tool` ∈ `{ pcbnew, eeschema, calculator }` — selects the WASM app **[decided]**.
|
||||||
|
(`calculator` takes no file; opening it ignores `*filepath`.)
|
||||||
|
- `*filepath` is the project-relative path of the file to auto-open
|
||||||
|
(e.g. `pcbnew/nyak.kicad_pcb`). It must match a `project_file.path` row.
|
||||||
|
- `:project` is the project **slug** within the default owner.
|
||||||
|
- Owner is implicit (default owner) now; route gains an owner segment when auth lands
|
||||||
|
**[later]** — `/p/:project/...` is forward-compatible.
|
||||||
|
|
||||||
|
Tool→file-extension mapping (for validation / "open with" UI) **[default]**:
|
||||||
|
`.kicad_pcb → pcbnew`, `.kicad_sch → eeschema`. Mismatches surface a warning but the
|
||||||
|
explicit `:tool` segment wins (per the decided semantics).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API (ts-rest contract in `packages/contract`) **[decided: ts-rest + Zod]**
|
||||||
|
|
||||||
|
All endpoints under `/api`. Contract is the single typed source; Fastify router
|
||||||
|
implements it, frontend uses the generated ts-rest react-query client **[default]**.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/projects -> Project[]
|
||||||
|
POST /api/projects { name, slug? } -> Project # create [scope]
|
||||||
|
GET /api/projects/:project -> Project + file tree # open [scope]
|
||||||
|
DELETE /api/projects/:project -> 204 # [default, nice-to-have]
|
||||||
|
|
||||||
|
GET /api/projects/:project/files -> ProjectFile[]
|
||||||
|
GET /api/projects/:project/files/*path -> file bytes (streamed) # used to fill MEMFS
|
||||||
|
POST /api/projects/:project/files (multipart) -> ProjectFile[] # upload [scope]
|
||||||
|
POST /api/projects/:project/files/zip (multipart zip) -> ProjectFile[] # upload-zip [scope]
|
||||||
|
# write/rename/delete of individual files: interface ready, [later] for save-back
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload handling **[decided: files + folder + zip]**:
|
||||||
|
- **Individual files (multi)**: multipart; each part carries its target project-relative
|
||||||
|
path. Streamed to `FileStorage`, one `project_file` row each.
|
||||||
|
- **Folder (preserve structure)**: frontend uses `webkitdirectory`; relative paths derived
|
||||||
|
from `file.webkitRelativePath` and sent as the per-file path. Server preserves the tree.
|
||||||
|
- **Zip**: server unpacks (streaming unzip) into the project tree, creating `project_file`
|
||||||
|
rows per entry. Reject path traversal (`../`) and absolute paths.
|
||||||
|
|
||||||
|
Validation: Zod schemas (shared) validate bodies/params; Fastify JSON-schema serialization
|
||||||
|
for responses. Errors via a consistent ts-rest error shape **[default]**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open-a-file flow (the core of the iteration)
|
||||||
|
|
||||||
|
Visiting `/p/:project/:tool/*filepath`:
|
||||||
|
|
||||||
|
1. Frontend fetches the project's file tree: `GET /api/projects/:project/files`.
|
||||||
|
2. Frontend loads the WASM glue for `:tool` from `WASM_ASSET_BASE_URL` and instantiates the
|
||||||
|
Emscripten module into a canvas-bearing harness (reuse the proven shell from
|
||||||
|
`tests/apps/kicad/pcbnew.html` — `createCanvas`, `images.tar.gz` prefetch+write,
|
||||||
|
`locateFile`, status/progress UI), ported into a React component.
|
||||||
|
3. **Sync whole project tree into MEMFS** **[decided]**: for every `project_file`, fetch its
|
||||||
|
bytes (`GET .../files/*path`) and `FS.mkdirTree` + `FS.writeFile` at the project root
|
||||||
|
inside MEMFS (mirroring `tests/kicad/utils/fs-inject.ts`). Files land at the path KiCad
|
||||||
|
expects (e.g. under the default projects dir, confirmed by `load-pcb-probe.spec.ts`:
|
||||||
|
`/home/kicad/documents/kicad/9.99/projects/...`). The exact MEMFS mount point for an
|
||||||
|
arbitrary user project is an **open implementation detail** — see §11.
|
||||||
|
4. Drive File→Open on `*filepath` using the existing element-tracker / menu-driving
|
||||||
|
helpers (`tests/e2e/utils/element-tracker.ts`, `tests/kicad/load-pcb.spec.ts`). This UI
|
||||||
|
automation already works for the demo boards and is the reference implementation.
|
||||||
|
5. Render. Read-only — no write-back **[decided]**.
|
||||||
|
|
||||||
|
> **[later]** Lazy/partial loading: instead of syncing the whole tree up front, intercept
|
||||||
|
> MEMFS reads and fetch siblings on demand. This lands **together with save-back/sync**, as
|
||||||
|
> a single coherent iteration (both need MEMFS↔storage plumbing). Not now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend detail **[shadcn decided; rest default]**
|
||||||
|
|
||||||
|
- Vite + React + TS, shadcn/ui components, Tailwind.
|
||||||
|
- Pages: project list (cards + create dialog), project detail (file tree + upload
|
||||||
|
dropzone + per-file "open in pcbnew/eeschema" buttons), tool view (full-viewport WASM
|
||||||
|
canvas + status overlay).
|
||||||
|
- Data layer: ts-rest react-query client generated from the contract.
|
||||||
|
- Upload UX: drag-drop dropzone supporting multi-file, folder (`webkitdirectory`), and
|
||||||
|
`.zip`; progress per file; streamed to backend.
|
||||||
|
- The WASM tool view is a dedicated component that owns the Emscripten lifecycle and tears
|
||||||
|
it down on unmount (WebGL context, MEMFS) to allow switching tools/projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open questions for `implement plan` (not blocking this spec)
|
||||||
|
|
||||||
|
1. **MEMFS mount point for arbitrary projects.** Demos rely on KiCad's default projects
|
||||||
|
path. For a user project we must decide where in MEMFS the tree is written and whether
|
||||||
|
pcbnew/eeschema need it under their expected projects dir, or whether File→Open can
|
||||||
|
target an arbitrary MEMFS path. Resolve by probing (extend `load-pcb-probe`).
|
||||||
|
2. **Driving File→Open generically.** Current helpers are tuned to the demo dialog flow
|
||||||
|
(filelist bbox click + filename input + Enter). Confirm it generalizes to arbitrary
|
||||||
|
paths, or expose a cleaner embind "open file" entry point in the WASM layer.
|
||||||
|
3. **eeschema/calculator open flows.** Mirror the pcbnew flow; verify eeschema's File→Open
|
||||||
|
and that calculator (no file) just boots.
|
||||||
|
4. **Large-tree sync performance.** Whole-tree sync of a big project over many HTTP
|
||||||
|
requests may be slow; consider a single tar/zip stream endpoint to fill MEMFS in one
|
||||||
|
shot (still "sync whole tree", just one request). Decide in planning.
|
||||||
|
5. **COOP/COEP in prod** with cross-origin S3 artifacts — validate header matrix.
|
||||||
|
6. **Project slug generation/collision** rules; reserved tool names as slugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope this iteration (explicit)
|
||||||
|
|
||||||
|
- Auth / login / real multi-user (data model is ready; UI/enforcement is **[later]**).
|
||||||
|
- WebSocket / realtime / Hocuspocus collaboration (stack chosen to allow it; none built).
|
||||||
|
- Saving or syncing edits back to storage (**[later]**, paired with lazy load).
|
||||||
|
- S3 storage implementation (interface ready; **[later]**).
|
||||||
|
- Editing project files in the browser outside the WASM tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Definition of done (this iteration)
|
||||||
|
|
||||||
|
- `pnpm install && docker-compose up -d && pnpm dev` in `web/` brings up Postgres,
|
||||||
|
Fastify (`/api` + WASM static), and the Vite frontend.
|
||||||
|
- Create a project from the UI; it appears in the list and in Postgres.
|
||||||
|
- Open the project; upload files via multi-select, folder, and zip — files appear in the
|
||||||
|
tree and in `FileStorage` + `project_file`.
|
||||||
|
- Navigate to `/p/<slug>/pcbnew/<path>.kicad_pcb`; the board renders read-only, equivalent
|
||||||
|
to the existing `tests/kicad/load-pcb` result, sourcing files from project storage.
|
||||||
|
- Same for an eeschema `.kicad_sch` file.
|
||||||
|
- Swapping `WASM_ASSET_BASE_URL` between local `output/` and a remote URL requires no code
|
||||||
|
change.
|
||||||
|
|
@ -137,10 +137,41 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// KiCad's standalone entry (single_top.cpp) runs STARTWIZARD on launch: a
|
||||||
|
// modal first-run "Setup" wizard shown whenever the settings dir lacks a
|
||||||
|
// kicad_common.json or valid global library tables. In this ephemeral MEMFS
|
||||||
|
// that is EVERY load, and the wizard's modal event loop crashes Asyncify
|
||||||
|
// (func is not a function). Seed a minimal default config before main() so
|
||||||
|
// all three providers report NeedsUserInput()==false — equivalent to the
|
||||||
|
// wizard's "use defaults" path — and it never opens. Settings dir matches
|
||||||
|
// PATHS::GetUserSettingsPath() for this build.
|
||||||
|
var seedKicadConfig = function() {
|
||||||
|
var cfgDir = '/home/kicad/.config/kicad/kicad/9.99';
|
||||||
|
FS.mkdirTree(cfgDir);
|
||||||
|
|
||||||
|
var writeIfAbsent = function(path, contents) {
|
||||||
|
try { FS.stat(path); return; } catch (e) { /* absent — seed it */ }
|
||||||
|
FS.writeFile(path, contents);
|
||||||
|
console.log('[KICAD] Seeded ' + path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// SETTINGS provider: settings dir is "valid" once kicad_common.json exists.
|
||||||
|
// PRIVACY provider: both prompts must be flagged do-not-show-again.
|
||||||
|
writeIfAbsent(cfgDir + '/kicad_common.json', JSON.stringify({
|
||||||
|
do_not_show_again: { update_check_prompt: true, data_collection_prompt: true }
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
// LIBRARIES provider: needs valid global symbol/footprint/design-block
|
||||||
|
// tables. Empty (zero-row) tables parse fine and satisfy GlobalTablesValid().
|
||||||
|
writeIfAbsent(cfgDir + '/sym-lib-table', '(sym_lib_table\n (version 7)\n)\n');
|
||||||
|
writeIfAbsent(cfgDir + '/fp-lib-table', '(fp_lib_table\n (version 7)\n)\n');
|
||||||
|
writeIfAbsent(cfgDir + '/design-block-lib-table', '(design_block_lib_table\n (version 7)\n)\n');
|
||||||
|
};
|
||||||
|
|
||||||
var Module = {
|
var Module = {
|
||||||
thisProgram: '/usr/bin/eeschema', // Fake absolute path for argv[0] (KiCad DEBUG check)
|
thisProgram: '/usr/bin/eeschema', // Fake absolute path for argv[0] (KiCad DEBUG check)
|
||||||
|
|
||||||
preRun: [createCanvas, writeResources],
|
preRun: [createCanvas, writeResources, seedKicadConfig],
|
||||||
postRun: [],
|
postRun: [],
|
||||||
|
|
||||||
print: function(text) {
|
print: function(text) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,21 @@ OUTPUT_DIR="$PROJECT_ROOT/output"
|
||||||
|
|
||||||
mkdir -p "$KICAD_TEST"
|
mkdir -p "$KICAD_TEST"
|
||||||
|
|
||||||
|
# Smart copy: skip when the destination already exists and is byte-identical to
|
||||||
|
# the source (cmp -s is portable across macOS/Linux). Makes this a real sync —
|
||||||
|
# re-running does not rewrite unchanged multi-hundred-MB .wasm files.
|
||||||
|
smart_cp() {
|
||||||
|
local src="$1" destdir="$2"
|
||||||
|
[ -f "$src" ] || return 0
|
||||||
|
local dst="$destdir/$(basename "$src")"
|
||||||
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||||
|
echo " = $(basename "$src") (up-to-date)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
cp "$src" "$dst"
|
||||||
|
echo " + $(basename "$src")"
|
||||||
|
}
|
||||||
|
|
||||||
# Map an app name to its inner CMake build subdirectory. Most apps share their
|
# Map an app name to its inner CMake build subdirectory. Most apps share their
|
||||||
# subdir name with the app name; pcb_calculator emits OUTPUT_NAME=calculator
|
# subdir name with the app name; pcb_calculator emits OUTPUT_NAME=calculator
|
||||||
# but lives under the pcb_calculator/ subtree of the build dir, and pl_editor's
|
# but lives under the pcb_calculator/ subtree of the build dir, and pl_editor's
|
||||||
|
|
@ -36,12 +51,12 @@ copy_app() {
|
||||||
subdir=$(kicad_subdir_for "$app")
|
subdir=$(kicad_subdir_for "$app")
|
||||||
|
|
||||||
if [ -f "$OUTPUT_DIR/${app}.js" ] && [ -f "$OUTPUT_DIR/${app}.wasm" ]; then
|
if [ -f "$OUTPUT_DIR/${app}.js" ] && [ -f "$OUTPUT_DIR/${app}.wasm" ]; then
|
||||||
echo "Copying ${app} WASM files from output directory..."
|
echo "Syncing ${app} WASM files from output directory..."
|
||||||
cp "$OUTPUT_DIR/${app}.js" "$KICAD_TEST/"
|
smart_cp "$OUTPUT_DIR/${app}.js" "$KICAD_TEST"
|
||||||
cp "$OUTPUT_DIR/${app}.wasm" "$KICAD_TEST/"
|
smart_cp "$OUTPUT_DIR/${app}.wasm" "$KICAD_TEST"
|
||||||
cp "$OUTPUT_DIR/${app}.wasm.map" "$KICAD_TEST/" 2>/dev/null || true
|
smart_cp "$OUTPUT_DIR/${app}.wasm.map" "$KICAD_TEST"
|
||||||
cp "$OUTPUT_DIR/${app}.worker.js" "$KICAD_TEST/" 2>/dev/null || true
|
smart_cp "$OUTPUT_DIR/${app}.worker.js" "$KICAD_TEST"
|
||||||
cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST/" 2>/dev/null || true
|
smart_cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -76,17 +91,17 @@ if [ "$found_any" -eq 0 ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# wxWidgets WASM JavaScript glue code (defines JS functions called from WASM)
|
# wxWidgets WASM JavaScript glue code (defines JS functions called from WASM)
|
||||||
echo "Copying wxWidgets WASM glue code..."
|
echo "Syncing wxWidgets WASM glue code..."
|
||||||
if [ -f "$OUTPUT_DIR/wx.js" ]; then
|
if [ -f "$OUTPUT_DIR/wx.js" ]; then
|
||||||
cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST/"
|
smart_cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST"
|
||||||
else
|
else
|
||||||
if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
|
if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
|
||||||
kicad-wasm-builder:/workspace/build-wasm/wxwidgets/build/wasm/wx.js "$KICAD_TEST/" 2>/dev/null; then
|
kicad-wasm-builder:/workspace/build-wasm/wxwidgets/build/wasm/wx.js "$KICAD_TEST/" 2>/dev/null; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST/"
|
smart_cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "KiCad WASM files copied to $KICAD_TEST"
|
echo "KiCad WASM files synced to $KICAD_TEST"
|
||||||
ls -lh "$KICAD_TEST"
|
ls -lh "$KICAD_TEST"
|
||||||
|
|
|
||||||
43
wasm/bindings/eeschema_embind.cpp
Normal file
43
wasm/bindings/eeschema_embind.cpp
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Embind bindings for KiCad eeschema WASM.
|
||||||
|
*
|
||||||
|
* Picked up automatically by scripts/kicad/build-kicad-target.sh when building
|
||||||
|
* the eeschema app (it compiles wasm/bindings/<app>_embind.cpp if present).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
#include <emscripten/bind.h>
|
||||||
|
#include <kiway_player.h>
|
||||||
|
#include <kiway.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <wx/app.h>
|
||||||
|
#include <wx/string.h>
|
||||||
|
#include <wx/window.h>
|
||||||
|
|
||||||
|
using namespace emscripten;
|
||||||
|
|
||||||
|
// Programmatically open a project file (schematic) in the running editor frame,
|
||||||
|
// without UI automation. Mirrors single_top.cpp's MacOpenFile path: the editor
|
||||||
|
// frame is the app's top window and is a KIWAY_PLAYER. Returns the result of
|
||||||
|
// OpenProjectFiles, or false if no frame is available — letting the JS caller
|
||||||
|
// fall back to driving File→Open.
|
||||||
|
bool kicadOpenFile( std::string path )
|
||||||
|
{
|
||||||
|
KIWAY_PLAYER* frame =
|
||||||
|
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||||
|
|
||||||
|
if( !frame )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
|
||||||
|
blocking->Close( true );
|
||||||
|
|
||||||
|
return frame->OpenProjectFiles(
|
||||||
|
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_BINDINGS(eeschema) {
|
||||||
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -14,10 +14,35 @@
|
||||||
#include <board.h>
|
#include <board.h>
|
||||||
#include <footprint.h>
|
#include <footprint.h>
|
||||||
#include <pad.h>
|
#include <pad.h>
|
||||||
|
#include <kiway_player.h>
|
||||||
|
#include <kiway.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <wx/app.h>
|
||||||
|
#include <wx/string.h>
|
||||||
|
#include <wx/window.h>
|
||||||
|
|
||||||
using namespace emscripten;
|
using namespace emscripten;
|
||||||
|
|
||||||
|
// Programmatically open a project file (board/schematic) in the running editor
|
||||||
|
// frame, without UI automation. Mirrors single_top.cpp's MacOpenFile path:
|
||||||
|
// the editor frame is the app's top window and is a KIWAY_PLAYER. Returns the
|
||||||
|
// result of OpenProjectFiles, or false if no frame is available — letting the
|
||||||
|
// JS caller fall back to driving File→Open.
|
||||||
|
bool kicadOpenFile( std::string path )
|
||||||
|
{
|
||||||
|
KIWAY_PLAYER* frame =
|
||||||
|
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||||
|
|
||||||
|
if( !frame )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
|
||||||
|
blocking->Close( true );
|
||||||
|
|
||||||
|
return frame->OpenProjectFiles(
|
||||||
|
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
// Wrapper to return footprints as vector for JS iteration
|
// Wrapper to return footprints as vector for JS iteration
|
||||||
std::vector<FOOTPRINT*> Board_GetFootprints(BOARD* board) {
|
std::vector<FOOTPRINT*> Board_GetFootprints(BOARD* board) {
|
||||||
if (!board) return {};
|
if (!board) return {};
|
||||||
|
|
@ -82,5 +107,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
function("Footprint_GetValue", &Footprint_GetValue, allow_raw_pointers());
|
function("Footprint_GetValue", &Footprint_GetValue, allow_raw_pointers());
|
||||||
function("Pad_GetNumber", &Pad_GetNumber, allow_raw_pointers());
|
function("Pad_GetNumber", &Pad_GetNumber, allow_raw_pointers());
|
||||||
function("Pad_GetPinFunction", &Pad_GetPinFunction, allow_raw_pointers());
|
function("Pad_GetPinFunction", &Pad_GetPinFunction, allow_raw_pointers());
|
||||||
|
|
||||||
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
29
web/.env.example
Normal file
29
web/.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Copy to web/.env and adjust as needed. docker-compose and the server both
|
||||||
|
# read this file.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
|
VITE_WASM_ASSET_BASE_URL=/wasm
|
||||||
11
web/.gitignore
vendored
Normal file
11
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
.data/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
# WASM artifacts: public/wasm is a symlink to the synced artifact dir
|
||||||
|
# (tests/apps/kicad), created on `dev` — never committed.
|
||||||
|
**/public/wasm
|
||||||
|
**/public/wasm/
|
||||||
111
web/README.md
Normal file
111
web/README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# KiCad Web
|
||||||
|
|
||||||
|
Single web app to create/open KiCad projects, upload files, and open them in the
|
||||||
|
WASM tools (pcbnew / eeschema / calculator) by URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
/p/<project>/<tool>/<file-path> e.g. /p/project5/pcbnew/nyak.kicad_pcb
|
||||||
|
```
|
||||||
|
|
||||||
|
Design + decisions: [`../features/web-init/0001-web-app-spec.md`](../features/web-init/0001-web-app-spec.md).
|
||||||
|
|
||||||
|
## 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`)
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
cp .env.example .env # Postgres host port defaults to 54329 (non-default)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
pnpm db:up # start Postgres (docker compose)
|
||||||
|
pnpm db:migrate # apply migrations + seed the default owner
|
||||||
|
|
||||||
|
pnpm dev # turbo: server :3050 + frontend :3048
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3048 — create a project, upload files (multi / folder /
|
||||||
|
.zip), then open a `.kicad_pcb` / `.kicad_sch` in its tool.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
**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`.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
`tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`.
|
||||||
|
|
||||||
|
The tool view (`WasmTool.tsx`) loads the **actual harness** (`/wasm/<tool>.html`,
|
||||||
|
the same page the e2e tests use) in a same-origin iframe, then injects the
|
||||||
|
project tree into its MEMFS and drives File→Open — reusing the proven loader
|
||||||
|
rather than re-implementing the Emscripten bootstrap.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
## 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 build` | build all packages |
|
||||||
|
|
||||||
|
## API (shared via `packages/contract`)
|
||||||
|
|
||||||
|
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).
|
||||||
12
web/apps/frontend/index.html
Normal file
12
web/apps/frontend/index.html
Normal 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>
|
||||||
40
web/apps/frontend/package.json
Normal file
40
web/apps/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/apps/frontend/postcss.config.js
Normal file
6
web/apps/frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
104
web/apps/frontend/scripts/link-wasm.mjs
Normal file
104
web/apps/frontend/scripts/link-wasm.mjs
Normal 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);
|
||||||
|
});
|
||||||
14
web/apps/frontend/src/App.tsx
Normal file
14
web/apps/frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
web/apps/frontend/src/components/UploadDropzone.tsx
Normal file
108
web/apps/frontend/src/components/UploadDropzone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
web/apps/frontend/src/components/WasmTool.tsx
Normal file
96
web/apps/frontend/src/components/WasmTool.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
web/apps/frontend/src/components/ui/button.tsx
Normal file
55
web/apps/frontend/src/components/ui/button.tsx
Normal 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 };
|
||||||
82
web/apps/frontend/src/components/ui/card.tsx
Normal file
82
web/apps/frontend/src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
||||||
106
web/apps/frontend/src/components/ui/dialog.tsx
Normal file
106
web/apps/frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
19
web/apps/frontend/src/components/ui/input.tsx
Normal file
19
web/apps/frontend/src/components/ui/input.tsx
Normal 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 };
|
||||||
20
web/apps/frontend/src/components/ui/label.tsx
Normal file
20
web/apps/frontend/src/components/ui/label.tsx
Normal 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 };
|
||||||
54
web/apps/frontend/src/index.css
Normal file
54
web/apps/frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
web/apps/frontend/src/lib/api.ts
Normal file
132
web/apps/frontend/src/lib/api.ts
Normal 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());
|
||||||
|
}
|
||||||
9
web/apps/frontend/src/lib/config.ts
Normal file
9
web/apps/frontend/src/lib/config.ts
Normal 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";
|
||||||
18
web/apps/frontend/src/lib/utils.ts
Normal file
18
web/apps/frontend/src/lib/utils.ts
Normal 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]}`;
|
||||||
|
}
|
||||||
21
web/apps/frontend/src/main.tsx
Normal file
21
web/apps/frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
98
web/apps/frontend/src/pages/ProjectDetailPage.tsx
Normal file
98
web/apps/frontend/src/pages/ProjectDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
web/apps/frontend/src/pages/ProjectsPage.tsx
Normal file
150
web/apps/frontend/src/pages/ProjectsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web/apps/frontend/src/pages/ToolPage.tsx
Normal file
41
web/apps/frontend/src/pages/ToolPage.tsx
Normal 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
10
web/apps/frontend/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
31
web/apps/frontend/src/wasm/constants.ts
Normal file
31
web/apps/frontend/src/wasm/constants.ts
Normal 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
53
web/apps/frontend/src/wasm/global.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
99
web/apps/frontend/src/wasm/kicad-runner.ts
Normal file
99
web/apps/frontend/src/wasm/kicad-runner.ts
Normal 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("");
|
||||||
|
}
|
||||||
175
web/apps/frontend/src/wasm/open-flow.ts
Normal file
175
web/apps/frontend/src/wasm/open-flow.ts
Normal 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";
|
||||||
|
}
|
||||||
53
web/apps/frontend/tailwind.config.js
Normal file
53
web/apps/frontend/tailwind.config.js
Normal 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],
|
||||||
|
};
|
||||||
15
web/apps/frontend/tsconfig.json
Normal file
15
web/apps/frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
59
web/apps/frontend/vite.config.ts
Normal file
59
web/apps/frontend/vite.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
17
web/apps/server/drizzle.config.ts
Normal file
17
web/apps/server/drizzle.config.ts
Normal 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 ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
31
web/apps/server/drizzle/0000_lucky_husk.sql
Normal file
31
web/apps/server/drizzle/0000_lucky_husk.sql
Normal 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;
|
||||||
222
web/apps/server/drizzle/meta/0000_snapshot.json
Normal file
222
web/apps/server/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/apps/server/drizzle/meta/_journal.json
Normal file
13
web/apps/server/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780307401097,
|
||||||
|
"tag": "0000_lucky_husk",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
web/apps/server/package.json
Normal file
37
web/apps/server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
web/apps/server/src/app.ts
Normal file
32
web/apps/server/src/app.ts
Normal 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;
|
||||||
|
}
|
||||||
9
web/apps/server/src/db/index.ts
Normal file
9
web/apps/server/src/db/index.ts
Normal 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;
|
||||||
21
web/apps/server/src/db/migrate.ts
Normal file
21
web/apps/server/src/db/migrate.ts
Normal 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);
|
||||||
|
});
|
||||||
62
web/apps/server/src/db/schema.ts
Normal file
62
web/apps/server/src/db/schema.ts
Normal 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;
|
||||||
43
web/apps/server/src/db/seed.ts
Normal file
43
web/apps/server/src/db/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
21
web/apps/server/src/env.ts
Normal file
21
web/apps/server/src/env.ts
Normal 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;
|
||||||
47
web/apps/server/src/lib/paths.ts
Normal file
47
web/apps/server/src/lib/paths.ts
Normal 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";
|
||||||
|
}
|
||||||
51
web/apps/server/src/routes/api.ts
Normal file
51
web/apps/server/src/routes/api.ts
Normal 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);
|
||||||
117
web/apps/server/src/routes/files.ts
Normal file
117
web/apps/server/src/routes/files.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
12
web/apps/server/src/server.ts
Normal file
12
web/apps/server/src/server.ts
Normal 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);
|
||||||
|
});
|
||||||
196
web/apps/server/src/services/projects.ts
Normal file
196
web/apps/server/src/services/projects.ts
Normal 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]!);
|
||||||
|
}
|
||||||
16
web/apps/server/src/storage.ts
Normal file
16
web/apps/server/src/storage.ts
Normal 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}`;
|
||||||
|
}
|
||||||
8
web/apps/server/tsconfig.json
Normal file
8
web/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"],
|
||||||
|
"lib": ["ES2022"]
|
||||||
|
},
|
||||||
|
"include": ["src", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
26
web/docker-compose.yml
Normal file
26
web/docker-compose.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 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:
|
||||||
27
web/package.json
Normal file
27
web/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "kicad-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.5.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["esbuild"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
web/packages/contract/package.json
Normal file
19
web/packages/contract/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
web/packages/contract/src/index.ts
Normal file
79
web/packages/contract/src/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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;
|
||||||
67
web/packages/contract/src/schemas.ts
Normal file
67
web/packages/contract/src/schemas.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** WASM tools that can be selected by the `:tool` URL segment. */
|
||||||
|
export const TOOLS = ["pcbnew", "eeschema", "calculator"] as const;
|
||||||
|
export const toolSchema = z.enum(TOOLS);
|
||||||
|
export type Tool = z.infer<typeof toolSchema>;
|
||||||
|
|
||||||
|
/** Default file-extension → tool mapping (the explicit URL segment always wins). */
|
||||||
|
export const EXTENSION_TOOL: Record<string, Tool> = {
|
||||||
|
".kicad_pcb": "pcbnew",
|
||||||
|
".kicad_sch": "eeschema",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tools that do not take a file (booted standalone). */
|
||||||
|
export const FILELESS_TOOLS: ReadonlySet<Tool> = new Set<Tool>(["calculator"]);
|
||||||
|
|
||||||
|
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>;
|
||||||
4
web/packages/contract/tsconfig.json
Normal file
4
web/packages/contract/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
web/packages/storage/package.json
Normal file
16
web/packages/storage/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
web/packages/storage/src/index.ts
Normal file
22
web/packages/storage/src/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
web/packages/storage/src/local-disk.ts
Normal file
98
web/packages/storage/src/local-disk.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
web/packages/storage/src/types.ts
Normal file
33
web/packages/storage/src/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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>;
|
||||||
|
}
|
||||||
7
web/packages/storage/tsconfig.json
Normal file
7
web/packages/storage/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4086
web/pnpm-lock.yaml
generated
Normal file
4086
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
web/pnpm-workspace.yaml
Normal file
3
web/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
19
web/tsconfig.base.json
Normal file
19
web/tsconfig.base.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
28
web/turbo.json
Normal file
28
web/turbo.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"globalEnv": [
|
||||||
|
"DATABASE_URL",
|
||||||
|
"PORT",
|
||||||
|
"STORAGE_ROOT",
|
||||||
|
"STORAGE_DRIVER",
|
||||||
|
"CORS_ORIGIN",
|
||||||
|
"DEFAULT_OWNER_SLUG",
|
||||||
|
"WASM_SRC_DIR",
|
||||||
|
"VITE_API_BASE_URL",
|
||||||
|
"VITE_WASM_ASSET_BASE_URL"
|
||||||
|
],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"lint": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue