Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9b052b9a | ||
|
|
b1ae502435 | ||
|
|
ca559acb3f | ||
|
|
53faaca5ab | ||
|
|
2ee3317b00 | ||
|
|
40b882672a | ||
|
|
bb710b46b0 | ||
|
|
9e53a9b92c | ||
|
|
7f01d76c2b | ||
|
|
a1db6e782d | ||
|
|
7222c9ae63 | ||
|
|
a8ee1c229e | ||
|
|
a06c3d2e36 | ||
|
|
bc5c924c8e | ||
|
|
cc3403363c | ||
|
|
e44053a25d | ||
|
|
6ed842dae9 | ||
|
|
0926415ffa | ||
|
|
ac638a3586 | ||
|
|
3de5951e48 | ||
|
|
5775ec1e3b | ||
|
|
d50de9232f | ||
|
|
547fd6affb | ||
|
|
460671e753 | ||
|
|
32188d93b4 | ||
|
|
27dbc1f40e | ||
|
|
2b6500c697 | ||
|
|
0e8c855818 | ||
|
|
936dd63976 | ||
|
|
4dceeb2e7f | ||
|
|
a4a1b03215 | ||
|
|
f91082d2a3 | ||
|
|
82574e7308 | ||
|
|
8d99a58600 | ||
|
|
f1b71ca5b8 | ||
|
|
a092e5b548 | ||
|
|
37be53dae8 | ||
|
|
a8723f6875 | ||
|
|
046719aabc | ||
|
|
9846e0ba0f |
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search.
|
||||
3. **UI switcher**: The canvas UI shows a "Workspace: <name>" 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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
|
||||
customData?: Record<string, any> | null;
|
||||
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
||||
updated?: number;
|
||||
index?: string;
|
||||
containerId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user