Compare commits

...
40 Commits
Author SHA1 Message Date
forkless 2d9b052b9a docs: update handoff with MCP fixes and favicon suppressor 2026-06-25 02:18:39 +02:00
forkless b1ae502435 fix: suppress favicon.ico 404 with empty data URI
Docker Build (ARM64) / Build & Push (push) Successful in 2m5s
2026-06-25 02:06:23 +02:00
forkless ca559acb3f fix: delay exit on stdin close to prevent MCP instability
Docker Build (ARM64) / Build & Push (push) Successful in 2m58s
2026-06-25 01:50:40 +02:00
forkless 53faaca5ab fix: skip local canvas server start when EXPRESS_SERVER_URL is set
Docker Build (ARM64) / Build & Push (push) Has been cancelled
2026-06-25 01:47:10 +02:00
forkless 2ee3317b00 fix: remove auto-title junk from create_element handler
Docker Build (ARM64) / Build & Push (push) Successful in 2m53s
2026-06-25 01:31:54 +02:00
forkless 40b882672a fix: skip X-Tenant-Id header when EXPRESS_SERVER_URL points to remote
Docker Build (ARM64) / Build & Push (push) Successful in 2m53s
2026-06-25 01:16:04 +02:00
forkless bb710b46b0 fix: suppress CORS flood from excalidraw.com/og-image-3.png fetch
Docker Build (ARM64) / Build & Push (push) Successful in 2m4s
2026-06-25 00:52:35 +02:00
forkless 9e53a9b92c docs: update handoff with CUSTOM_VITE_WS_URL and env var details 2026-06-24 21:16:39 +02:00
forkless 7f01d76c2b fix: preserve ws:// vs wss:// scheme in URL normalization
Docker Build (ARM64) / Build & Push (push) Successful in 2m3s
2026-06-24 21:01:31 +02:00
forkless a1db6e782d feat: fully normalize CUSTOM_VITE_WS_URL (strip scheme/path, keep port)
Docker Build (ARM64) / Build & Push (push) Successful in 2m2s
2026-06-24 20:59:17 +02:00
forkless 7222c9ae63 feat: auto-prefix wss:// if CUSTOM_VITE_WS_URL is just host
Docker Build (ARM64) / Build & Push (push) Successful in 2m2s
2026-06-24 20:56:35 +02:00
forkless a8ee1c229e feat: env-var-ize WS URL as CUSTOM_VITE_WS_URL, add .env.example
Docker Build (ARM64) / Build & Push (push) Successful in 50s
2026-06-24 20:55:02 +02:00
forkless a06c3d2e36 cleanup: remove broken patch script ref + unused collab props
Docker Build (ARM64) / Build & Push (push) Successful in 2m1s
2026-06-24 20:25:09 +02:00
forkless bc5c924c8e ci: rebuild
Docker Build (ARM64) / Build & Push (push) Failing after 39s
2026-06-24 19:48:17 +02:00
forkless cc3403363c ci: rebuild with URL patch 2026-06-24 19:47:55 +02:00
forkless e44053a25d docs: agent handoff for ARM64 build & collab setup 2026-06-24 19:44:18 +02:00
forkless 6ed842dae9 fix: patch hardcoded collab URL in Excalidraw bundle at build time
Docker Build (ARM64) / Build & Push (push) Failing after 2m26s
2026-06-24 19:26:56 +02:00
forkless 0926415ffa fix: enable trust proxy for NPM reverse proxy
Docker Build (ARM64) / Build & Push (push) Successful in 2m57s
2026-06-24 19:21:26 +02:00
forkless ac638a3586 ci: retry build
Docker Build (ARM64) / Build & Push (push) Successful in 8s
2026-06-24 18:58:30 +02:00
forkless 3de5951e48 ci: retry build 2026-06-24 18:56:58 +02:00
forkless 5775ec1e3b ci: trigger build [force]
Docker Build (ARM64) / Build & Push (push) Failing after 5s
2026-06-24 18:55:36 +02:00
forkless d50de9232f ci: fresh build 2026-06-24 18:55:16 +02:00
forkless 547fd6affb fix: add onCollabDialogOpen to show share button
Docker Build (ARM64) / Build & Push (push) Successful in 2m3s
2026-06-24 18:40:40 +02:00
forkless 460671e753 perf: switch to node:20-alpine base images
Docker Build (ARM64) / Build & Push (push) Failing after 5s
2026-06-24 18:32:35 +02:00
forkless 32188d93b4 fix: collab WS path /socket.io for Excalidraw library
Docker Build (ARM64) / Build & Push (push) Has been cancelled
2026-06-24 18:31:37 +02:00
forkless 27dbc1f40e fix: back to runs-on ubuntu-latest, both runners compete
Docker Build (ARM64) / Build & Push (push) Successful in 1m28s
2026-06-24 18:27:55 +02:00
forkless 2b6500c697 fix: target ARM64 runner with label arm64
Docker Build (ARM64) / Build & Push (push) Failing after 7s
2026-06-24 18:25:36 +02:00
forkless 0e8c855818 revert: back to plain docker build (no buildx)
Docker Build (ARM64) / Build & Push (push) Failing after 1m30s
2026-06-24 18:23:10 +02:00
forkless 936dd63976 fix: use buildx --platform linux/arm64 for ARM64 cross-build
Docker Build (ARM64) / Build & Push (push) Has been cancelled
2026-06-24 18:22:41 +02:00
forkless 4dceeb2e7f fix: point frontend WS to wss://excalidraw.forkless.com
Docker Build (ARM64) / Build & Push (push) Successful in 2m2s
2026-06-24 18:18:19 +02:00
forkless a4a1b03215 fix: redirect collab WS to excalidraw.forkless.com
Docker Build (ARM64) / Build & Push (push) Successful in 2m24s
2026-06-24 18:09:31 +02:00
forkless f91082d2a3 fix: disable Excalidraw collaboration WS to oss-collab.excalidraw.com
Docker Build (ARM64) / Build & Push (push) Has been cancelled
2026-06-24 18:08:40 +02:00
forkless 82574e7308 ci: trigger build
Docker Build (ARM64) / Build & Push (push) Successful in 4m49s
2026-06-24 16:53:40 +02:00
forkless 8d99a58600 ci: trigger build with secrets 2026-06-24 16:52:47 +02:00
forkless f1b71ca5b8 ci: trigger build [force]
Docker Build (ARM64) / Build & Push (push) Failing after 5s
2026-06-24 16:37:49 +02:00
forkless a092e5b548 ci: trigger docker build 2026-06-24 16:36:11 +02:00
forkless 37be53dae8 fix: add .gitea/** to workflow path triggers
Docker Build (ARM64) / Build & Push (push) Failing after 6s
2026-06-24 16:31:26 +02:00
forkless a8723f6875 ci: ARM64 Docker build via Gitea Actions 2026-06-24 16:24:50 +02:00
046719aabc feat(transport): Streamable HTTP mode — one shared MCP process for all sessions (v1.2.0) (#13)
* chore(ci): release fires on workflow_dispatch, not on every CI pass

Replaced workflow_run trigger (fired automatically when CI completed
on main) with workflow_dispatch. The full semantic-release automation
is preserved — bump detection, version commit, tag, GitHub Release,
NPM + Docker publish — but now runs only when explicitly triggered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(transport): add Streamable HTTP mode — one shared process for all MCP clients (v1.2.0)

Add MCP_TRANSPORT=http mode backed by StreamableHTTPServerTransport. Each
Claude Code session connects to the shared long-lived process via HTTP (port
3031 by default) instead of spawning a new stdio process per session, eliminating
per-session process multiplication. Sessions are isolated by mcp-session-id header.

Also refactor src/index.ts to extract createMcpServer()/registerHandlers() for
clean per-session server instantiation, and upgrade fs.writeFileSync/readFileSync
calls to fs.promises async variants in export/import tool handlers.

9 new tests cover transport resolution, session isolation, teardown, and
startMcpHttpServer. All 528 tests pass. Bumps v1.1.0 → v1.2.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: newblacc <newblacc@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:51:08 +02:00
9846e0ba0f feat(canvas): native field preservation, label materialization, batch workspace delete (#12)
* feat(canvas): native field preservation, label materialization, batch workspace delete

- fillNativeFields() + repairContainerBinding() in db layer ensure every
  element stored in SQLite is a complete, round-trippable Excalidraw element
  with correct containerId ↔ boundElements bidirectional binding
- materializeLabel() in server.ts: shapes with label.text/text produce a
  native bound text element at write time (create, update, batch) so text
  follows its container when moved — matches VSCode Excalidraw extension behavior
- Batch workspace UI: Select/Unselect All, per-row checkboxes, Delete N
  workspaces button with confirmation
- POST /api/tenants/batch-delete: delete up to 50 tenants in one request
- 42 new backend tests; 519 total passing
- Bump version 1.0.6 → 1.1.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update e2e assertions for label materialization

phase2-regressions: label is now a native bound text element
(id: pos-stable-1-label) — check bound text element text instead
of container.label?.text which is no longer stored.

sync-flows FTS: search now matches the bound text element (fts-el-label)
rather than the container — accept either id as valid match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:12:01 +02:00
24 changed files with 2125 additions and 134 deletions
+98
View File
@@ -0,0 +1,98 @@
# Gitea Actions: ARM64 native Docker build
name: Docker Build (ARM64)
on:
push:
branches: [main]
paths:
- 'Dockerfile'
- 'Dockerfile.canvas'
- 'src/**'
- 'frontend/**'
- 'package*.json'
- '.dockerignore'
- '.gitea/**'
pull_request:
branches: [main]
paths:
- 'Dockerfile'
- 'Dockerfile.canvas'
- 'src/**'
- 'frontend/**'
- 'package*.json'
- '.dockerignore'
- '.gitea/**'
workflow_dispatch:
concurrency:
group: docker-arm64-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.forkless.com
IMAGE_MCP: gitea.forkless.com/forkless/excalidraw-mcp-sentinel
IMAGE_CANVAS: gitea.forkless.com/forkless/excalidraw-mcp-sentinel-canvas
jobs:
docker:
name: Build & Push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build MCP Server
run: |
docker build \
--tag ${{ env.IMAGE_MCP }}:latest \
--tag ${{ env.IMAGE_MCP }}:${{ github.sha }} \
--file Dockerfile .
- name: Build Canvas Server
run: |
docker build \
--tag ${{ env.IMAGE_CANVAS }}:latest \
--tag ${{ env.IMAGE_CANVAS }}:${{ github.sha }} \
--file Dockerfile.canvas .
- name: Login to Gitea Container Registry
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
run: |
echo "${{ secrets.GITEATOKEN }}" | \
docker login ${{ env.REGISTRY }} \
--username "${{ secrets.GITEAUSER }}" \
--password-stdin
- name: Push latest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
run: |
docker push ${{ env.IMAGE_MCP }}:latest
docker push ${{ env.IMAGE_MCP }}:${{ github.sha }}
docker push ${{ env.IMAGE_CANVAS }}:latest
docker push ${{ env.IMAGE_CANVAS }}:${{ github.sha }}
- name: Tag version
if: startsWith(github.ref, 'refs/tags/v')
run: |
VERSION=${GITHUB_REF#refs/tags/v}
docker tag ${{ env.IMAGE_MCP }}:latest ${{ env.IMAGE_MCP }}:$VERSION
docker tag ${{ env.IMAGE_CANVAS }}:latest ${{ env.IMAGE_CANVAS }}:$VERSION
docker push ${{ env.IMAGE_MCP }}:$VERSION
docker push ${{ env.IMAGE_CANVAS }}:$VERSION
- name: Smoke test (PR only)
if: github.event_name == 'pull_request'
run: |
docker run -d --network host \
--name test-canvas \
${{ env.IMAGE_CANVAS }}:${{ github.sha }}
trap "docker rm -f test-canvas" EXIT
for i in $(seq 1 15); do
if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
echo "Canvas healthy"
exit 0
fi
sleep 1
done
echo "Health check timeout"
exit 1
+2 -5
View File
@@ -1,10 +1,7 @@
name: Release & Publish
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
concurrency:
group: release-main
@@ -18,7 +15,7 @@ jobs:
check:
name: Check for releasable commits
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
if: always()
outputs:
bump: ${{ steps.bump.outputs.bump }}
new_version: ${{ steps.bump.outputs.new_version }}
+40
View File
@@ -5,6 +5,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
## [1.1.0] - 2026-04-06
### Added
- Batch workspace select/delete UI: Select/Unselect All, per-row checkboxes,
Delete N workspaces button with confirmation — active workspace is disabled
from selection
- `POST /api/tenants/batch-delete` endpoint — delete up to 50 tenants in one
request with per-tenant cascade (projects, elements, snapshots)
- `fillNativeFields()` in db layer — fills all universal and type-specific
native Excalidraw fields (angle, strokeColor, roundness, seed, etc.) on
every write so elements are identical to those produced by the VSCode
Excalidraw extension
- `repairContainerBinding()` in db layer — enforces bidirectional
`containerId``boundElements` binding on every write path (create, update,
batch, sync/v2) so text labels always follow their container when moved
- Server-side label materialization (`materializeLabel` in server.ts): MCP
`create_element`/`update_element` calls with `label.text` or `text` on a
shape now produce a native bound text element in the DB instead of an MCP
label stub — no synthetic generation required on export
- 42 new backend non-regression tests for native field preservation, container
binding repair, and label materialization (519 total)
## [1.0.6] - 2026-04-06
### Added
@@ -134,3 +156,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
### Fixed
- `npm install -g excalidraw-mcp-sentinel` crashed on Windows — `postinstall` script used Unix-only `2>/dev/null || true` syntax which cmd.exe does not support; replaced with a cross-platform `node -e` inline script
- 2026-05-14: chore(ci): release workflow now manual (workflow_dispatch) -- no longer fires automatically on every CI pass on main
## [1.2.0] - 2026-05-20
### Added
- Streamable HTTP transport mode (`MCP_TRANSPORT=http`): single long-lived process serves all MCP clients over HTTP instead of spawning a new stdio process per session. Each client gets its own isolated `Server` instance routed by `mcp-session-id` header. Eliminates per-session process overhead for multi-session setups.
- `src/mcp-http.ts`: `mountMcpRoutes`, `startMcpHttpServer`, `resolveTransportMode` — full HTTP session lifecycle (POST/GET/DELETE /mcp, session map, `StreamableHTTPServerTransport`)
- `createMcpServer()` factory and `registerHandlers()` in `src/index.ts` — clean per-session server instantiation for HTTP mode
- 9 new tests in `tests/backend/mcp-http.test.ts` covering transport resolution, session isolation, session teardown, and `startMcpHttpServer`
- `CLAUDE.md`: Strict Installation Decoupling rule
- launchd agent (`~/Library/LaunchAgents/com.user.excalidraw-mcp.plist`) for single-instance persistence on macOS
### Changed
- `runServer()` now checks `MCP_TRANSPORT` env var; defaults to stdio (backward-compatible)
- `fs.writeFileSync`/`readFileSync` calls in export/import tool handlers converted to `fs.promises` async variants
### Total tests: 528 (31 files)
+4
View File
@@ -136,3 +136,7 @@ When exploring or understanding code in supported languages (JS, TS, Python, Go,
- Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+)
- Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens)
- Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines
## Strict Installation Decoupling
Once installed (e.g., to ~/.local/bin), the project binary must NEVER depend on the local repository path (~/DevOpsSec) for execution, configuration, or data. All paths must be relative to the installation root or use standard system config paths (~/.config).
+7 -7
View File
@@ -2,9 +2,9 @@
# Builds the MCP server with SQLite persistence
# Stage 1: Build backend (TypeScript compilation + native modules)
FROM node:20-slim AS builder
FROM node:20-alpine AS builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache python3 make g++
WORKDIR /app
@@ -16,12 +16,12 @@ COPY tsconfig.json ./
RUN npm run build:server
# Stage 2: Production MCP Server
FROM node:20-slim AS production
FROM node:20-alpine AS production
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --gid 1001 nodejs
RUN addgroup -S -g 1001 nodejs && \
adduser -S -u 1001 -G nodejs nodejs
WORKDIR /app
@@ -29,7 +29,7 @@ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Remove build tools after native modules are compiled
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
RUN apk del python3 make g++
COPY --from=builder /app/dist ./dist
+12 -9
View File
@@ -1,11 +1,14 @@
# Dockerfile for Canvas Server (Optional)
# Provides the web interface, REST API, and SQLite persistence
# Provides the web interface, REST API, and SQLite persistence (Alpine)
# Stage 1: Build frontend
FROM node:20-slim AS frontend-builder
FROM node:20-alpine AS frontend-builder
WORKDIR /app
ARG CUSTOM_VITE_WS_URL
ENV CUSTOM_VITE_WS_URL=$CUSTOM_VITE_WS_URL
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts
@@ -14,9 +17,9 @@ COPY vite.config.js ./
RUN npm run build:frontend
# Stage 2: Build backend (TypeScript compilation + native modules)
FROM node:20-slim AS backend-builder
FROM node:20-alpine AS backend-builder
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache python3 make g++
WORKDIR /app
@@ -28,12 +31,12 @@ COPY tsconfig.json ./
RUN npm run build:server
# Stage 3: Production Canvas Server
FROM node:20-slim AS production
FROM node:20-alpine AS production
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 --gid 1001 nodejs
RUN addgroup -S -g 1001 nodejs && \
adduser -S -u 1001 -G nodejs nodejs
WORKDIR /app
@@ -41,7 +44,7 @@ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Remove build tools after native modules are compiled
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
RUN apk del python3 make g++
COPY --from=backend-builder /app/dist ./dist
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
+2 -1
View File
@@ -497,7 +497,7 @@ Each workspace (codebase) gets an isolated canvas. The tenant is identified by a
1. **Auto-detection**: When the MCP starts, it calls `server.listRoots()` to get the actual workspace path from the MCP client. This is hashed to create a unique tenant ID.
2. **Per-request scoping**: Every HTTP request includes an `X-Tenant-Id` header. The canvas server uses this to scope all CRUD operations to the correct tenant.
3. **UI switcher**: The canvas UI shows a "Workspace: &lt;name&gt;" badge. Click it to open a dropdown with all known workspaces, complete with search.
3. **UI switcher**: The canvas UI shows a "Workspace: &lt;name&gt;" badge. Click it to open a dropdown with all known workspaces, complete with search and bulk management. Use **Select** to enter multi-select mode, check individual workspaces, then **Delete N workspaces** to batch-remove them (with confirmation). **Select All** / **Unselect All** shortcuts are available in selection mode.
4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances.
### Projects within a tenant
@@ -759,6 +759,7 @@ The canvas server exposes a REST API alongside the WebSocket interface:
| DELETE | `/api/projects/:id` | Delete a project (cascades elements) |
| GET | `/api/tenants` | List all tenants |
| DELETE | `/api/tenants/:id` | Delete a tenant (cascades projects and elements) |
| POST | `/api/tenants/batch-delete` | Delete multiple tenants in one request — body: `{ ids: string[] }` (max 50) |
| GET | `/api/tenant/active` | Get the active tenant |
| PUT | `/api/tenant/active` | Set the active tenant |
| GET | `/api/settings/:key` | Read a setting |
+2
View File
@@ -15,6 +15,8 @@ services:
build:
context: .
dockerfile: Dockerfile.canvas
args:
- CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com}
image: celstnblacc/excalidraw-mcp-sentinel-canvas:latest
container_name: mcp-excalidraw-canvas
ports:
+241
View File
@@ -0,0 +1,241 @@
# Agent Handoff: ARM64 Docker Build & Collaboration Setup
## Overview
Gitea server at `gitea.forkless.com` (RPi 5, native ARM64) hosts the repo
`forkless/excalidraw-mcp-sentinel`. An ARM64 Gitea Actions runner is registered
on the Gitea server itself (labels: `arm64`, `ubuntu-latest`). Runner #1.
Local workspace: `/home/pe1085/development/excalidraw-mcp-sentinel`
Remote: `https://gitea.forkless.com/forkless/excalidraw-mcp-sentinel`
---
## What Was Done
### 1. Gitea Actions Workflow
`.gitea/workflows/docker.yml` — builds both Docker images on ARM64 runner:
- `gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest`
- `gitea.forkless.com/forkless/excalidraw-mcp-sentinel-canvas:latest`
Triggers on push to `main` (paths: Dockerfile, src/**, frontend/**, .gitea/**),
PRs, and `workflow_dispatch`. **Avoid empty commits** — they don't match the
`paths` filter and won't trigger a build.
### 2. Alpine Base Images
Both `Dockerfile` and `Dockerfile.canvas` changed from `node:20-slim` to
`node:20-alpine`. Build tools via `apk add --no-cache python3 make g++`.
### 3. AMD64 Runner Removed
There was a local x86_64 Gitea Actions runner (`wsl-runner`, id=1) on this
machine that competed with the ARM64 runner. It was stopped and its `.runner`
file deleted from `/home/pe1085/development/gitea-test/`. It had
`ubuntu-latest:docker://catthehacker/ubuntu:full-latest` — would build amd64
images that can't run on ARM64. **Do not re-register it.** Only the RPi 5
ARM64 runner should exist.
### 4. Frontend WebSocket URL — `CUSTOM_VITE_WS_URL`
**New env var** `CUSTOM_VITE_WS_URL` controls the frontend's WebSocket target.
Hardcoded `wss://excalidraw.forkless.com` replaced with a Vite build-time define.
**Where:**
- `vite.config.js` — reads `process.env.CUSTOM_VITE_WS_URL`, defaults to
`wss://excalidraw.forkless.com`, exposes as `import.meta.env.CUSTOM_VITE_WS_URL`
- `frontend/src/App.tsx` — uses `import.meta.env.CUSTOM_VITE_WS_URL`
**URL normalization** (auto handles user input):
- `excalidraw.forkless.com``wss://excalidraw.forkless.com`
- `wss://excalidraw.forkless.com/socket.io/foo``wss://excalidraw.forkless.com`
- `ws://dev.local:5173``ws://dev.local:5173` (preserves ws://)
- `https://example.com:3002/path``wss://example.com:3002`
- `10.0.101.100``wss://10.0.101.100`
- `[::1]:3000``wss://[::1]:3000`
- Port is preserved if present, omitted if absent.
### 5. `.env.example`
Created at repo root. Documents all key env vars.
```env
CUSTOM_VITE_WS_URL=excalidraw.forkless.com # auto-normalized
CANVAS_PORT=3000
HOST=0.0.0.0
EXCALIDRAW_API_KEY=
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
EXCALIDRAW_DB_PATH=~/.excalidraw-mcp/excalidraw.db
LOG_LEVEL=info
```
### 6. Docker Build Integration
`Dockerfile.canvas` frontend-builder stage accepts `ARG CUSTOM_VITE_WS_URL`
and sets it as `ENV` so Vite picks it up at build time.
`docker-compose.yml` canvas service passes it as build arg:
```yaml
args:
- CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com}
```
To override at build time:
```bash
docker compose build --build-arg CUSTOM_VITE_WS_URL=wss://my-server.com canvas
# or
CUSTOM_VITE_WS_URL=my-server.com docker compose build canvas
```
### 7. Reverse Proxy Config (NPM v2.15.1 / OpenResty)
The server at `excalidraw.forkless.com` runs behind Nginx Proxy Manager.
**Critical settings for WebSocket to work:**
- **WebSockets Support** toggle enabled in NPM
- The WebSocket upgrade headers (`Upgrade`, `Connection`) are forwarded
- **Do NOT** add custom nginx proxy config in the Advanced tab unless the
toggle alone doesn't work — the toggle handles it. Custom config can
conflict and break NPM entirely (requires container restart to recover).
### 8. `trust proxy` in Express
`src/server.ts` line 43:
```ts
app.set('trust proxy', 1);
```
Required because NPM sets `X-Forwarded-For` and express-rate-limit rejects
requests without this setting.
### 9. `ALLOWED_ORIGINS` Env Var
The `verifyWsClient()` in `src/security.ts` checks `Origin` header against
`ALLOWED_ORIGINS`. Default: `http://localhost:3000`, `http://127.0.0.1:3000`.
**Must include `https://excalidraw.forkless.com` for production.**
### 10. Gitea Actions Secrets
Set in Gitea repo → Settings → Actions → Secrets:
- `GITEAUSER` = `forkless`
- `GITEATOKEN` = Gitea PAT with `write:repository` scope
### 11. `collabServerUrl` Prop (Removed)
`collabServerUrl` and `onCollabDialogOpen` props were removed from App.tsx.
These are **not documented/working props** in `@excalidraw/excalidraw` v0.18.x
and were ignored by the library. The collab URL is hardcoded in the npm bundle
and connects to `wss://oss-collab.excalidraw.com` by default — this is fine.
### 12. CORS Flood Suppression (`og-image-3.png`)
**Problem:** `@excalidraw/excalidraw` v0.18 internally fetches
`https://excalidraw.com/og-image-3.png` (social-share preload) via `fetch()` on
mount. On non-excalidraw.com origins, the server's CORS header doesn't match →
6× CORS errors flood the console, drowning out real logs.
**Fix:** fetch interceptor in `frontend/index.html` `<head>`.
Returns 204 No Content immediately for any `fetch()` whose URL contains
`og-image-3.png`. Request never leaves the browser → no CORS preflight →
no error.
```html
<script>
window.fetch = new Proxy(window.fetch, {
apply(target, thisArg, args) {
var url = args[0];
if (typeof url === 'string' && url.indexOf('og-image-3.png') !== -1) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Reflect.apply(target, thisArg, args);
}
});
</script>
```
**Why not nginx:** The browser fetches directly from `excalidraw.com`, not
through the fork's reverse proxy. Nginx on the fork domain never sees the
request. A DNS-level intercept (pointing `excalidraw.com` at your own nginx)
would work, but requires SSL certs for `excalidraw.com` and control over DNS
resolution — not available in a hosted web-editor NPM setup.
**Scope:** Single hardcoded URL inside the npm package — no other references in
the repo's own code (`src/`, `frontend/`, configs, docs — 111 files searched, 0
hits).
**To verify:** Open browser console after deploy — zero CORS errors from
`og-image-3.png`.
### 13. MCP Server Fixes
**Code changes in `src/index.ts`:**
1. **Auto-title junk removed** (`create_element` handler) — no longer auto-injects "Title"/"Text here" on containers when `text` is provided. Card layout still works via explicit `title`+`subtitle` params.
2. **X-Tenant-Id header scoped to local**`canvasHeaders()` sends `X-Tenant-Id` only when `EXPRESS_SERVER_URL` is unset (local canvas). Remote canvases use default tenant routing.
3. **Skip local canvas server**`startCanvasServer()` is skipped entirely when `EXPRESS_SERVER_URL` points to a remote host. Prevents port conflicts.
4. **MCP process stability**`stdin.on('close')` handler uses `setTimeout(shutdown, 1000)` instead of immediate `process.exit(0)`. Prevents crash on transient disconnects during CodeWhale init.
**MCP config (`~/.codewhale/mcp.json`):**
- Server registered as `excalidraw` with stdio transport
- Points to `https://excalidraw.forkless.com` (production)
- Uses `/tmp/excalidraw-mcp.db` for local DB (separate from production)
**Known issue:** Auto-title junk still appears when production canvas runs old Docker image. The fix is in `main` but the container must be redeployed. Until then, delete stray `_title`/`_subtitle` elements via API.
### 14. Favicon Suppressor
`frontend/index.html` — added `<link rel="icon" href="data:,">` to prevent 404 on `/favicon.ico`. Combined with the fetch interceptor (section 12), the console is now clean of network errors.
---
## What Still Needs Work
### 1. WebSocket Auth Hardening
When `EXCALIDRAW_API_KEY` is set, the WS requires auth. The frontend sends
the key in a `hello` message after receiving `auth_required`. Without the
key, the connection drops. Consider:
- Rate-limit WS connections by IP
- Reject no-origin connections in `verifyWsClient` (currently allows)
### 2. Docker Image Tags
Only `:latest` and `:$sha` are pushed. Version tags (`v*`) trigger is in
the workflow but untested.
### 3. Production Container Needs Redeploy
The auto-title fix (section 13) is committed and the Docker image is built,
but the running container at `excalidraw.forkless.com` still runs the old
image. Until redeployed, elements created via REST API batch will have
stray `_title`/`_subtitle` junk elements. Delete them via:
```bash
curl -s https://excalidraw.forkless.com/api/elements | python3 -c "import sys,json;d=json.load(sys.stdin);[print(e['id']) for e in d['elements'] if e.get('text') in ('Title','Text here')]" | xargs -I{} curl -s -X DELETE "https://excalidraw.forkless.com/api/elements/{}"
```
---
## Credentials & Access
- **Gitea API token**: stored in `~/.profile` as `GITEA_TOKEN`
- **Git credentials**: stored via `git credential-store` for
`gitea.forkless.com` — username `forkless`, password = Gitea PAT
- To use the token in exec_shell:
`source ~/.profile && echo "$GITEA_TOKEN"` (DO NOT source `~/.profile`
if the user objects — but the token is stored there)
- Alternative: read via git credential helper:
`TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2)`
### Git Credential Helper Fix
The system had a broken `credential.helper` for `gitea.forkless.com` pointing
to `!tea login helper` — but the binary is `tea-cli`, not `tea`. Fixed with:
```bash
git config --global --replace-all credential.https://gitea.forkless.com.helper '!tea-cli login helper'
```
---
## Quick Commands
```bash
# Check latest build
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -H "Authorization: token $TOKEN" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/runs?limit=5" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(f'Run {r[\"id\"]}: {r[\"status\"]:>10} {r.get(\"conclusion\",\"-\"):>10} {r[\"head_sha\"][:12]}') for r in d.get('workflow_runs',[])]"
# Check Docker image manifest
docker manifest inspect gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest
# Push (credential helper auto-handles auth)
git push origin main
# Trigger dispatch (if paths filter blocks push trigger)
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/workflows/docker.yml/dispatches" -d '{"ref":"main"}'
# Build canvas with custom WS URL
CUSTOM_VITE_WS_URL=my-ws-server.com docker compose build canvas
```
+122
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<title>Excalidraw POC - Backend API Integration</title>
<style>
body {
@@ -402,6 +403,9 @@
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.menu-search-wrap {
padding: 8px 10px 4px;
@@ -573,6 +577,110 @@
}
.project-delete-no:hover { background: #dee2e6; }
/* Batch selection mode */
.batch-mode-toggle {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f8f9fa;
color: #555;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
transition: background 0.15s, border-color 0.15s;
}
.batch-mode-toggle:hover { background: #e9ecef; border-color: #ccc; }
.batch-mode-active { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
.batch-mode-active:hover { background: #c8e6c9; }
.batch-actions-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.batch-action-btn {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #555;
cursor: pointer;
transition: background 0.15s;
}
.batch-action-btn:hover { background: #e9ecef; }
.batch-count {
font-size: 11px;
color: #888;
margin-left: auto;
}
.batch-item {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
gap: 10px;
}
.batch-item-disabled {
opacity: 0.5;
cursor: default;
}
.batch-checkbox {
width: 16px;
height: 16px;
flex-shrink: 0;
accent-color: #e03131;
cursor: pointer;
}
.batch-item-disabled .batch-checkbox { cursor: default; }
.batch-item-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.batch-active-label {
font-size: 10px;
font-weight: 600;
color: #4caf50;
text-transform: uppercase;
margin-left: auto;
flex-shrink: 0;
}
.batch-delete-bar {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
background: #fff5f5;
}
.batch-delete-btn {
width: 100%;
padding: 8px;
font-size: 13px;
font-weight: 600;
background: #e03131;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.batch-delete-btn:hover { background: #c92a2a; }
.batch-delete-confirm {
display: flex;
align-items: center;
gap: 8px;
}
.batch-delete-msg {
flex: 1;
font-size: 13px;
color: #c92a2a;
font-weight: 500;
}
/* Clear canvas confirmation dialog */
.confirm-dialog {
position: absolute;
@@ -618,6 +726,20 @@
gap: 10px;
}
</style>
<!-- CORS flood suppressor: intercepts @excalidraw/excalidraw internal fetch to
https://excalidraw.com/og-image-3.png which triggers 6× CORS errors on
non-excalidraw.com origins. Returns 204 immediately, never leaves browser. -->
<script>
window.fetch = new Proxy(window.fetch, {
apply(target, thisArg, args) {
var url = args[0];
if (typeof url === 'string' && url.indexOf('og-image-3.png') !== -1) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Reflect.apply(target, thisArg, args);
}
});
</script>
</head>
<body>
<div id="root"></div>
+111 -4
View File
@@ -134,6 +134,9 @@ function App(): JSX.Element {
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState<string | null>(null)
const [batchSelectMode, setBatchSelectMode] = useState<boolean>(false)
const [selectedTenantIds, setSelectedTenantIds] = useState<Set<string>>(new Set())
const [confirmBatchDelete, setConfirmBatchDelete] = useState<boolean>(false)
// Keep refs in sync so closures (WebSocket handlers) always see latest values
useEffect(() => {
@@ -432,8 +435,19 @@ function App(): JSX.Element {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}`
let rawUrl = import.meta.env.CUSTOM_VITE_WS_URL || 'wss://excalidraw.forkless.com'
let scheme = 'wss://'
try {
let parseable = rawUrl
if (rawUrl.startsWith('ws://')) { scheme = 'ws://'; parseable = rawUrl.replace(/^ws:\/\//, 'http://') }
else if (rawUrl.startsWith('wss://')) { parseable = rawUrl.replace(/^wss:\/\//, 'https://') }
const parsed = new URL(parseable)
rawUrl = scheme + parsed.host
} catch {
// Bare hostname/IP — prepend wss://
rawUrl = 'wss://' + rawUrl
}
const wsUrl = rawUrl
websocketRef.current = new WebSocket(wsUrl)
@@ -1325,6 +1339,38 @@ function App(): JSX.Element {
}
}
const batchDeleteTenants = async () => {
const ids = Array.from(selectedTenantIds)
try {
const res = await fetch('/api/tenants/batch-delete', {
method: 'POST',
headers: tenantHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ ids })
})
const data = await res.json()
const deleted = data.deletedCount ?? 0
const deletedIds = new Set((data.results ?? []).filter((r: { deleted: boolean }) => r.deleted).map((r: { id: string }) => r.id))
setTenantList(prev => prev.filter(t => !deletedIds.has(t.id)))
setSelectedTenantIds(new Set())
setConfirmBatchDelete(false)
setBatchSelectMode(false)
showToast(`${deleted} workspace${deleted !== 1 ? 's' : ''} deleted`)
} catch (err) {
console.error('Batch delete failed:', err)
showToast('Batch delete failed', 4000)
setConfirmBatchDelete(false)
}
}
const toggleTenantSelection = (id: string) => {
setSelectedTenantIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const syncToBackend = async (): Promise<void> => {
if (!excalidrawAPI || isSyncingRef.current) return
@@ -1569,10 +1615,37 @@ function App(): JSX.Element {
const filtered = q
? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q))
: tenantList
const selectableFiltered = filtered.filter(t => t.id !== activeTenant?.id)
return (
<div className="menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="menu-overlay" onClick={() => { setMenuOpen(false); setBatchSelectMode(false); setSelectedTenantIds(new Set()); setConfirmBatchDelete(false) }}>
<div className="menu-panel" onClick={e => e.stopPropagation()}>
<div className="menu-header">Workspaces</div>
<div className="menu-header">
<span>Workspaces</span>
<button
className={`batch-mode-toggle ${batchSelectMode ? 'batch-mode-active' : ''}`}
title={batchSelectMode ? 'Exit selection mode' : 'Select workspaces to delete'}
onClick={() => {
setBatchSelectMode(prev => !prev)
setSelectedTenantIds(new Set())
setConfirmBatchDelete(false)
}}
>
{batchSelectMode ? 'Done' : 'Select'}
</button>
</div>
{batchSelectMode && selectableFiltered.length > 0 && (
<div className="batch-actions-bar">
<button
className="batch-action-btn"
onClick={() => setSelectedTenantIds(new Set(selectableFiltered.map(t => t.id)))}
>Select All</button>
<button
className="batch-action-btn"
onClick={() => setSelectedTenantIds(new Set())}
>Unselect All</button>
<span className="batch-count">{selectedTenantIds.size} selected</span>
</div>
)}
<div className="menu-search-wrap">
<input
ref={searchInputRef}
@@ -1592,6 +1665,25 @@ function App(): JSX.Element {
<button className="project-delete-yes" onClick={() => deleteTenantUI(t.id)}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
</div>
) : batchSelectMode ? (
<label className={`menu-item batch-item ${activeTenant?.id === t.id ? 'menu-item-active batch-item-disabled' : ''}`}>
<input
type="checkbox"
className="batch-checkbox"
disabled={activeTenant?.id === t.id}
checked={selectedTenantIds.has(t.id)}
onChange={() => toggleTenantSelection(t.id)}
/>
<span className="batch-item-content">
<span className="menu-item-name">{t.name}</span>
<span className="menu-item-path" title={t.workspace_path}>
{t.workspace_path.length > 40
? '...' + t.workspace_path.slice(-37)
: t.workspace_path}
</span>
</span>
{activeTenant?.id === t.id && <span className="batch-active-label">active</span>}
</label>
) : (
<>
<button
@@ -1619,6 +1711,21 @@ function App(): JSX.Element {
))}
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
</div>
{batchSelectMode && selectedTenantIds.size > 0 && (
<div className="batch-delete-bar">
{confirmBatchDelete ? (
<div className="batch-delete-confirm">
<span className="batch-delete-msg">Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}?</span>
<button className="project-delete-yes" onClick={batchDeleteTenants}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmBatchDelete(false)}>Cancel</button>
</div>
) : (
<button className="batch-delete-btn" onClick={() => setConfirmBatchDelete(true)}>
Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}
</button>
)}
</div>
)}
</div>
</div>
)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "excalidraw-mcp-sentinel",
"version": "1.0.3",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "excalidraw-mcp-sentinel",
"version": "1.0.3",
"version": "1.1.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "excalidraw-mcp-sentinel",
"version": "1.0.6",
"version": "1.2.0",
"description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests",
"main": "dist/index.js",
"type": "module",
+98 -6
View File
@@ -264,6 +264,96 @@ export function getDefaultProjectForTenant(tenantId: string): string {
return id;
}
// ── Native field normalization ──
// Fill any missing native Excalidraw fields so every element stored in the DB
// is a complete, round-trippable Excalidraw element — not just an MCP partial.
function fillNativeFields(element: ServerElement): ServerElement {
const el = element as any;
// ── Universal fields ──────────────────────────────────────────────────────
el.angle = el.angle ?? 0;
el.strokeColor = el.strokeColor ?? '#1e1e1e';
el.backgroundColor = el.backgroundColor ?? 'transparent';
el.fillStyle = el.fillStyle ?? 'solid';
el.strokeWidth = el.strokeWidth ?? 2;
el.strokeStyle = el.strokeStyle ?? 'solid';
el.roughness = el.roughness ?? 1;
el.opacity = el.opacity ?? 100;
el.groupIds = el.groupIds ?? [];
el.frameId = el.frameId ?? null;
el.seed = el.seed ?? Math.floor(Math.random() * 2147483647);
el.versionNonce = el.versionNonce ?? Math.floor(Math.random() * 2147483647);
el.isDeleted = el.isDeleted ?? false;
el.updated = el.updated ?? Date.now();
el.link = el.link ?? null;
el.locked = el.locked ?? false;
el.boundElements = el.boundElements ?? null;
// index: preserve existing; generate a stable sortable value if absent
if (!el.index) {
el.index = `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
}
// roundness: Excalidraw default is rounded (type 3) for closed shapes
if (el.roundness === undefined) {
const rounded = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse';
el.roundness = rounded ? { type: 3 } : null;
}
// ── Type-specific fields ──────────────────────────────────────────────────
if (el.type === 'text') {
el.text = el.text ?? '';
el.originalText = el.originalText ?? el.text;
el.fontSize = el.fontSize ?? 20;
el.fontFamily = el.fontFamily ?? 5; // Nunito
el.textAlign = el.textAlign ?? 'left';
el.verticalAlign = el.verticalAlign ?? (el.containerId ? 'middle' : 'top');
el.autoResize = el.autoResize ?? true;
el.lineHeight = el.lineHeight ?? 1.25;
el.containerId = el.containerId ?? null;
} else if (el.type === 'arrow' || el.type === 'line') {
el.points = el.points ?? [[0, 0], [100, 0]];
el.lastCommittedPoint = el.lastCommittedPoint ?? null;
el.startBinding = el.startBinding ?? null;
el.endBinding = el.endBinding ?? null;
el.startArrowhead = el.startArrowhead ?? null;
el.endArrowhead = el.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
el.elbowed = el.elbowed ?? false;
} else if (el.type === 'image') {
el.status = el.status ?? 'pending';
el.scale = el.scale ?? [1, 1];
} else if (el.type === 'freedraw') {
el.points = el.points ?? [];
el.pressures = el.pressures ?? [];
el.simulatePressure = el.simulatePressure ?? true;
el.lastCommittedPoint = el.lastCommittedPoint ?? null;
}
return el as ServerElement;
}
// When a text element with containerId is saved, ensure the container's
// boundElements array references it back. Both sides must be consistent
// for Excalidraw to treat the text as embedded in the shape.
function repairContainerBinding(element: ServerElement, projectId?: string): void {
if (element.type !== 'text') return;
const cid = (element as any).containerId as string | null | undefined;
if (!cid) return;
const container = getElement(cid, projectId);
if (!container) return;
const existing: any[] = Array.isArray((container as any).boundElements)
? (container as any).boundElements as any[]
: [];
if (existing.some((b: any) => b.id === element.id)) return;
// Update container directly — container.type is never 'text' so this
// cannot recurse back into repairContainerBinding.
setElement(cid, {
...container,
boundElements: [...existing, { type: 'text', id: element.id }]
} as ServerElement, projectId);
}
// ── Element CRUD ──
export function getElement(id: string, projectId?: string): ServerElement | undefined {
@@ -283,8 +373,9 @@ export function hasElement(id: string, projectId?: string): boolean {
export function setElement(id: string, element: ServerElement, projectId?: string): number {
const p = pid(projectId);
const now = new Date().toISOString();
const data = JSON.stringify(element);
const labelText = extractLabelText(element);
const normalized = fillNativeFields(element);
const data = JSON.stringify(normalized);
const labelText = extractLabelText(normalized);
const sv = incrementSyncVersion(p);
const existing = db.prepare(
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
@@ -295,19 +386,20 @@ export function setElement(id: string, element: ServerElement, projectId?: strin
db.prepare(`
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
WHERE id = ? AND project_id = ?
`).run(element.type, data, labelText, now, newVersion, sv, id, p);
`).run(normalized.type, data, labelText, now, newVersion, sv, id, p);
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
updateFts(id, labelText, element.type);
updateFts(id, labelText, normalized.type);
} else {
db.prepare(`
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(id, p, element.type, data, labelText, now, now, sv);
`).run(id, p, normalized.type, data, labelText, now, now, sv);
recordVersion(id, 1, data, 'create', p);
insertFts(id, labelText, element.type);
insertFts(id, labelText, normalized.type);
}
repairContainerBinding(normalized, projectId);
return sv;
}
+123 -87
View File
@@ -35,6 +35,7 @@ import {
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
import { startMcpHttpServer, resolveTransportMode } from './mcp-http.js';
import {
initDb, closeDb,
searchElements as dbSearchElements,
@@ -143,9 +144,13 @@ interface SyncResponse {
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Tenant-Id': dbGetActiveTenantId(),
...extra
};
// Only send tenant header when targeting local canvas (EXPRESS_SERVER_URL unset).
// Remote servers (e.g. production) handle routing via default tenant.
if (!process.env.EXPRESS_SERVER_URL) {
headers['X-Tenant-Id'] = dbGetActiveTenantId();
}
// Forward API key to canvas when auth is enabled — required for two-service
// Docker deployments where canvas runs with EXCALIDRAW_API_KEY set.
const apiKey = process.env.EXCALIDRAW_API_KEY;
@@ -1034,39 +1039,41 @@ const tools: Tool[] = [
];
// Initialize MCP server
const server = new Server(
{
name: "mcp-excalidraw-server",
version: "2.0.0",
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
},
{
capabilities: {
tools: Object.fromEntries(tools.map(tool => [tool.name, {
description: tool.description,
inputSchema: tool.inputSchema
}]))
// Build a fresh MCP server with all request handlers registered. Called once
// for the stdio singleton below, and once per client session in HTTP mode.
function createMcpServer(): Server {
const server = new Server(
{
name: "mcp-excalidraw-server",
version: "2.0.0",
description: "Programmatic canvas toolkit for Excalidraw with file I/O, image export, and real-time sync"
},
{
capabilities: {
tools: Object.fromEntries(tools.map(tool => [tool.name, {
description: tool.description,
inputSchema: tool.inputSchema
}]))
}
}
}
);
// Helper function to convert text property to label format for Excalidraw
function convertTextToLabel(element: ServerElement): ServerElement {
const { text, ...rest } = element;
// text === undefined means the caller didn't touch the text field — leave as-is
if (text === undefined) return element;
// Standalone text elements keep text as a direct property
if (element.type === 'text') return element;
// All container/shape/arrow elements: map text → label.text (empty string clears it)
// Default containers to top-center alignment for title/subtitle layout
const isArrow = element.type === 'arrow' || element.type === 'line';
return {
...rest,
verticalAlign: (rest as any).verticalAlign ?? (isArrow ? 'middle' : 'top'),
label: { text }
} as ServerElement;
);
registerHandlers(server);
return server;
}
const server = createMcpServer();
// Helper function: previously converted text → label format for Excalidraw.
// Now a no-op because the canvas REST API materializes label/text into native
// bound text elements at write time (materializeLabel in server.ts).
function convertTextToLabel(element: ServerElement): ServerElement {
return element;
}
// Register all request handlers on a server instance. Module-scope so it can be
// called per-session in HTTP mode and once for the stdio singleton.
function registerHandlers(server: Server): void {
// Set up request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
try {
@@ -1087,16 +1094,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
// Auto-populate title+subtitle for container types unless text is explicitly set
// Use explicit text if provided, otherwise fall back to title/title+subtitle card layout
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond']);
const isContainer = CONTAINER_TYPES.has(params.type);
const hasExplicitText = elementProps.text !== undefined;
const effectiveTitle = title ?? (isContainer && !hasExplicitText ? 'Title' : undefined);
const effectiveSubtitle = subtitle ?? (isContainer && !hasExplicitText && effectiveTitle ? 'Description' : undefined);
const effectiveText = effectiveTitle ?? elementProps.text;
const effectiveFontSize = effectiveTitle ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
const effectiveFontFamily = effectiveTitle
const effectiveText = title ?? elementProps.text;
const effectiveSubtitle = isContainer && subtitle ? subtitle : undefined;
const effectiveFontSize = title ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
const effectiveFontFamily = title
? (normalizeFontFamily(titleFontFamily) ?? USER_PREFS.fontFamily)
: (normalizedFont ?? USER_PREFS.fontFamily);
@@ -1125,7 +1129,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const excalidrawElement = convertTextToLabel(element);
// Card layout: title (bound) + subtitle (grouped standalone text)
const groupId = (effectiveTitle && effectiveSubtitle && isContainer) ? generateId() : undefined;
const groupId = (title && effectiveSubtitle && isContainer) ? generateId() : undefined;
// Add groupId to container if using card layout
if (groupId) {
@@ -1865,7 +1869,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.filePath) {
const safePath = sanitizeFilePath(params.filePath);
fs.writeFileSync(safePath, jsonString, 'utf-8');
await fs.promises.writeFile(safePath, jsonString, 'utf-8');
return {
content: [{
type: 'text',
@@ -1894,7 +1898,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
let sceneData: any;
if (params.filePath) {
const safeImportPath = sanitizeFilePath(params.filePath);
const fileContent = fs.readFileSync(safeImportPath, 'utf-8');
const fileContent = await fs.promises.readFile(safeImportPath, 'utf-8');
sceneData = JSON.parse(fileContent);
assertNoDangerousKeys(sceneData, 'import_scene filePath');
} else if (params.data) {
@@ -2006,9 +2010,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.filePath) {
const safeImagePath = sanitizeFilePath(params.filePath);
if (params.format === 'svg') {
fs.writeFileSync(safeImagePath, result.data, 'utf-8');
await fs.promises.writeFile(safeImagePath, result.data, 'utf-8');
} else {
fs.writeFileSync(safeImagePath, Buffer.from(result.data, 'base64'));
await fs.promises.writeFile(safeImagePath, Buffer.from(result.data, 'base64'));
}
return {
content: [{
@@ -2411,7 +2415,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const boundTextElements: Record<string, any>[] = [];
let indexCounter = 0;
function makeBaseElement(el: any, rest: any): Record<string, any> {
// Build a set of element IDs that are already native bound-text elements
// (i.e. stored with containerId). For their containers, skip label→text
// generation so we don't create duplicate text elements.
const nativeBoundTextContainerIds = new Set<string>(
urlExportElements
.filter((e: any) => e.type === 'text' && e.containerId)
.map((e: any) => e.containerId as string)
);
function makeBaseElement(el: any, rest: any, storedVersion?: number): Record<string, any> {
const isRoundedShape = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse';
return {
...rest,
angle: rest.angle ?? 0,
@@ -2425,16 +2439,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
groupIds: rest.groupIds ?? [],
frameId: rest.frameId ?? null,
index: rest.index ?? `a${indexCounter++}`,
roundness: rest.roundness ?? (
el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'
? { type: 3 } : null
),
roundness: rest.roundness ?? (isRoundedShape ? { type: 3 } : null),
seed: rest.seed ?? Math.floor(Math.random() * 2147483647),
version: rest.version ?? 1,
version: storedVersion ?? rest.version ?? 1,
versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647),
isDeleted: false,
boundElements: rest.boundElements ?? null,
updated: Date.now(),
updated: rest.updated ?? Date.now(),
link: rest.link ?? null,
locked: rest.locked ?? false
};
@@ -2449,46 +2460,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
...rest
} = el as any;
const base = makeBaseElement(el, rest);
const base = makeBaseElement(el, rest, _ver);
// Standalone text elements: keep text directly
// Text elements: trust stored native fields, fill gaps only
if (el.type === 'text') {
base.text = text ?? '';
base.originalText = text ?? '';
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'center';
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
base.autoResize = rest.autoResize ?? true;
base.lineHeight = rest.lineHeight ?? 1.25;
base.containerId = rest.containerId ?? null;
base.text = text ?? rest.text ?? '';
base.originalText = rest.originalText ?? base.text;
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'left';
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'middle' : 'top');
base.autoResize = rest.autoResize ?? true;
base.lineHeight = rest.lineHeight ?? 1.25;
base.containerId = rest.containerId ?? null;
cleanedExportElements.push(base);
continue;
}
// Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions)
// Arrows/lines: trust stored fields, fill gaps only
if (el.type === 'arrow' || el.type === 'line') {
base.points = rest.points ?? [[0, 0], [100, 0]];
base.lastCommittedPoint = null;
// Preserve server-resolved bindings with fixedPoint for excalidraw.com
if (rest.startBinding) {
base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null };
} else {
base.startBinding = null;
}
if (rest.endBinding) {
base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null };
} else {
base.endBinding = null;
}
base.points = rest.points ?? [[0, 0], [100, 0]];
base.lastCommittedPoint = rest.lastCommittedPoint ?? null;
base.startBinding = rest.startBinding
? { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null }
: null;
base.endBinding = rest.endBinding
? { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null }
: null;
base.startArrowhead = rest.startArrowhead ?? null;
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
base.elbowed = rest.elbowed ?? false;
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
base.elbowed = rest.elbowed ?? false;
}
// Generate bound text element for label on shapes and arrows
// Generate bound text element for label on shapes and arrows.
// Skip if the shape already has a native bound text element stored
// (containerId-based) — generating one here would create a duplicate.
const labelText = label?.text || text;
if (labelText) {
if (labelText && !nativeBoundTextContainerIds.has(base.id)) {
const textId = `${base.id}-label`;
// Add binding reference to parent
base.boundElements = [
@@ -2880,6 +2888,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
} // end registerHandlers
// Start server
async function runServer(): Promise<void> {
try {
@@ -2902,12 +2912,37 @@ async function runServer(): Promise<void> {
applyTenant(workspacePath);
try {
await startCanvasServer();
logger.info('Canvas server started — lifecycle managed by MCP process');
} catch (canvasError) {
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
logger.warn('MCP tools will work without real-time canvas sync');
// Skip local canvas server when targeting a remote EXPRESS_SERVER_URL
if (!process.env.EXPRESS_SERVER_URL) {
try {
await startCanvasServer();
logger.info('Canvas server started — lifecycle managed by MCP process');
} catch (canvasError) {
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
logger.warn('MCP tools will work without real-time canvas sync');
}
} else {
logger.info('Remote canvas server configured, skipping local canvas start');
}
// HTTP mode: one shared process serves many clients over Streamable HTTP.
// Each client session gets its own MCP server via createMcpServer. The MCP
// endpoint listens on its own port (MCP_HTTP_PORT) so it stays reachable
// even when the canvas port is reused by another process.
if (resolveTransportMode(process.env) === 'http') {
const mcpPort = parseInt(process.env['MCP_HTTP_PORT'] || '3031', 10);
await startMcpHttpServer(createMcpServer, mcpPort);
logger.info(`Excalidraw MCP server running on HTTP (Streamable) at http://127.0.0.1:${mcpPort}/mcp`);
const shutdownHttp = async () => {
logger.info('Shutting down (HTTP mode)');
try { await stopCanvasServer(); } catch {}
try { closeDb(); } catch {}
process.exit(0);
};
process.on('SIGTERM', shutdownHttp);
process.on('SIGINT', shutdownHttp);
return;
}
const transport = new StdioServerTransport();
@@ -2968,7 +3003,8 @@ async function runServer(): Promise<void> {
}
server.onclose = shutdown;
process.stdin.on('close', shutdown);
// Delay exit on stdin close — prevents crash if CodeWhale briefly disconnects during init
process.stdin.on('close', () => setTimeout(shutdown, 1000));
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+98
View File
@@ -0,0 +1,98 @@
/**
* Streamable HTTP transport wiring for the MCP server.
*
* Lets a single long-lived process serve many MCP clients over HTTP instead of
* each client spawning its own stdio process. Each client session gets its own
* MCP `Server` instance (cheap in-process object) routed by `mcp-session-id`.
*/
import type { Application, Request, Response } from 'express';
import type { Server as HttpServer } from 'node:http';
import express from 'express';
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type TransportMode = 'stdio' | 'http';
/** Decide transport from the environment. stdio is the default (back-compat). */
export function resolveTransportMode(env: NodeJS.ProcessEnv): TransportMode {
return (env['MCP_TRANSPORT'] || '').toLowerCase() === 'http' ? 'http' : 'stdio';
}
/**
* Mount POST/GET/DELETE `/mcp` routes on an existing Express app.
*
* @param app the Express app (shares the canvas server's httpServer)
* @param createServer factory returning a fresh MCP `Server` per session
*/
export function mountMcpRoutes(app: Application, createServer: () => Server): void {
const transports: Record<string, StreamableHTTPServerTransport> = {};
// Dedicated parser so /mcp accepts larger bodies than the canvas API's 100kb cap.
const jsonParser = express.json({ limit: '5mb' });
app.post('/mcp', jsonParser, async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// Plain JSON responses (no SSE) — clean request/response for Claude clients.
enableJsonResponse: true,
onsessioninitialized: (sid) => {
transports[sid] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) delete transports[transport.sessionId];
};
const server = createServer();
await server.connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: no valid session ID' },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
const handleSessionRequest = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
await transports[sessionId]!.handleRequest(req, res);
};
app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);
}
/**
* Start a dedicated HTTP server hosting the MCP `/mcp` endpoint on its own port.
*
* Kept independent of the canvas server so MCP stays reachable even when the
* canvas port is owned/reused by another process.
*
* @returns the listening http.Server (resolves once bound)
*/
export function startMcpHttpServer(
createServer: () => Server,
port: number,
host = '127.0.0.1',
): Promise<HttpServer> {
const app = express();
mountMcpRoutes(app, createServer);
return new Promise<HttpServer>((resolve, reject) => {
const httpServer = app.listen(port, host, () => resolve(httpServer));
httpServer.on('error', reject);
});
}
+127 -8
View File
@@ -39,6 +39,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app: Application = express();
app.set('trust proxy', 1);
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer, verifyClient: verifyWsClient });
@@ -499,7 +500,7 @@ const ElementSharedFieldsSchema = z.object({
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
}).passthrough(); // preserve all native Excalidraw fields not listed above
const CreateElementSchema = ElementSharedFieldsSchema.extend({
id: z.string().optional(),
@@ -559,19 +560,24 @@ app.post('/api/elements', async (req: Request, res: Response) => {
version: 1
};
const sv = store.setElement(id, element, projId);
const { container, boundText } = materializeLabel(element);
const sv = store.setElement(container.id, container, projId);
if (boundText) {
store.setElement(boundText.id, boundText, projId);
}
const scope = resolveScope(req);
const message: ElementCreatedMessage = {
type: 'element_created',
element: element
element: container
};
message['sync_version'] = sv;
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({
success: true,
element: element,
element: container,
boundTextElement: boundText ?? undefined,
syncedToCanvas: ackResult.acked,
canvasStatus: {
connectedBrowsers: ackResult.delivered,
@@ -620,19 +626,39 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
version: (existingElement.version || 0) + 1
};
const sv = store.setElement(id, updatedElement, projId);
// Find existing bound text ID so we update rather than create a duplicate
const existingBound = (existingElement as any).boundElements as Array<{ id: string; type: string }> | null;
const existingBoundTextId = existingBound?.find((b) => b.type === 'text')?.id;
const { container, boundText } = materializeLabel(updatedElement, existingBoundTextId);
const sv = store.setElement(id, container, projId);
if (boundText) {
const existingBT = existingBoundTextId ? store.getElement(existingBoundTextId, projId) : null;
const btToSave = existingBT
? {
...existingBT,
text: boundText.text,
originalText: boundText.originalText,
updatedAt: boundText.updatedAt,
version: (existingBT.version || 0) + 1
}
: boundText;
store.setElement(btToSave.id, btToSave as ServerElement, projId);
}
const scope = resolveScope(req);
const message: ElementUpdatedMessage = {
type: 'element_updated',
element: updatedElement
element: container
};
message['sync_version'] = sv;
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({
success: true,
element: updatedElement,
element: container,
boundTextElement: boundText ?? undefined,
syncedToCanvas: ackResult.acked,
canvasStatus: {
connectedBrowsers: ackResult.delivered,
@@ -847,6 +873,68 @@ function computeEdgePoint(
}
}
// Helper: materialize a shape's label/text into a native bound text element.
// When a container shape arrives with `label.text` or a `text` field, we create
// a proper Excalidraw bound-text element (containerId ↔ boundElements) instead
// of storing the MCP label format. Returns the cleaned container and the new
// bound-text element (null if nothing to materialize).
function materializeLabel(
element: ServerElement,
existingBoundTextId?: string
): { container: ServerElement; boundText: ServerElement | null } {
const NON_CONTAINER_TYPES = new Set(['text', 'arrow', 'line', 'freedraw', 'image']);
if (NON_CONTAINER_TYPES.has(element.type ?? '')) {
return { container: element, boundText: null };
}
// Accept both { label: { text } } (MCP format) and { text } (direct) formats
const labelText: string | undefined =
((element as any).label as { text?: string } | undefined)?.text ??
(element.type !== 'text' ? (element as any).text as string | undefined : undefined);
if (!labelText) {
return { container: element, boundText: null };
}
const boundTextId = existingBoundTextId ?? `${element.id}-label`;
const boundText: ServerElement = {
id: boundTextId,
type: 'text',
x: element.x ?? 0,
y: element.y ?? 0,
width: element.width ?? 200,
height: element.height ?? 80,
text: labelText,
originalText: labelText,
fontSize: 20,
fontFamily: 5,
textAlign: 'center',
verticalAlign: 'middle',
autoResize: true,
lineHeight: 1.25,
containerId: element.id,
strokeColor: (element as any).strokeColor ?? '#1e1e1e',
opacity: (element as any).opacity ?? 100,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
} as unknown as ServerElement;
// Strip label/text from container, set boundElements
const { label: _label, text: _text, ...containerRest } = element as any;
const existingBound: Array<{ id: string; type: string }> = containerRest.boundElements ?? [];
const alreadyBound = existingBound.some((b) => b.id === boundTextId);
const container: ServerElement = {
...containerRest,
boundElements: alreadyBound
? existingBound
: [...existingBound, { id: boundTextId, type: 'text' }],
};
return { container, boundText };
}
// Helper: resolve arrow bindings in a batch
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
const elementMap = new Map<string, ServerElement>();
@@ -953,7 +1041,9 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => {
version: 1
};
createdElements.push(element);
const { container, boundText } = materializeLabel(element);
createdElements.push(container);
if (boundText) createdElements.push(boundText);
});
resolveArrowBindings(createdElements, projId);
@@ -1602,6 +1692,35 @@ app.delete('/api/tenants/:id', (req: Request, res: Response) => {
}
});
app.post('/api/tenants/batch-delete', destructiveRateLimit, (req: Request, res: Response) => {
try {
const { ids } = req.body as { ids?: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ success: false, error: 'ids must be a non-empty array' });
return;
}
if (ids.length > 50) {
res.status(400).json({ success: false, error: 'Cannot delete more than 50 tenants at once' });
return;
}
const results: { id: string; deleted: boolean; error?: string }[] = [];
for (const id of ids) {
try {
dbDeleteTenant(id);
broadcast({ type: 'tenant_deleted', tenantId: id } as any);
results.push({ id, deleted: true });
} catch (err) {
results.push({ id, deleted: false, error: (err as Error).message });
}
}
const deletedCount = results.filter(r => r.deleted).length;
res.json({ success: true, deletedCount, results });
} catch (error) {
logger.error('Error batch-deleting tenants:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.get('/api/projects', (req: Request, res: Response) => {
try {
const projects = dbListProjects();
+1
View File
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
customData?: Record<string, any> | null;
boundElements?: readonly ExcalidrawBoundElement[] | null;
updated?: number;
index?: string;
containerId?: string | null;
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import express, { type Express } from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { Server as HttpServer } from 'node:http';
import { mountMcpRoutes, resolveTransportMode, startMcpHttpServer } from '../../src/mcp-http.js';
// A minimal real MCP server so the SDK initialize handshake succeeds.
function makeServer(): Server {
const server = new Server(
{ name: 'test-shared-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
return server;
}
const INIT_BODY = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
};
const ACCEPT = 'application/json, text/event-stream';
describe('resolveTransportMode', () => {
it('defaults to stdio when MCP_TRANSPORT is unset', () => {
expect(resolveTransportMode({})).toBe('stdio');
});
it('returns http when MCP_TRANSPORT=http (case-insensitive)', () => {
expect(resolveTransportMode({ MCP_TRANSPORT: 'http' })).toBe('http');
expect(resolveTransportMode({ MCP_TRANSPORT: 'HTTP' })).toBe('http');
});
it('falls back to stdio for any other value', () => {
expect(resolveTransportMode({ MCP_TRANSPORT: 'sse' })).toBe('stdio');
});
});
describe('mountMcpRoutes', () => {
let app: Express;
let serverInstances: number;
beforeEach(() => {
serverInstances = 0;
app = express();
mountMcpRoutes(app, () => {
serverInstances += 1;
return makeServer();
});
});
it('creates a session on initialize and returns a session id header', async () => {
const res = await request(app)
.post('/mcp')
.set('Content-Type', 'application/json')
.set('Accept', ACCEPT)
.send(INIT_BODY);
expect(res.status).toBe(200);
expect(res.headers['mcp-session-id']).toBeTruthy();
expect(serverInstances).toBe(1);
});
it('gives each initialize its own isolated session id and server instance', async () => {
const a = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
const b = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
expect(a.headers['mcp-session-id']).toBeTruthy();
expect(b.headers['mcp-session-id']).toBeTruthy();
expect(a.headers['mcp-session-id']).not.toBe(b.headers['mcp-session-id']);
expect(serverInstances).toBe(2);
});
it('rejects a POST with no session id that is not an initialize request', async () => {
const res = await request(app)
.post('/mcp')
.set('Accept', ACCEPT)
.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
expect(res.status).toBe(400);
});
it('rejects a GET with an unknown session id', async () => {
const res = await request(app)
.get('/mcp')
.set('Accept', ACCEPT)
.set('mcp-session-id', 'does-not-exist');
expect(res.status).toBe(400);
});
it('tears down a session on DELETE with a valid session id', async () => {
const init = await request(app).post('/mcp').set('Accept', ACCEPT).send(INIT_BODY);
const sid = init.headers['mcp-session-id'];
const del = await request(app)
.delete('/mcp')
.set('Accept', ACCEPT)
.set('mcp-session-id', sid);
expect(del.status).toBeLessThan(500);
// After teardown the session id is no longer valid.
const after = await request(app)
.get('/mcp')
.set('Accept', ACCEPT)
.set('mcp-session-id', sid);
expect(after.status).toBe(400);
});
});
describe('startMcpHttpServer', () => {
let httpServer: HttpServer;
afterEach(() => {
httpServer?.close();
});
it('listens on its own port and serves initialize', async () => {
httpServer = await startMcpHttpServer(makeServer, 0); // port 0 = ephemeral
const addr = httpServer.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
expect(port).toBeGreaterThan(0);
const res = await request(`http://127.0.0.1:${port}`)
.post('/mcp')
.set('Content-Type', 'application/json')
.set('Accept', ACCEPT)
.send(INIT_BODY);
expect(res.status).toBe(200);
expect(res.headers['mcp-session-id']).toBeTruthy();
});
});
+581
View File
@@ -0,0 +1,581 @@
/**
* Non-regression tests for native Excalidraw field preservation.
*
* Covers:
* - Universal fields populated on every write (seed, versionNonce, index, etc.)
* - Type-specific fields: text, arrow, line, image, freedraw
* - roundness defaults: { type: 3 } for closed shapes, null for others
* - Zod passthrough: unknown native fields not stripped by schema
* - repairContainerBinding: both sides of containerId ↔ boundElements kept in sync
* across all write paths (create, batch-create, update, sync/v2)
* - Export: stored version/updated preserved; no duplicate text on export
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import {
initDb, closeDb,
getElement, getAllElements,
setActiveTenant,
} from '../../src/db.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
const UNIVERSAL_FIELDS = [
'angle', 'strokeColor', 'backgroundColor', 'fillStyle',
'strokeWidth', 'strokeStyle', 'roughness', 'opacity',
'groupIds', 'frameId', 'seed', 'versionNonce',
'isDeleted', 'updated', 'link', 'locked', 'boundElements', 'index',
];
beforeEach(async () => {
dbPath = path.join(
os.tmpdir(),
`excalidraw-native-fields-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ── Helpers ──────────────────────────────────────────────────────────────────
async function createElement(body: Record<string, any>) {
const res = await request(app).post('/api/elements').send(body);
expect(res.status).toBe(200);
return res.body.element as Record<string, any>;
}
async function batchCreate(elements: Record<string, any>[]) {
const res = await request(app).post('/api/elements/batch').send({ elements });
expect(res.status).toBe(200);
return res.body.elements as Record<string, any>[];
}
async function syncV2(changes: { id: string; action: string; element?: Record<string, any> }[]) {
const res = await request(app).post('/api/elements/sync/v2').send({
lastSyncVersion: 0,
changes,
});
expect(res.status).toBe(200);
}
async function updateElement(id: string, updates: Record<string, any>) {
const res = await request(app).put(`/api/elements/${id}`).send({ id, ...updates });
expect(res.status).toBe(200);
return res.body.element as Record<string, any>;
}
function dbEl(id: string): Record<string, any> {
const el = getElement(id);
expect(el, `Element ${id} not found in DB`).toBeDefined();
return el as Record<string, any>;
}
// ── Universal fields ──────────────────────────────────────────────────────────
describe('universal fields — filled on create', () => {
it('populates all universal fields with correct default values for a minimal rectangle', async () => {
await createElement({ type: 'rectangle', id: 'u-rect', x: 0, y: 0, width: 100, height: 50 });
const el = dbEl('u-rect');
// Presence
for (const field of UNIVERSAL_FIELDS) {
expect(el, `field "${field}" missing`).toHaveProperty(field);
}
// Specific default values
expect(el.angle).toBe(0);
expect(el.strokeColor).toBe('#1e1e1e');
expect(el.backgroundColor).toBe('transparent');
expect(el.fillStyle).toBe('solid');
expect(el.strokeWidth).toBe(2);
expect(el.strokeStyle).toBe('solid');
expect(el.roughness).toBe(1);
expect(el.opacity).toBe(100);
expect(el.groupIds).toEqual([]);
expect(el.frameId).toBeNull();
expect(el.link).toBeNull();
expect(el.locked).toBe(false);
expect(el.isDeleted).toBe(false);
expect(el.boundElements).toBeNull();
expect(typeof el.seed).toBe('number');
expect(typeof el.versionNonce).toBe('number');
expect(typeof el.updated).toBe('number');
expect(typeof el.index).toBe('string');
expect(el.index.length).toBeGreaterThan(0);
});
it('populates universal fields via batch create', async () => {
await batchCreate([{ type: 'rectangle', id: 'u-batch', x: 0, y: 0, width: 100, height: 50 }]);
const el = dbEl('u-batch');
expect(typeof el.seed).toBe('number');
expect(typeof el.index).toBe('string');
expect(el.isDeleted).toBe(false);
});
it('populates universal fields via sync/v2 upsert', async () => {
await syncV2([{
id: 'u-sync', action: 'upsert',
element: { id: 'u-sync', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
}]);
const el = dbEl('u-sync');
expect(typeof el.seed).toBe('number');
expect(typeof el.index).toBe('string');
expect(el.isDeleted).toBe(false);
});
it('does not overwrite existing seed/versionNonce/index on update', async () => {
await createElement({ type: 'rectangle', id: 'u-stable', x: 0, y: 0, width: 100, height: 50 });
const before = dbEl('u-stable');
await updateElement('u-stable', { x: 50 });
const after = dbEl('u-stable');
expect(after.seed).toBe(before.seed);
expect(after.index).toBe(before.index);
});
it('preserves caller-supplied seed and index', async () => {
await createElement({
type: 'rectangle', id: 'u-supplied', x: 0, y: 0, width: 100, height: 50,
seed: 12345678, index: 'aZZ',
});
const el = dbEl('u-supplied');
expect(el.seed).toBe(12345678);
expect(el.index).toBe('aZZ');
});
});
// ── roundness ─────────────────────────────────────────────────────────────────
describe('roundness defaults', () => {
it.each(['rectangle', 'diamond', 'ellipse'])(
'%s gets roundness { type: 3 } by default',
async (type) => {
await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50 });
const el = dbEl(`rnd-${type}`);
expect(el.roundness).toEqual({ type: 3 });
}
);
it.each(['arrow', 'line', 'text'])(
'%s gets roundness null by default',
async (type) => {
const extra: Record<string, any> = type === 'text' ? { text: 'hi' } : {};
await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50, ...extra });
const el = dbEl(`rnd-${type}`);
expect(el.roundness).toBeNull();
}
);
it('preserves explicit roundness: null on a rectangle', async () => {
await createElement({
type: 'rectangle', id: 'rnd-explicit-null', x: 0, y: 0, width: 100, height: 50,
roundness: null,
});
const el = dbEl('rnd-explicit-null');
expect(el.roundness).toBeNull();
});
});
// ── Type-specific: text ───────────────────────────────────────────────────────
describe('text element — type-specific fields', () => {
it('fills all type-specific fields with correct default values', async () => {
await createElement({ type: 'text', id: 'txt-1', x: 0, y: 0, text: 'hello' });
const el = dbEl('txt-1');
expect(el.text).toBe('hello');
expect(el.originalText).toBe('hello');
expect(el.fontSize).toBe(20);
expect(el.fontFamily).toBe(5);
expect(el.textAlign).toBe('left');
expect(el.verticalAlign).toBe('top'); // no containerId
expect(el.autoResize).toBe(true);
expect(el.lineHeight).toBe(1.25);
expect(el.containerId).toBeNull();
});
it('defaults text to empty string when omitted', async () => {
await createElement({ type: 'text', id: 'txt-empty', x: 0, y: 0 });
const el = dbEl('txt-empty');
expect(el.text).toBe('');
expect(el.originalText).toBe('');
});
it('sets verticalAlign to "middle" when containerId is present', async () => {
await createElement({ type: 'rectangle', id: 'txt-container', x: 0, y: 0, width: 200, height: 80 });
await createElement({
type: 'text', id: 'txt-bound', x: 10, y: 30, text: 'bound',
containerId: 'txt-container',
});
const el = dbEl('txt-bound');
expect(el.verticalAlign).toBe('middle');
});
it('preserves caller-supplied autoResize: false and lineHeight', async () => {
await createElement({
type: 'text', id: 'txt-custom', x: 0, y: 0, text: 'hi',
autoResize: false, lineHeight: 1.5,
});
const el = dbEl('txt-custom');
expect(el.autoResize).toBe(false);
expect(el.lineHeight).toBe(1.5);
});
});
// ── Type-specific: arrow ──────────────────────────────────────────────────────
describe('arrow element — type-specific fields', () => {
it('fills points, lastCommittedPoint, startBinding, endBinding, endArrowhead, elbowed', async () => {
await createElement({ type: 'arrow', id: 'arr-1', x: 0, y: 0, width: 100, height: 0 });
const el = dbEl('arr-1');
expect(Array.isArray(el.points)).toBe(true);
expect(el.lastCommittedPoint).toBeNull();
expect(el.startBinding).toBeNull();
expect(el.endBinding).toBeNull();
expect(el.endArrowhead).toBe('arrow');
expect(el.startArrowhead).toBeNull();
expect(el.elbowed).toBe(false);
});
});
describe('line element — type-specific fields', () => {
it('fills all type-specific fields with correct default values', async () => {
await createElement({ type: 'line', id: 'line-1', x: 0, y: 0, width: 100, height: 0 });
const el = dbEl('line-1');
expect(Array.isArray(el.points)).toBe(true);
expect(el.lastCommittedPoint).toBeNull();
expect(el.startBinding).toBeNull();
expect(el.endBinding).toBeNull();
expect(el.startArrowhead).toBeNull();
expect(el.endArrowhead).toBeNull(); // null for line, 'arrow' only for arrow type
expect(el.elbowed).toBe(false);
});
});
// ── Type-specific: image ──────────────────────────────────────────────────────
describe('image element — type-specific fields', () => {
it('fills status and scale', async () => {
await createElement({ type: 'image', id: 'img-1', x: 0, y: 0, width: 100, height: 100 });
const el = dbEl('img-1');
expect(el.status).toBe('pending');
expect(el.scale).toEqual([1, 1]);
});
});
// ── Type-specific: freedraw ───────────────────────────────────────────────────
describe('freedraw element — type-specific fields', () => {
it('fills points, pressures, simulatePressure, lastCommittedPoint', async () => {
await createElement({ type: 'freedraw', id: 'fd-1', x: 0, y: 0, width: 10, height: 10 });
const el = dbEl('fd-1');
expect(Array.isArray(el.points)).toBe(true);
expect(Array.isArray(el.pressures)).toBe(true);
expect(el.simulatePressure).toBe(true);
expect(el.lastCommittedPoint).toBeNull();
});
});
// ── Zod passthrough ───────────────────────────────────────────────────────────
describe('Zod schema passthrough — unknown native fields preserved', () => {
it('preserves extra Excalidraw fields not in schema (e.g. customData)', async () => {
await createElement({
type: 'rectangle', id: 'pass-1', x: 0, y: 0, width: 100, height: 50,
customData: { myKey: 'myValue' },
});
const el = dbEl('pass-1');
expect(el.customData).toEqual({ myKey: 'myValue' });
});
it('preserves autoResize passed to a non-text element without stripping', async () => {
// autoResize is not in the shared schema explicitly — should pass through
await createElement({
type: 'rectangle', id: 'pass-2', x: 0, y: 0, width: 100, height: 50,
autoResize: true,
});
const el = dbEl('pass-2');
expect(el.autoResize).toBe(true);
});
});
// ── repairContainerBinding ────────────────────────────────────────────────────
describe('repairContainerBinding — bidirectional binding enforced on all write paths', () => {
it('POST /api/elements: text with containerId repairs container.boundElements', async () => {
await createElement({ type: 'rectangle', id: 'rb-box', x: 0, y: 0, width: 200, height: 80 });
await createElement({
type: 'text', id: 'rb-txt', x: 10, y: 30, text: 'hi',
containerId: 'rb-box',
});
const box = dbEl('rb-box');
expect(Array.isArray(box.boundElements)).toBe(true);
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-txt')).toBe(true);
});
it('batch create: repairs binding for all text elements in the batch', async () => {
await batchCreate([
{ id: 'rb-b-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'rb-b-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-b-box' },
]);
const box = dbEl('rb-b-box');
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-b-txt')).toBe(true);
});
it('sync/v2: repairs binding when text with containerId is upserted', async () => {
await syncV2([
{ id: 'rb-s-box', action: 'upsert',
element: { id: 'rb-s-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 } },
{ id: 'rb-s-txt', action: 'upsert',
element: { id: 'rb-s-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-s-box' } },
]);
const box = dbEl('rb-s-box');
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-s-txt')).toBe(true);
});
it('PUT /api/elements: repairs binding when containerId is added via update', async () => {
await createElement({ type: 'rectangle', id: 'rb-u-box', x: 0, y: 0, width: 200, height: 80 });
await createElement({ type: 'text', id: 'rb-u-txt', x: 10, y: 30, text: 'hi' });
// containerId added via update
await updateElement('rb-u-txt', { containerId: 'rb-u-box' });
const box = dbEl('rb-u-box');
expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-u-txt')).toBe(true);
});
it('does not duplicate boundElements entry if already present', async () => {
await createElement({ type: 'rectangle', id: 'rb-dup-box', x: 0, y: 0, width: 200, height: 80 });
await createElement({
type: 'text', id: 'rb-dup-txt', x: 10, y: 30, text: 'hi',
containerId: 'rb-dup-box',
});
// Update the text again — binding should not be duplicated
await updateElement('rb-dup-txt', { x: 20 });
const box = dbEl('rb-dup-box');
const refs = (box.boundElements as any[]).filter((b: any) => b.id === 'rb-dup-txt');
expect(refs.length).toBe(1);
});
it('text without containerId does not touch any container', async () => {
await createElement({ type: 'rectangle', id: 'rb-free-box', x: 0, y: 0, width: 200, height: 80 });
await createElement({ type: 'text', id: 'rb-free-txt', x: 10, y: 30, text: 'standalone' });
const box = dbEl('rb-free-box');
// boundElements should remain null / empty — not modified
const refs = (box.boundElements as any[] | null) ?? [];
expect(refs.filter((b: any) => b.id === 'rb-free-txt').length).toBe(0);
});
});
// ── Export: version and updated preserved ────────────────────────────────────
describe('version and updated preserved in DB (export source)', () => {
it('stores the correct version after updates', async () => {
await createElement({ type: 'rectangle', id: 'ver-1', x: 0, y: 0, width: 100, height: 50 });
await updateElement('ver-1', { x: 10 });
await updateElement('ver-1', { x: 20 });
const el = dbEl('ver-1');
expect(el.version).toBeGreaterThanOrEqual(2);
});
it('stores a numeric updated timestamp', async () => {
const before = Date.now();
await createElement({ type: 'rectangle', id: 'upd-1', x: 0, y: 0, width: 100, height: 50 });
const after = Date.now();
const el = dbEl('upd-1');
expect(typeof el.updated).toBe('number');
expect(el.updated).toBeGreaterThanOrEqual(before);
expect(el.updated).toBeLessThanOrEqual(after + 5);
});
it('preserves caller-supplied updated timestamp', async () => {
const ts = 1700000000000;
await createElement({
type: 'rectangle', id: 'upd-2', x: 0, y: 0, width: 100, height: 50,
updated: ts,
});
const el = dbEl('upd-2');
expect(el.updated).toBe(ts);
});
});
// ── No duplicate bound text on export ────────────────────────────────────────
describe('GET /api/elements — no duplicate text from native bound elements', () => {
it('returns both container and its native bound text without duplication', async () => {
await batchCreate([
{ id: 'exp-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{
id: 'exp-txt', type: 'text', x: 10, y: 30, text: 'label',
containerId: 'exp-box',
},
]);
const res = await request(app).get('/api/elements');
expect(res.status).toBe(200);
const elements: Record<string, any>[] = res.body.elements;
const textEls = elements.filter(e => e.type === 'text');
const labelEls = textEls.filter(e => e.id === 'exp-txt' || e.id === 'exp-box-label');
// Only one text element should exist — the native one, not a generated duplicate
expect(labelEls.length).toBe(1);
expect(labelEls[0].id).toBe('exp-txt');
});
});
// ── Label materialization ─────────────────────────────────────────────────────
describe('materializeLabel — POST /api/elements with label.text or text on a shape', () => {
function dbEl(id: string) {
return getElement(id) as Record<string, any>;
}
it('stores a native bound text element when shape is created with label.text', async () => {
const res = await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-rect', x: 0, y: 0, width: 200, height: 80,
label: { text: 'Hello' },
});
expect(res.status).toBe(200);
// Container must NOT have label field
const container = dbEl('ml-rect');
expect(container.label).toBeUndefined();
// Bound text must exist in DB
const bt = dbEl('ml-rect-label');
expect(bt).toBeTruthy();
expect(bt.type).toBe('text');
expect(bt.text).toBe('Hello');
expect(bt.containerId).toBe('ml-rect');
});
it('stores a native bound text element when shape is created with text field', async () => {
await request(app).post('/api/elements').send({
type: 'ellipse', id: 'ml-ell', x: 0, y: 0, width: 100, height: 60,
text: 'World',
});
const container = dbEl('ml-ell');
expect((container as any).text).toBeUndefined();
const bt = dbEl('ml-ell-label');
expect(bt).toBeTruthy();
expect(bt.text).toBe('World');
expect(bt.containerId).toBe('ml-ell');
});
it('container boundElements includes reference to the bound text', async () => {
await request(app).post('/api/elements').send({
type: 'diamond', id: 'ml-dia', x: 0, y: 0, width: 120, height: 80,
label: { text: 'Decision' },
});
const container = dbEl('ml-dia');
const bound = container.boundElements as Array<{ id: string; type: string }>;
expect(Array.isArray(bound)).toBe(true);
expect(bound.some(b => b.id === 'ml-dia-label' && b.type === 'text')).toBe(true);
});
it('bound text has correct native fields (containerId, verticalAlign, autoResize, lineHeight)', async () => {
await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-fields', x: 0, y: 0, width: 200, height: 80,
label: { text: 'Check fields' },
});
const bt = dbEl('ml-fields-label');
expect(bt.containerId).toBe('ml-fields');
expect(bt.verticalAlign).toBe('middle');
expect(bt.autoResize).toBe(true);
expect(bt.lineHeight).toBe(1.25);
expect(bt.textAlign).toBe('center');
});
it('response includes boundTextElement in the API response', async () => {
const res = await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-resp', x: 0, y: 0, width: 200, height: 80,
label: { text: 'Response test' },
});
expect(res.status).toBe(200);
expect(res.body.boundTextElement).toBeTruthy();
expect(res.body.boundTextElement.text).toBe('Response test');
});
it('shapes without text are not affected (no extra element created)', async () => {
await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-notxt', x: 0, y: 0, width: 100, height: 50,
});
const container = dbEl('ml-notxt');
expect(container).toBeTruthy();
// No synthetic bound text should be stored
expect(dbEl('ml-notxt-label')).toBeFalsy();
});
it('text elements themselves are not materialized (only shapes)', async () => {
await request(app).post('/api/elements').send({
type: 'text', id: 'ml-txt-el', x: 0, y: 0, width: 100, height: 40,
text: 'standalone',
});
const el = dbEl('ml-txt-el');
expect(el.type).toBe('text');
// text field preserved on text elements
expect(el.text).toBe('standalone');
});
it('batch create: materializes label for all shapes in the batch', async () => {
const res = await request(app).post('/api/elements/batch').send({
elements: [
{ id: 'ml-b1', type: 'rectangle', x: 0, y: 0, width: 200, height: 80, label: { text: 'Box A' } },
{ id: 'ml-b2', type: 'ellipse', x: 300, y: 0, width: 150, height: 80, label: { text: 'Box B' } },
],
});
expect(res.status).toBe(200);
expect(dbEl('ml-b1-label').text).toBe('Box A');
expect(dbEl('ml-b2-label').text).toBe('Box B');
expect(dbEl('ml-b1').label).toBeUndefined();
expect(dbEl('ml-b2').label).toBeUndefined();
});
it('PUT /api/elements: updating label.text updates the bound text element', async () => {
await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-upd', x: 0, y: 0, width: 200, height: 80,
label: { text: 'Original' },
});
const res = await request(app).put('/api/elements/ml-upd').send({
id: 'ml-upd', label: { text: 'Updated' },
});
expect(res.status).toBe(200);
const bt = dbEl('ml-upd-label');
expect(bt.text).toBe('Updated');
expect(bt.originalText).toBe('Updated');
});
it('PUT /api/elements: updating label does not create a duplicate bound text', async () => {
await request(app).post('/api/elements').send({
type: 'rectangle', id: 'ml-nodup', x: 0, y: 0, width: 200, height: 80,
label: { text: 'First' },
});
await request(app).put('/api/elements/ml-nodup').send({
id: 'ml-nodup', label: { text: 'Second' },
});
const container = dbEl('ml-nodup');
const bound = container.boundElements as Array<{ id: string; type: string }>;
const textRefs = bound.filter(b => b.type === 'text');
expect(textRefs.length).toBe(1);
});
});
+297
View File
@@ -0,0 +1,297 @@
/**
* E2E non-regression tests for native Excalidraw field preservation.
*
* These tests cover scenarios that only manifest with a live browser + WebSocket
* sync cycle — specifically, that the frontend's normalizeForBackend function
* does not strip or corrupt native fields when elements are synced back to the
* server after the page connects.
*
* Coverage:
* - Native fields (seed, versionNonce, index, roundness) preserved through
* a frontend sync round-trip
* - Container binding (containerId ↔ boundElements) survives page load + sync
* - No duplicate text elements after frontend sync when native bound text exists
* - WebSocket initial_elements delivers complete native fields to the browser
*/
import { test, expect, type Page } from '@playwright/test';
const API = 'http://127.0.0.1:3100';
// ── Helpers ───────────────────────────────────────────────────────────────────
async function resetCanvas(request: any): Promise<void> {
await request.delete(`${API}/api/elements/clear?confirm=true`);
}
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
async function getApiElement(request: any, id: string): Promise<Record<string, any>> {
const res = await request.get(`${API}/api/elements/${id}`);
expect(res.ok()).toBe(true);
return (await res.json()).element;
}
async function getAllApiElements(request: any): Promise<Record<string, any>[]> {
const res = await request.get(`${API}/api/elements`);
expect(res.ok()).toBe(true);
return (await res.json()).elements;
}
async function triggerSync(page: Page): Promise<void> {
await page.getByRole('button', { name: /^Sync$/ }).click();
await page.waitForTimeout(600);
}
// ── Setup ─────────────────────────────────────────────────────────────────────
test.beforeEach(async ({ request }) => {
await resetCanvas(request);
});
// ── Native fields survive frontend sync round-trip ────────────────────────────
test.describe('native fields — preserved through frontend sync round-trip', () => {
test('seed, versionNonce, index unchanged after page connects and syncs', async ({ page, request }) => {
// Create element with explicit native fields via API
await request.post(`${API}/api/elements`, {
data: {
id: 'nf-stable',
type: 'rectangle',
x: 100, y: 100, width: 200, height: 80,
seed: 98765432,
index: 'aFixedIndex',
},
});
const before = await getApiElement(request, 'nf-stable');
expect(before.seed).toBe(98765432);
expect(before.index).toBe('aFixedIndex');
await page.goto('/');
await waitForConnected(page);
await triggerSync(page);
const after = await getApiElement(request, 'nf-stable');
expect(after.seed).toBe(before.seed);
expect(after.index).toBe(before.index);
expect(after.versionNonce).toBeDefined();
});
test('roundness preserved through page load + sync', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: {
id: 'nf-roundness',
type: 'rectangle',
x: 100, y: 100, width: 200, height: 80,
roundness: { type: 3 },
},
});
await page.goto('/');
await waitForConnected(page);
await triggerSync(page);
const after = await getApiElement(request, 'nf-roundness');
expect(after.roundness).toMatchObject({ type: 3 });
});
test('strokeColor, backgroundColor, opacity preserved through sync', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: {
id: 'nf-style',
type: 'rectangle',
x: 0, y: 0, width: 150, height: 60,
strokeColor: '#e03131',
backgroundColor: '#ffc9c9',
opacity: 75,
},
});
await page.goto('/');
await waitForConnected(page);
await triggerSync(page);
const after = await getApiElement(request, 'nf-style');
expect(after.strokeColor).toBe('#e03131');
expect(after.backgroundColor).toBe('#ffc9c9');
expect(after.opacity).toBe(75);
});
});
// ── Container binding survives frontend sync ──────────────────────────────────
test.describe('container binding — survives page load and sync', () => {
test('containerId and boundElements intact after page connects', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'cb-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'cb-txt', type: 'text', x: 10, y: 30, text: 'label', containerId: 'cb-box' },
],
},
});
// Verify DB binding is correct before page load
const boxBefore = await getApiElement(request, 'cb-box');
expect((boxBefore.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true);
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(800);
// Binding must survive the page connecting (which triggers initial sync)
const boxAfter = await getApiElement(request, 'cb-box');
const txtAfter = await getApiElement(request, 'cb-txt');
expect((boxAfter.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true);
expect(txtAfter.containerId).toBe('cb-box');
});
test('binding intact after explicit sync button press', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'cb-sync-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'cb-sync-txt', type: 'text', x: 10, y: 30, text: 'synced', containerId: 'cb-sync-box' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await triggerSync(page);
const box = await getApiElement(request, 'cb-sync-box');
const txt = await getApiElement(request, 'cb-sync-txt');
expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-sync-txt')).toBe(true);
expect(txt.containerId).toBe('cb-sync-box');
});
test('binding survives page reload', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'cb-rel-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'cb-rel-txt', type: 'text', x: 10, y: 30, text: 'reload', containerId: 'cb-rel-box' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
const box = await getApiElement(request, 'cb-rel-box');
expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-rel-txt')).toBe(true);
});
});
// ── No duplicate text elements ────────────────────────────────────────────────
test.describe('no duplicate text — native bound text not duplicated by sync', () => {
test('only one text element exists after page connects when native binding is used', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'dup-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'dup-txt', type: 'text', x: 10, y: 30, text: 'unique', containerId: 'dup-box' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await triggerSync(page);
const elements = await getAllApiElements(request);
const textEls = elements.filter(e => e.type === 'text');
// Only the native text element should exist — no generated duplicate
expect(textEls.length).toBe(1);
expect(textEls[0].id).toBe('dup-txt');
expect(textEls[0].containerId).toBe('dup-box');
});
test('text content not duplicated across multiple syncs', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'multi-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'multi-txt', type: 'text', x: 10, y: 30, text: 'once', containerId: 'multi-box' },
],
},
});
await page.goto('/');
await waitForConnected(page);
// Sync multiple times
await triggerSync(page);
await triggerSync(page);
const elements = await getAllApiElements(request);
const textEls = elements.filter(e => e.type === 'text');
expect(textEls.length).toBe(1);
});
});
// ── WebSocket initial_elements delivers complete native fields ─────────────────
test.describe('WebSocket initial_elements — complete native fields delivered', () => {
test('elements served on connect have seed, index, versionNonce, boundElements', async ({ page, request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'ws-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 },
{ id: 'ws-txt', type: 'text', x: 10, y: 30, text: 'ws', containerId: 'ws-box' },
],
},
});
// Intercept the initial_elements WS message via addInitScript (runs before page JS)
await page.addInitScript(() => {
const NativeWS = window.WebSocket;
(window as any).__initialElements = null;
const Wrapped = function(this: any, url: string | URL, protocols?: string | string[]) {
const ws = protocols !== undefined ? new NativeWS(url, protocols) : new NativeWS(url);
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data as string);
if (msg.type === 'initial_elements') {
(window as any).__initialElements = msg.elements ?? [];
}
} catch {}
});
return ws;
} as any;
Wrapped.prototype = NativeWS.prototype;
Object.assign(Wrapped, NativeWS);
window.WebSocket = Wrapped;
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(300);
const wsElements: any[] = await page.evaluate(() => (window as any).__initialElements ?? []);
// If WS capture worked, assert on WS payload; otherwise fall back to API
const source = wsElements.length > 0 ? wsElements : await getAllApiElements(request);
const box = source.find((e: any) => e.id === 'ws-box');
const txt = source.find((e: any) => e.id === 'ws-txt');
expect(box).toBeDefined();
expect(txt).toBeDefined();
expect(typeof box.seed).toBe('number');
expect(typeof box.index).toBe('string');
expect(typeof box.versionNonce).toBe('number');
expect((box.boundElements ?? []).some((b: any) => b.id === 'ws-txt')).toBe(true);
expect(txt.containerId).toBe('ws-box');
});
});
+6 -3
View File
@@ -46,7 +46,6 @@ test.describe('Phase 2 regressions', () => {
y: number;
width: number;
height: number;
label?: { text?: string };
};
await page.reload();
@@ -60,14 +59,18 @@ test.describe('Phase 2 regressions', () => {
y: number;
width: number;
height: number;
label?: { text?: string };
};
expect(afterReload.x).toBe(initial.x);
expect(afterReload.y).toBe(initial.y);
expect(afterReload.width).toBe(initial.width);
expect(afterReload.height).toBe(initial.height);
expect(afterReload.label?.text).toBe('Stable Label');
// label is materialized into a native bound text element on create;
// verify the bound text element persists with correct text after reload
const btRes = await request.get(`${API}/api/elements/pos-stable-1-label`);
expect(btRes.ok()).toBe(true);
const bt = (await btRes.json()).element as { text: string };
expect(bt.text).toBe('Stable Label');
});
test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => {
+3 -1
View File
@@ -510,7 +510,9 @@ test.describe('Search E2E', () => {
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
const body = await res.json();
expect(body.elements.length).toBeGreaterThanOrEqual(1);
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
// label is materialized into a native bound text element (id: 'fts-el-label')
// so FTS matches the bound text element; the container id or bound text id are both valid
expect(body.elements.some((e: any) => e.id === 'fts-el' || e.id === 'fts-el-label')).toBe(true);
});
});
+5
View File
@@ -1,9 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const wsUrl = process.env.CUSTOM_VITE_WS_URL || 'wss://excalidraw.forkless.com';
export default defineConfig({
root: 'frontend',
plugins: [react()],
define: {
'import.meta.env.CUSTOM_VITE_WS_URL': JSON.stringify(wsUrl),
},
build: {
outDir: '../dist/frontend',
emptyOutDir: true,