Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41cbd393bd | |||
| 8aa53ed3a8 | |||
| 65ecc8bfa4 | |||
| fcfde9f862 | |||
| f349617427 | |||
| d7a0a5d076 | |||
| 83191055bd | |||
| ab786a7861 | |||
| 6911033c1a | |||
| c13c137e24 | |||
| 1b0024d78f | |||
| 570baf83ab | |||
| 7e0ec4b1c4 | |||
| 9ba2d91d05 | |||
| e61d7c9cbd |
@@ -35,3 +35,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
|
||||||
|
excali-box-data/
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@mcp path /mcp
|
||||||
|
handle @mcp {
|
||||||
|
reverse_proxy 127.0.0.1:3001
|
||||||
|
}
|
||||||
|
|
||||||
|
@api path /api/*
|
||||||
|
handle @api {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
@root path /
|
||||||
|
handle @root {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
root * /srv/public
|
||||||
|
|
||||||
|
@html path /index.html /d/*
|
||||||
|
header @html Cache-Control "no-cache"
|
||||||
|
|
||||||
|
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
|
||||||
|
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-7
@@ -5,21 +5,31 @@ COPY package.json bun.lock tsconfig.json ./
|
|||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY README.md ./
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM caddy:2-alpine AS caddy
|
||||||
|
|
||||||
FROM oven/bun:1.3.11-alpine AS runner
|
FROM oven/bun:1.3.11-alpine AS runner
|
||||||
WORKDIR /app/dist
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=127.0.0.1
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ENV MCP_HOST=127.0.0.1
|
||||||
|
ENV MCP_PORT=3001
|
||||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||||
|
|
||||||
COPY --from=build /app/dist ./
|
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||||
|
COPY --from=build /app/dist/public /srv/public
|
||||||
|
COPY --from=build /app/dist/server /app/dist/server
|
||||||
|
COPY --from=build /app/dist/mcp /app/dist/mcp
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
VOLUME ["/data"]
|
||||||
|
|
||||||
CMD ["bun", "./server.js"]
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# excali-box
|
# excali-box
|
||||||
|
|
||||||
Private self-hosted Excalidraw with Bun and SQLite.
|
Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
||||||
|
|
||||||
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||||
No auth to keep things simple - you either run in a private network or put it behind a Caddy auth or similar solutions.
|
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
||||||
|
|
||||||
> GPT 5.5 generated
|
> GPT 5.5 generated
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Images are published to:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/ruinivist/excalidraw-box:latest
|
docker pull ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
|
docker run --rm -p 127.0.0.1:3000:80 -e PUBLIC_BASE_URL=http://localhost:3000 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
@@ -38,7 +38,7 @@ bun run typecheck
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t excali .
|
docker build -t excali .
|
||||||
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" excali
|
docker run --rm -p 127.0.0.1:3000:80 -e PUBLIC_BASE_URL=http://localhost:3000 -v "$PWD/excali-box-data:/data" excali
|
||||||
```
|
```
|
||||||
|
|
||||||
This will make the data persistent in the `excali-box-data` folder in the current directory, the site will be available at `localhost:3000`.
|
This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`. `PUBLIC_BASE_URL` is required so MCP tools can return drawing URLs with the public browser origin. You would want it to be the same url you use to access the app in the browser - in case you reverse proxy or use a tailscale alias etc.
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
"name": "excali",
|
"name": "excali",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "^0.18.1",
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"mcp-handler": "^1.1.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
@@ -52,12 +56,16 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||||
|
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||||
|
|
||||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||||
|
|
||||||
"@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
|
"@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
|
||||||
|
|
||||||
"@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="],
|
"@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
|
||||||
@@ -108,6 +116,18 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
|
||||||
|
|
||||||
|
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
|
||||||
|
|
||||||
|
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
|
||||||
|
|
||||||
|
"@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="],
|
||||||
|
|
||||||
|
"@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="],
|
||||||
|
|
||||||
|
"@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="],
|
||||||
|
|
||||||
|
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||||
@@ -184,20 +204,36 @@
|
|||||||
|
|
||||||
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
|
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
|
||||||
|
|
||||||
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"browser-fs-access": ["browser-fs-access@0.29.1", "", {}, "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw=="],
|
"browser-fs-access": ["browser-fs-access@0.29.1", "", {}, "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
"canvas-roundrect-polyfill": ["canvas-roundrect-polyfill@0.0.1", "", {}, "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw=="],
|
"canvas-roundrect-polyfill": ["canvas-roundrect-polyfill@0.0.1", "", {}, "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="],
|
"chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="],
|
||||||
|
|
||||||
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
|
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
|
||||||
@@ -206,7 +242,19 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@1.1.1", "", {}, "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="],
|
"clsx": ["clsx@1.1.1", "", {}, "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="],
|
||||||
|
|
||||||
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||||
|
|
||||||
|
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||||
|
|
||||||
|
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||||
|
|
||||||
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||||
|
|
||||||
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
|
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
|
||||||
|
|
||||||
@@ -292,33 +340,91 @@
|
|||||||
|
|
||||||
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
|
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
|
||||||
|
|
||||||
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
|
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
||||||
|
|
||||||
"es6-promise-pool": ["es6-promise-pool@2.5.0", "", {}, "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA=="],
|
"es6-promise-pool": ["es6-promise-pool@2.5.0", "", {}, "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
|
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||||
|
|
||||||
|
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||||
|
|
||||||
|
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
|
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
|
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
"fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
|
"fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
|
||||||
|
|
||||||
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"fuzzy": ["fuzzy@0.1.3", "", {}, "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w=="],
|
"fuzzy": ["fuzzy@0.1.3", "", {}, "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w=="],
|
||||||
|
|
||||||
|
"generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"glur": ["glur@1.1.2", "", {}, "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA=="],
|
"glur": ["glur@1.1.2", "", {}, "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||||
|
|
||||||
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
|
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
|
||||||
|
|
||||||
@@ -330,6 +436,10 @@
|
|||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||||
|
|
||||||
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
@@ -338,12 +448,20 @@
|
|||||||
|
|
||||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||||
|
|
||||||
"jotai": ["jotai@2.11.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ=="],
|
"jotai": ["jotai@2.11.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ=="],
|
||||||
|
|
||||||
"jotai-scope": ["jotai-scope@0.7.2", "", { "peerDependencies": { "jotai": ">=2.9.2", "react": ">=17.0.0" } }, "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg=="],
|
"jotai-scope": ["jotai-scope@0.7.2", "", { "peerDependencies": { "jotai": ">=2.9.2", "react": ">=17.0.0" } }, "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||||
|
|
||||||
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||||
@@ -360,32 +478,60 @@
|
|||||||
|
|
||||||
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"mcp-handler": ["mcp-handler@1.1.0", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^11.1.0", "redis": "^4.6.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "1.26.0", "next": ">=13.0.0" }, "optionalPeers": ["next"], "bin": { "mcp-adapter": "dist/cli/index.js", "mcp-handler": "dist/cli/index.js", "create-mcp-route": "dist/cli/index.js" } }, "sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg=="],
|
||||||
|
|
||||||
|
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
|
|
||||||
|
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||||
|
|
||||||
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
|
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
|
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.3", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w=="],
|
"nanoid": ["nanoid@3.3.3", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"open-color": ["open-color@1.9.1", "", {}, "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw=="],
|
"open-color": ["open-color@1.9.1", "", {}, "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw=="],
|
||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||||
|
|
||||||
"pako": ["pako@2.0.3", "", {}, "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw=="],
|
"pako": ["pako@2.0.3", "", {}, "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw=="],
|
||||||
|
|
||||||
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
"perfect-freehand": ["perfect-freehand@1.2.0", "", {}, "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw=="],
|
"perfect-freehand": ["perfect-freehand@1.2.0", "", {}, "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw=="],
|
||||||
|
|
||||||
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
|
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
"png-chunk-text": ["png-chunk-text@1.0.0", "", {}, "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw=="],
|
"png-chunk-text": ["png-chunk-text@1.0.0", "", {}, "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw=="],
|
||||||
|
|
||||||
"png-chunks-encode": ["png-chunks-encode@1.0.0", "", { "dependencies": { "crc-32": "^0.3.0", "sliced": "^1.0.1" } }, "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA=="],
|
"png-chunks-encode": ["png-chunks-encode@1.0.0", "", { "dependencies": { "crc-32": "^0.3.0", "sliced": "^1.0.1" } }, "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA=="],
|
||||||
@@ -396,8 +542,16 @@
|
|||||||
|
|
||||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||||
|
|
||||||
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||||
|
|
||||||
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||||
@@ -410,10 +564,16 @@
|
|||||||
|
|
||||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||||
|
|
||||||
"roughjs": ["roughjs@4.6.4", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw=="],
|
"roughjs": ["roughjs@4.6.4", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw=="],
|
||||||
|
|
||||||
|
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
@@ -422,30 +582,52 @@
|
|||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
|
|
||||||
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||||
|
|
||||||
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
|
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
|
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="],
|
"tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="],
|
||||||
|
|
||||||
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
@@ -454,6 +636,8 @@
|
|||||||
|
|
||||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||||
|
|
||||||
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||||
|
|
||||||
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
|
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
|
||||||
@@ -470,6 +654,14 @@
|
|||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
|
|
||||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||||
|
|
||||||
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
||||||
@@ -524,10 +716,14 @@
|
|||||||
|
|
||||||
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||||
|
|
||||||
|
"d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
||||||
|
|
||||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||||
|
|
||||||
|
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|
||||||
"mermaid/@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
"mermaid/@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||||
|
|
||||||
"mermaid/@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
|
"mermaid/@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
|
||||||
@@ -538,6 +734,8 @@
|
|||||||
|
|
||||||
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||||
|
|
||||||
|
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="],
|
"@radix-ui/react-roving-focus/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="],
|
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
app_pid=""
|
||||||
|
mcp_pid=""
|
||||||
|
caddy_pid=""
|
||||||
|
|
||||||
|
stop_children() {
|
||||||
|
for pid in "$app_pid" "$mcp_pid" "$caddy_pid"; do
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'stop_children' INT TERM
|
||||||
|
|
||||||
|
bun /app/dist/server/server.js &
|
||||||
|
app_pid=$!
|
||||||
|
|
||||||
|
bun /app/dist/mcp/mcp-server.js &
|
||||||
|
mcp_pid=$!
|
||||||
|
|
||||||
|
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
|
||||||
|
caddy_pid=$!
|
||||||
|
|
||||||
|
exit_code=0
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
if ! kill -0 "$app_pid" 2>/dev/null; then
|
||||||
|
wait "$app_pid" || exit_code=$?
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kill -0 "$mcp_pid" 2>/dev/null; then
|
||||||
|
wait "$mcp_pid" || exit_code=$?
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kill -0 "$caddy_pid" 2>/dev/null; then
|
||||||
|
wait "$caddy_pid" || exit_code=$?
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
stop_children
|
||||||
|
wait "$app_pid" 2>/dev/null || true
|
||||||
|
wait "$mcp_pid" 2>/dev/null || true
|
||||||
|
wait "$caddy_pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
exit "$exit_code"
|
||||||
+11
-4
@@ -3,16 +3,23 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/server.tsx",
|
"dev": "bun --hot src/server/dev-server.tsx",
|
||||||
"build": "rm -rf dist && bun build --minify --target=browser ./src/index.html --outdir ./dist/public && bun build --target=bun ./src/server.tsx --outdir ./dist",
|
"build": "rm -rf dist && bun run build:client && bun run build:server && bun run build:mcp",
|
||||||
"start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js",
|
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
|
||||||
|
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
|
||||||
|
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
|
||||||
|
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "^0.18.1",
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"mcp-handler": "^1.1.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
|
|||||||
-512
@@ -1,512 +0,0 @@
|
|||||||
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawInitialDataState,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared";
|
|
||||||
|
|
||||||
type DrawingResponse = DrawingMeta & ScenePayload;
|
|
||||||
|
|
||||||
const EXCALIDRAW_THEME_TOKEN_MAP = [
|
|
||||||
["--island-bg-color", "--app-island-bg"],
|
|
||||||
["--sidebar-bg-color", "--app-sidebar-bg"],
|
|
||||||
["--sidebar-border-color", "--app-sidebar-border"],
|
|
||||||
["--sidebar-shadow", "--app-sidebar-shadow"],
|
|
||||||
["--color-surface-lowest", "--app-surface-lowest"],
|
|
||||||
["--color-surface-low", "--app-surface-low"],
|
|
||||||
["--color-surface-primary-container", "--app-selected-bg"],
|
|
||||||
["--color-on-surface", "--app-text-color"],
|
|
||||||
["--color-on-primary-container", "--app-selected-text-color"],
|
|
||||||
["--color-disabled", "--app-disabled-color"],
|
|
||||||
["--input-bg-color", "--app-input-bg"],
|
|
||||||
["--input-border-color", "--app-input-border"],
|
|
||||||
["--input-label-color", "--app-input-color"],
|
|
||||||
["--default-border-color", "--app-button-border"],
|
|
||||||
["--button-hover-bg", "--app-button-hover-bg"],
|
|
||||||
["--button-active-bg", "--app-button-active-bg"],
|
|
||||||
["--button-active-border", "--app-button-active-border"],
|
|
||||||
["--overlay-bg-color", "--app-overlay-bg"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function pathToId(pathname = window.location.pathname): string | null {
|
|
||||||
const match = pathname.match(/^\/d\/([^/]+)$/);
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
|
|
||||||
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
|
|
||||||
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
|
||||||
return sortDrawings([drawing, ...filtered]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
|
|
||||||
return scene as ExcalidrawInitialDataState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sceneFromEditor(
|
|
||||||
elements: readonly unknown[],
|
|
||||||
appState: AppState,
|
|
||||||
files: BinaryFiles,
|
|
||||||
): ScenePayload {
|
|
||||||
return {
|
|
||||||
elements: [...elements],
|
|
||||||
appState: appState as unknown as Record<string, unknown>,
|
|
||||||
files: files as unknown as Record<string, unknown>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...(init?.body ? { "content-type": "application/json" } : {}),
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = (await response.json()) as T & { error?: string };
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(body.error ?? `Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerIcon() {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
||||||
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(pathToId());
|
|
||||||
const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
|
|
||||||
const [scene, setScene] = useState<ScenePayload | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
const currentIdRef = useRef<string | null>(activeId);
|
|
||||||
const currentTitleRef = useRef(activeTitle);
|
|
||||||
const latestSceneRef = useRef<ScenePayload | null>(null);
|
|
||||||
const ignoreChangeRef = useRef(false);
|
|
||||||
const appShellRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const timeoutRef = useRef<number | null>(null);
|
|
||||||
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
|
||||||
const loadVersionRef = useRef(0);
|
|
||||||
const themeSyncFrameRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const activeDrawing = useMemo(
|
|
||||||
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
|
|
||||||
[activeId, drawings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
|
||||||
const list = await requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
|
||||||
setDrawings(sortDrawings(list));
|
|
||||||
return list;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveScene = useCallback(async () => {
|
|
||||||
const drawingId = currentIdRef.current;
|
|
||||||
const nextScene = latestSceneRef.current;
|
|
||||||
if (!drawingId || !nextScene) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(nextScene),
|
|
||||||
})
|
|
||||||
.then((body) => {
|
|
||||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
|
||||||
})
|
|
||||||
.catch((saveError: unknown) => {
|
|
||||||
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
|
||||||
throw saveError;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inFlightSaveRef.current = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
inFlightSaveRef.current = promise;
|
|
||||||
await promise;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushPendingSave = useCallback(async () => {
|
|
||||||
if (timeoutRef.current !== null) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
await saveScene();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inFlightSaveRef.current) {
|
|
||||||
await inFlightSaveRef.current;
|
|
||||||
}
|
|
||||||
}, [saveScene]);
|
|
||||||
|
|
||||||
const scheduleSave = useCallback(() => {
|
|
||||||
if (timeoutRef.current !== null) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutRef.current = window.setTimeout(() => {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
void saveScene();
|
|
||||||
}, 1500);
|
|
||||||
}, [saveScene]);
|
|
||||||
|
|
||||||
const loadDrawing = useCallback(
|
|
||||||
async (drawingId: string) => {
|
|
||||||
const version = ++loadVersionRef.current;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [list, drawing] = await Promise.all([
|
|
||||||
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
|
||||||
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (version !== loadVersionRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextScene = {
|
|
||||||
elements: drawing.elements,
|
|
||||||
appState: drawing.appState,
|
|
||||||
files: drawing.files,
|
|
||||||
};
|
|
||||||
|
|
||||||
ignoreChangeRef.current = true;
|
|
||||||
currentIdRef.current = drawing.id;
|
|
||||||
currentTitleRef.current = drawing.title;
|
|
||||||
latestSceneRef.current = nextScene;
|
|
||||||
|
|
||||||
setDrawings(sortDrawings(list));
|
|
||||||
setActiveId(drawing.id);
|
|
||||||
setActiveTitle(drawing.title);
|
|
||||||
setScene(nextScene);
|
|
||||||
} catch (loadError) {
|
|
||||||
const list = await refreshList();
|
|
||||||
if (list.length === 0) {
|
|
||||||
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
|
||||||
window.history.replaceState(null, "", `/d/${created.id}`);
|
|
||||||
await loadDrawing(created.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = list[0];
|
|
||||||
if (fallback && fallback.id !== drawingId) {
|
|
||||||
window.history.replaceState(null, "", `/d/${fallback.id}`);
|
|
||||||
await loadDrawing(fallback.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
|
|
||||||
} finally {
|
|
||||||
if (version === loadVersionRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refreshList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateToDrawing = useCallback(
|
|
||||||
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
|
|
||||||
const replace = options?.replace ?? false;
|
|
||||||
const flush = options?.flush ?? true;
|
|
||||||
|
|
||||||
if (drawingId === currentIdRef.current && latestSceneRef.current) {
|
|
||||||
if (replace) {
|
|
||||||
window.history.replaceState(null, "", `/d/${drawingId}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flush) {
|
|
||||||
await flushPendingSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replace) {
|
|
||||||
window.history.replaceState(null, "", `/d/${drawingId}`);
|
|
||||||
} else {
|
|
||||||
window.history.pushState(null, "", `/d/${drawingId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadDrawing(drawingId);
|
|
||||||
},
|
|
||||||
[flushPendingSave, loadDrawing],
|
|
||||||
);
|
|
||||||
|
|
||||||
const createDrawing = useCallback(async () => {
|
|
||||||
await flushPendingSave();
|
|
||||||
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
|
||||||
await navigateToDrawing(created.id, { flush: false });
|
|
||||||
}, [flushPendingSave, navigateToDrawing]);
|
|
||||||
|
|
||||||
const deleteDrawing = useCallback(
|
|
||||||
async (drawingId: string) => {
|
|
||||||
if (!window.confirm("Delete this drawing?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drawingId === currentIdRef.current) {
|
|
||||||
await flushPendingSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (drawingId === currentIdRef.current && response.nextId) {
|
|
||||||
await navigateToDrawing(response.nextId, { replace: true, flush: false });
|
|
||||||
} else {
|
|
||||||
await refreshList();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[flushPendingSave, navigateToDrawing, refreshList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTitleSubmit = useCallback(async () => {
|
|
||||||
const drawingId = currentIdRef.current;
|
|
||||||
if (!drawingId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ title: currentTitleRef.current }),
|
|
||||||
});
|
|
||||||
|
|
||||||
currentTitleRef.current = body.drawing.title;
|
|
||||||
setActiveTitle(body.drawing.title);
|
|
||||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
|
||||||
} catch (renameError) {
|
|
||||||
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const syncThemeTokens = useCallback(() => {
|
|
||||||
const appShell = appShellRef.current;
|
|
||||||
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
|
|
||||||
if (!appShell || !excalidrawRoot) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
|
||||||
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
|
||||||
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
|
||||||
if (value) {
|
|
||||||
appShell.style.setProperty(targetToken, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleThemeTokenSync = useCallback(() => {
|
|
||||||
if (themeSyncFrameRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
|
|
||||||
themeSyncFrameRef.current = null;
|
|
||||||
syncThemeTokens();
|
|
||||||
});
|
|
||||||
}, [syncThemeTokens]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPathId = pathToId();
|
|
||||||
if (currentPathId) {
|
|
||||||
void loadDrawing(currentPathId);
|
|
||||||
} else {
|
|
||||||
window.location.replace("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPopState = () => {
|
|
||||||
const nextId = pathToId();
|
|
||||||
if (nextId) {
|
|
||||||
void navigateToDrawing(nextId, { replace: true, flush: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("popstate", onPopState);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("popstate", onPopState);
|
|
||||||
};
|
|
||||||
}, [loadDrawing, navigateToDrawing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current !== null) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (themeSyncFrameRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scene || loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleThemeTokenSync();
|
|
||||||
}, [activeId, loading, scene, scheduleThemeTokenSync]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSidebarOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, [isSidebarOpen]);
|
|
||||||
|
|
||||||
const openSidebar = useCallback(() => {
|
|
||||||
setIsSidebarOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSidebar = useCallback(() => {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectDrawing = useCallback(
|
|
||||||
async (drawingId: string) => {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
await navigateToDrawing(drawingId);
|
|
||||||
},
|
|
||||||
[navigateToDrawing],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateDrawing = useCallback(async () => {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
await createDrawing();
|
|
||||||
}, [createDrawing]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-shell" ref={appShellRef}>
|
|
||||||
<main className="editor-shell">
|
|
||||||
<div className="app-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button app-actions-toggle"
|
|
||||||
onClick={openSidebar}
|
|
||||||
aria-label="Open drawings"
|
|
||||||
>
|
|
||||||
<DrawerIcon />
|
|
||||||
<span className="sr-only">Open drawings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading || !scene ? (
|
|
||||||
<div className="editor-loading">{error ?? "Loading..."}</div>
|
|
||||||
) : (
|
|
||||||
<div className="editor-frame">
|
|
||||||
<Excalidraw
|
|
||||||
key={activeDrawing?.id ?? activeId}
|
|
||||||
initialData={sceneToInitialData(scene)}
|
|
||||||
onChange={(elements, appState, files) => {
|
|
||||||
const nextScene = sceneFromEditor(elements, appState, files);
|
|
||||||
latestSceneRef.current = nextScene;
|
|
||||||
scheduleThemeTokenSync();
|
|
||||||
|
|
||||||
if (ignoreChangeRef.current) {
|
|
||||||
ignoreChangeRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSave();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<div
|
|
||||||
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
|
||||||
onClick={closeSidebar}
|
|
||||||
aria-hidden={!isSidebarOpen}
|
|
||||||
/>
|
|
||||||
<aside className={isSidebarOpen ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!isSidebarOpen}>
|
|
||||||
<div className="sidebar-header">
|
|
||||||
<h1>excali-box</h1>
|
|
||||||
<div className="sidebar-actions">
|
|
||||||
<button type="button" className="primary-button" onClick={() => void handleCreateDrawing()}>
|
|
||||||
New
|
|
||||||
</button>
|
|
||||||
<button type="button" className="icon-button" onClick={closeSidebar} aria-label="Close drawings">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="drawing-list">
|
|
||||||
{drawings.map((drawing) => (
|
|
||||||
<div
|
|
||||||
key={drawing.id}
|
|
||||||
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
|
||||||
>
|
|
||||||
{drawing.id === activeId ? (
|
|
||||||
<div className="drawing-link">
|
|
||||||
<input
|
|
||||||
className="title-input"
|
|
||||||
value={activeTitle}
|
|
||||||
onChange={(event) => {
|
|
||||||
currentTitleRef.current = event.target.value;
|
|
||||||
setActiveTitle(event.target.value);
|
|
||||||
}}
|
|
||||||
onBlur={() => void handleTitleSubmit()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.currentTarget.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="drawing-link"
|
|
||||||
onClick={() => void handleSelectDrawing(drawing.id)}
|
|
||||||
>
|
|
||||||
<span className="drawing-title">{drawing.title}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => void deleteDrawing(drawing.id)}
|
|
||||||
aria-label={`Delete ${drawing.title}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createApi } from "./api";
|
|
||||||
import { createDrawingStore } from "./db";
|
|
||||||
|
|
||||||
function withApi() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
|
||||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
const api = createApi(store);
|
|
||||||
|
|
||||||
return {
|
|
||||||
api,
|
|
||||||
cleanup() {
|
|
||||||
store.close();
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("api", () => {
|
|
||||||
test("creates a drawing with dark theme by default", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
|
|
||||||
const created = await api.createDrawing().json();
|
|
||||||
|
|
||||||
expect(created.appState.theme).toBe("dark");
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 400 for invalid JSON", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
const drawing = await api.createDrawing().json();
|
|
||||||
|
|
||||||
const response = await api.updateDrawing(
|
|
||||||
Object.assign(
|
|
||||||
new Request(`http://local/api/drawings/${drawing.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: "{",
|
|
||||||
}),
|
|
||||||
{ params: { id: drawing.id } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 404 for missing drawing", () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
|
|
||||||
const response = api.getDrawing(
|
|
||||||
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates a drawing scene", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
const created = await api.createDrawing().json();
|
|
||||||
|
|
||||||
const response = await api.updateDrawing(
|
|
||||||
Object.assign(
|
|
||||||
new Request(`http://local/api/drawings/${created.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
elements: [{ id: "shape" }],
|
|
||||||
appState: {},
|
|
||||||
files: {},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{ params: { id: created.id } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
|
||||||
|
import { EditorCanvas } from "./components/EditorCanvas";
|
||||||
|
import { useDrawingSession } from "./hooks/useDrawingSession";
|
||||||
|
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
|
||||||
|
const {
|
||||||
|
drawings,
|
||||||
|
activeId,
|
||||||
|
activeTitle,
|
||||||
|
scene,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
toastMessage,
|
||||||
|
editorReloadNonce,
|
||||||
|
setActiveTitle,
|
||||||
|
submitTitle,
|
||||||
|
handleSceneChange,
|
||||||
|
selectDrawing,
|
||||||
|
createDrawing,
|
||||||
|
deleteDrawing,
|
||||||
|
} = useDrawingSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene || loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleThemeTokenSync();
|
||||||
|
}, [activeId, loading, scene, scheduleThemeTokenSync]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSidebarOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
|
const openSidebar = useCallback(() => {
|
||||||
|
setIsSidebarOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSidebar = useCallback(() => {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectDrawing = useCallback(
|
||||||
|
(drawingId: string) => {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
void selectDrawing(drawingId);
|
||||||
|
},
|
||||||
|
[selectDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateDrawing = useCallback(() => {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
void createDrawing();
|
||||||
|
}, [createDrawing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell" ref={appShellRef}>
|
||||||
|
{toastMessage && (
|
||||||
|
<div className="toast-overlay" aria-live="polite">
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="editor-shell">
|
||||||
|
<div className="app-actions">
|
||||||
|
<DrawingsToggle onClick={openSidebar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorCanvas
|
||||||
|
activeId={activeId}
|
||||||
|
scene={scene}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
editorReloadNonce={editorReloadNonce}
|
||||||
|
onSceneChange={handleSceneChange}
|
||||||
|
onEditorActivity={scheduleThemeTokenSync}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<DrawingSidebar
|
||||||
|
open={isSidebarOpen}
|
||||||
|
drawings={drawings}
|
||||||
|
activeId={activeId}
|
||||||
|
activeTitle={activeTitle}
|
||||||
|
onClose={closeSidebar}
|
||||||
|
onCreate={handleCreateDrawing}
|
||||||
|
onSelect={handleSelectDrawing}
|
||||||
|
onDelete={(drawingId) => void deleteDrawing(drawingId)}
|
||||||
|
onTitleChange={setActiveTitle}
|
||||||
|
onTitleSubmit={() => void submitTitle()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { type DrawingMeta } from "../../core/shared";
|
||||||
|
|
||||||
|
type DrawingSidebarProps = {
|
||||||
|
open: boolean;
|
||||||
|
drawings: DrawingMeta[];
|
||||||
|
activeId: string | null;
|
||||||
|
activeTitle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onSelect: (drawingId: string) => void;
|
||||||
|
onDelete: (drawingId: string) => void;
|
||||||
|
onTitleChange: (title: string) => void;
|
||||||
|
onTitleSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DrawerIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingsToggle({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button app-actions-toggle"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Open drawings"
|
||||||
|
>
|
||||||
|
<DrawerIcon />
|
||||||
|
<span className="sr-only">Open drawings</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingSidebar({
|
||||||
|
open,
|
||||||
|
drawings,
|
||||||
|
activeId,
|
||||||
|
activeTitle,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onTitleChange,
|
||||||
|
onTitleSubmit,
|
||||||
|
}: DrawingSidebarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden={!open}
|
||||||
|
/>
|
||||||
|
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<h1>excali-box</h1>
|
||||||
|
<div className="sidebar-actions">
|
||||||
|
<button type="button" className="primary-button" onClick={onCreate}>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="drawing-list">
|
||||||
|
{drawings.map((drawing) => (
|
||||||
|
<div
|
||||||
|
key={drawing.id}
|
||||||
|
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
||||||
|
>
|
||||||
|
{drawing.id === activeId ? (
|
||||||
|
<div className="drawing-link">
|
||||||
|
<input
|
||||||
|
className="title-input"
|
||||||
|
value={activeTitle}
|
||||||
|
onChange={(event) => onTitleChange(event.target.value)}
|
||||||
|
onBlur={onTitleSubmit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
|
||||||
|
<span className="drawing-title">{drawing.title}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => onDelete(drawing.id)}
|
||||||
|
aria-label={`Delete ${drawing.title}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawInitialDataState,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import { type ScenePayload } from "../../core/shared";
|
||||||
|
|
||||||
|
type EditorCanvasProps = {
|
||||||
|
activeId: string | null;
|
||||||
|
scene: ScenePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
editorReloadNonce: number;
|
||||||
|
onSceneChange: (scene: ScenePayload) => void;
|
||||||
|
onEditorActivity: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
|
||||||
|
return scene as ExcalidrawInitialDataState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneFromEditor(
|
||||||
|
elements: readonly unknown[],
|
||||||
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
|
): ScenePayload {
|
||||||
|
return {
|
||||||
|
elements: [...elements],
|
||||||
|
appState: appState as unknown as Record<string, unknown>,
|
||||||
|
files: files as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorCanvas({
|
||||||
|
activeId,
|
||||||
|
scene,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
editorReloadNonce,
|
||||||
|
onSceneChange,
|
||||||
|
onEditorActivity,
|
||||||
|
}: EditorCanvasProps) {
|
||||||
|
if (loading || !scene) {
|
||||||
|
return <div className="editor-loading">{error ?? "Loading..."}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-frame">
|
||||||
|
<Excalidraw
|
||||||
|
key={`${activeId}:${editorReloadNonce}`}
|
||||||
|
initialData={sceneToInitialData(scene)}
|
||||||
|
onChange={(elements, appState, files) => {
|
||||||
|
onEditorActivity();
|
||||||
|
onSceneChange(sceneFromEditor(elements, appState, files));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared";
|
||||||
|
|
||||||
|
type DrawingResponse = DrawingMeta & ScenePayload;
|
||||||
|
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
|
||||||
|
type UpdateResponse = { ok: true; drawing: DrawingMeta };
|
||||||
|
|
||||||
|
class RequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly body: ApiErrorBody,
|
||||||
|
) {
|
||||||
|
super(body.error ?? `Request failed: ${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToId(pathname = window.location.pathname): string | null {
|
||||||
|
const match = pathname.match(/^\/d\/([^/]+)$/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
|
||||||
|
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
|
||||||
|
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
||||||
|
return sortDrawings([drawing, ...filtered]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await response.json()) as T & ApiErrorBody;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RequestError(response.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDrawingList() {
|
||||||
|
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
|
||||||
|
return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
|
||||||
|
return {
|
||||||
|
elements: drawing.elements,
|
||||||
|
appState: drawing.appState,
|
||||||
|
files: drawing.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawingSession() {
|
||||||
|
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(pathToId());
|
||||||
|
const [activeTitle, setActiveTitleState] = useState(DEFAULT_TITLE);
|
||||||
|
const [scene, setScene] = useState<ScenePayload | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
|
||||||
|
|
||||||
|
const currentIdRef = useRef<string | null>(activeId);
|
||||||
|
const currentTitleRef = useRef(activeTitle);
|
||||||
|
const latestSceneRef = useRef<ScenePayload | null>(null);
|
||||||
|
const ignoreChangeRef = useRef(false);
|
||||||
|
const saveTimeoutRef = useRef<number | null>(null);
|
||||||
|
const toastTimeoutRef = useRef<number | null>(null);
|
||||||
|
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
||||||
|
const loadVersionRef = useRef(0);
|
||||||
|
const loadedRevisionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearPendingSaveTimeout = useCallback(() => {
|
||||||
|
if (saveTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string | null, timeoutMs?: number) => {
|
||||||
|
if (toastTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(toastTimeoutRef.current);
|
||||||
|
toastTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToastMessage(message);
|
||||||
|
if (message !== null && timeoutMs !== undefined) {
|
||||||
|
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshList = useCallback(async () => {
|
||||||
|
const list = await fetchDrawingList();
|
||||||
|
setDrawings(sortDrawings(list));
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDrawing = useCallback(
|
||||||
|
async (drawingId: string) => {
|
||||||
|
const version = ++loadVersionRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [list, drawing] = await Promise.all([
|
||||||
|
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
||||||
|
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (version !== loadVersionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextScene = sceneFromDrawing(drawing);
|
||||||
|
|
||||||
|
ignoreChangeRef.current = true;
|
||||||
|
currentIdRef.current = drawing.id;
|
||||||
|
currentTitleRef.current = drawing.title;
|
||||||
|
latestSceneRef.current = nextScene;
|
||||||
|
loadedRevisionRef.current = drawing.revision;
|
||||||
|
|
||||||
|
setDrawings(replaceMeta(list, drawing));
|
||||||
|
setActiveId(drawing.id);
|
||||||
|
setActiveTitleState(drawing.title);
|
||||||
|
setScene(nextScene);
|
||||||
|
setEditorReloadNonce((current) => current + 1);
|
||||||
|
} catch (loadError) {
|
||||||
|
if (version !== loadVersionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
|
||||||
|
} finally {
|
||||||
|
if (version === loadVersionRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveScene = useCallback(
|
||||||
|
async (isManual = false) => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
const nextScene = latestSceneRef.current;
|
||||||
|
const expectedRevision = loadedRevisionRef.current;
|
||||||
|
if (!drawingId || !nextScene) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isManual) {
|
||||||
|
showToast("Saving...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ ...nextScene, expectedRevision }),
|
||||||
|
})
|
||||||
|
.then((body) => {
|
||||||
|
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||||
|
if (currentIdRef.current === drawingId) {
|
||||||
|
loadedRevisionRef.current = body.drawing.revision;
|
||||||
|
}
|
||||||
|
if (isManual) {
|
||||||
|
showToast("Saved", 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((saveError: unknown) => {
|
||||||
|
if (isConflictError(saveError)) {
|
||||||
|
clearPendingSaveTimeout();
|
||||||
|
setError(null);
|
||||||
|
setDrawings((current) => replaceMeta(current, saveError.body.drawing));
|
||||||
|
if (isManual) {
|
||||||
|
showToast("Reloading latest...");
|
||||||
|
}
|
||||||
|
return loadDrawing(drawingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
||||||
|
if (isManual) {
|
||||||
|
showToast("Save failed", 2000);
|
||||||
|
}
|
||||||
|
throw saveError;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlightSaveRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightSaveRef.current = promise;
|
||||||
|
await promise;
|
||||||
|
},
|
||||||
|
[clearPendingSaveTimeout, loadDrawing, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flushPendingSave = useCallback(async () => {
|
||||||
|
if (saveTimeoutRef.current !== null) {
|
||||||
|
clearPendingSaveTimeout();
|
||||||
|
await saveScene();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFlightSaveRef.current) {
|
||||||
|
await inFlightSaveRef.current;
|
||||||
|
}
|
||||||
|
}, [clearPendingSaveTimeout, saveScene]);
|
||||||
|
|
||||||
|
const triggerManualSave = useCallback(async () => {
|
||||||
|
if (saveTimeoutRef.current !== null) {
|
||||||
|
clearPendingSaveTimeout();
|
||||||
|
}
|
||||||
|
if (inFlightSaveRef.current) {
|
||||||
|
await inFlightSaveRef.current;
|
||||||
|
}
|
||||||
|
await saveScene(true);
|
||||||
|
}, [clearPendingSaveTimeout, saveScene]);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(() => {
|
||||||
|
if (saveTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
void saveScene();
|
||||||
|
}, 30000);
|
||||||
|
}, [saveScene]);
|
||||||
|
|
||||||
|
const navigateToDrawing = useCallback(
|
||||||
|
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
|
||||||
|
const replace = options?.replace ?? false;
|
||||||
|
const flush = options?.flush ?? true;
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current && latestSceneRef.current) {
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flush) {
|
||||||
|
await flushPendingSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||||
|
} else {
|
||||||
|
window.history.pushState(null, "", `/d/${drawingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDrawing(drawingId);
|
||||||
|
},
|
||||||
|
[flushPendingSave, loadDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDrawing = useCallback(async () => {
|
||||||
|
await flushPendingSave();
|
||||||
|
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
||||||
|
await navigateToDrawing(created.id, { flush: false });
|
||||||
|
}, [flushPendingSave, navigateToDrawing]);
|
||||||
|
|
||||||
|
const deleteDrawing = useCallback(
|
||||||
|
async (drawingId: string) => {
|
||||||
|
if (!window.confirm("Delete this drawing?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current) {
|
||||||
|
await flushPendingSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current && response.nextId) {
|
||||||
|
await navigateToDrawing(response.nextId, { replace: true, flush: false });
|
||||||
|
} else {
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flushPendingSave, navigateToDrawing, refreshList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setActiveTitle = useCallback((title: string) => {
|
||||||
|
currentTitleRef.current = title;
|
||||||
|
setActiveTitleState(title);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitTitle = useCallback(async () => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
if (!drawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: currentTitleRef.current,
|
||||||
|
expectedRevision: loadedRevisionRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTitleRef.current = body.drawing.title;
|
||||||
|
loadedRevisionRef.current = body.drawing.revision;
|
||||||
|
setActiveTitleState(body.drawing.title);
|
||||||
|
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||||
|
} catch (renameError) {
|
||||||
|
if (isConflictError(renameError)) {
|
||||||
|
clearPendingSaveTimeout();
|
||||||
|
setDrawings((current) => replaceMeta(current, renameError.body.drawing));
|
||||||
|
await loadDrawing(drawingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
||||||
|
}
|
||||||
|
}, [clearPendingSaveTimeout, loadDrawing]);
|
||||||
|
|
||||||
|
const handleSceneChange = useCallback(
|
||||||
|
(nextScene: ScenePayload) => {
|
||||||
|
latestSceneRef.current = nextScene;
|
||||||
|
|
||||||
|
if (ignoreChangeRef.current) {
|
||||||
|
ignoreChangeRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSave();
|
||||||
|
},
|
||||||
|
[scheduleSave],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectDrawing = useCallback(
|
||||||
|
async (drawingId: string) => {
|
||||||
|
await navigateToDrawing(drawingId);
|
||||||
|
},
|
||||||
|
[navigateToDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void triggerManualSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
};
|
||||||
|
}, [triggerManualSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPathId = pathToId();
|
||||||
|
if (currentPathId) {
|
||||||
|
void loadDrawing(currentPathId);
|
||||||
|
} else {
|
||||||
|
window.location.replace("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPopState = () => {
|
||||||
|
const nextId = pathToId();
|
||||||
|
if (nextId) {
|
||||||
|
void navigateToDrawing(nextId, { replace: true, flush: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", onPopState);
|
||||||
|
};
|
||||||
|
}, [loadDrawing, navigateToDrawing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkForExternalChanges = async () => {
|
||||||
|
try {
|
||||||
|
const list = await refreshList();
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
const loadedRevision = loadedRevisionRef.current;
|
||||||
|
const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null;
|
||||||
|
|
||||||
|
if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) {
|
||||||
|
clearPendingSaveTimeout();
|
||||||
|
await loadDrawing(serverDrawing.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Focus checks only discover external edits; direct save/load requests report failures.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
void checkForExternalChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void checkForExternalChanges();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [clearPendingSaveTimeout, loadDrawing, refreshList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (toastTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(toastTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawings,
|
||||||
|
activeId,
|
||||||
|
activeTitle,
|
||||||
|
scene,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
toastMessage,
|
||||||
|
editorReloadNonce,
|
||||||
|
setActiveTitle,
|
||||||
|
submitTitle,
|
||||||
|
handleSceneChange,
|
||||||
|
selectDrawing,
|
||||||
|
createDrawing,
|
||||||
|
deleteDrawing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
const EXCALIDRAW_THEME_TOKEN_MAP = [
|
||||||
|
["--island-bg-color", "--app-island-bg"],
|
||||||
|
["--sidebar-bg-color", "--app-sidebar-bg"],
|
||||||
|
["--sidebar-border-color", "--app-sidebar-border"],
|
||||||
|
["--sidebar-shadow", "--app-sidebar-shadow"],
|
||||||
|
["--color-surface-lowest", "--app-surface-lowest"],
|
||||||
|
["--color-surface-low", "--app-surface-low"],
|
||||||
|
["--color-surface-primary-container", "--app-selected-bg"],
|
||||||
|
["--color-on-surface", "--app-text-color"],
|
||||||
|
["--color-on-primary-container", "--app-selected-text-color"],
|
||||||
|
["--color-disabled", "--app-disabled-color"],
|
||||||
|
["--input-bg-color", "--app-input-bg"],
|
||||||
|
["--input-border-color", "--app-input-border"],
|
||||||
|
["--input-label-color", "--app-input-color"],
|
||||||
|
["--default-border-color", "--app-button-border"],
|
||||||
|
["--button-hover-bg", "--app-button-hover-bg"],
|
||||||
|
["--button-active-bg", "--app-button-active-bg"],
|
||||||
|
["--button-active-border", "--app-button-active-border"],
|
||||||
|
["--overlay-bg-color", "--app-overlay-bg"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) {
|
||||||
|
const themeSyncFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const syncThemeTokens = useCallback(() => {
|
||||||
|
const appShell = appShellRef.current;
|
||||||
|
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
|
||||||
|
if (!appShell || !excalidrawRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
||||||
|
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
||||||
|
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
||||||
|
if (value) {
|
||||||
|
appShell.style.setProperty(targetToken, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [appShellRef]);
|
||||||
|
|
||||||
|
const scheduleThemeTokenSync = useCallback(() => {
|
||||||
|
if (themeSyncFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
themeSyncFrameRef.current = null;
|
||||||
|
syncThemeTokens();
|
||||||
|
});
|
||||||
|
}, [syncThemeTokens]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (themeSyncFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return scheduleThemeTokenSync;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<!-- to resolve the hs and css from root -->
|
<!-- to resolve the hs and css from root -->
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<title>excali-box</title>
|
<title>excali-box</title>
|
||||||
<script type="module" src="./client.tsx"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
@@ -10,7 +10,7 @@ if (!root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<React.StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -253,6 +253,22 @@ input {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--app-overlay-bg);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: var(--app-text-color);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--app-sidebar-font-size);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid var(--app-sidebar-border);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-frame,
|
.editor-frame,
|
||||||
.editor-loading {
|
.editor-loading {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -87,11 +87,3 @@ export function parseJsonBody<T = unknown>(text: string): T {
|
|||||||
export function parseSceneText(text: string): ScenePayload {
|
export function parseSceneText(text: string): ScenePayload {
|
||||||
return normalizeScene(parseJsonBody(text));
|
return normalizeScene(parseJsonBody(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coerceStoredScene(input: unknown): ScenePayload {
|
|
||||||
try {
|
|
||||||
return normalizeScene(input);
|
|
||||||
} catch {
|
|
||||||
return emptyScene();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ export type DrawingMeta = {
|
|||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DrawingRecord = DrawingMeta & {
|
export type DrawingRecord = DrawingMeta & {
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createDrawingStore } from "./db";
|
|
||||||
|
|
||||||
const cleanup: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
while (cleanup.length > 0) {
|
|
||||||
const dir = cleanup.pop();
|
|
||||||
if (dir) {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function createTempStore() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
|
||||||
cleanup.push(dir);
|
|
||||||
return createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("drawing store", () => {
|
|
||||||
test("creates, updates, lists, and deletes drawings", () => {
|
|
||||||
const store = createTempStore();
|
|
||||||
const created = store.createDrawing();
|
|
||||||
|
|
||||||
expect(created.title).toBe("Untitled");
|
|
||||||
expect(created.scene.appState.theme).toBe("dark");
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
|
||||||
|
|
||||||
const updated = store.updateDrawing(created.id, {
|
|
||||||
title: "Flow",
|
|
||||||
scene: {
|
|
||||||
elements: [{ id: "one" }],
|
|
||||||
appState: { gridSize: 20 },
|
|
||||||
files: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updated?.title).toBe("Flow");
|
|
||||||
expect(updated?.scene.elements).toHaveLength(1);
|
|
||||||
|
|
||||||
const removed = store.deleteDrawing(created.id);
|
|
||||||
expect(removed.deleted).toBe(true);
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
|
||||||
expect(removed.next?.id).not.toBe(created.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { type ScenePayload } from "../core/shared";
|
||||||
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
|
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||||
|
|
||||||
|
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanup.length > 0) {
|
||||||
|
const item = cleanup.pop();
|
||||||
|
if (item) {
|
||||||
|
item.store.close();
|
||||||
|
rmSync(item.dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTempTools() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
||||||
|
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
cleanup.push({ dir, store });
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
tools: createMcpToolHandlers(store, "http://example.test"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function testScene(): ScenePayload {
|
||||||
|
return {
|
||||||
|
elements: [
|
||||||
|
{ id: "one", type: "rectangle", x: 10, y: 20 },
|
||||||
|
{ id: "two", type: "text", text: "hello" },
|
||||||
|
],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mcp tool handlers", () => {
|
||||||
|
test("PUBLIC_BASE_URL is required", () => {
|
||||||
|
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
||||||
|
const { store, tools } = createTempTools();
|
||||||
|
const scene = testScene();
|
||||||
|
|
||||||
|
const result = tools.create_drawing({ title: "explicit", scene });
|
||||||
|
const drawing = store.getDrawing(result.id);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: result.id,
|
||||||
|
title: "explicit",
|
||||||
|
revision: 1,
|
||||||
|
url: `http://example.test/d/${result.id}`,
|
||||||
|
});
|
||||||
|
expect(drawing?.revision).toBe(1);
|
||||||
|
expect(drawing?.scene).toEqual(scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("get_drawing returns raw scene plus URL", () => {
|
||||||
|
const { tools } = createTempTools();
|
||||||
|
const scene = testScene();
|
||||||
|
const result = tools.create_drawing({ title: "read", scene });
|
||||||
|
|
||||||
|
const drawing = tools.get_drawing({ id: result.id });
|
||||||
|
|
||||||
|
expect(drawing).toMatchObject({
|
||||||
|
id: result.id,
|
||||||
|
title: "read",
|
||||||
|
revision: 1,
|
||||||
|
url: `http://example.test/d/${result.id}`,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replace_drawing updates the existing drawing", () => {
|
||||||
|
const { store, tools } = createTempTools();
|
||||||
|
const result = tools.create_drawing({ title: "replace", scene: testScene() });
|
||||||
|
const nextScene: ScenePayload = {
|
||||||
|
elements: [{ id: "manual", type: "rectangle" }],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
|
||||||
|
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(replaced.revision).toBe(2);
|
||||||
|
expect(store.getDrawing(result.id)?.title).toBe("replaced");
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||||
|
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replace_drawing keeps the revision when the scene is already stored", () => {
|
||||||
|
const { store, tools } = createTempTools();
|
||||||
|
const scene = testScene();
|
||||||
|
const result = tools.create_drawing({ title: "replace noop", scene });
|
||||||
|
|
||||||
|
const replaced = tools.replace_drawing({ id: result.id, scene });
|
||||||
|
|
||||||
|
expect(replaced.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(result.id)?.scene).toEqual(scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patch_drawing replaces an element property and removes an element", () => {
|
||||||
|
const { store, tools } = createTempTools();
|
||||||
|
const result = tools.create_drawing({ title: "patch", scene: testScene() });
|
||||||
|
|
||||||
|
const patched = tools.patch_drawing({
|
||||||
|
id: result.id,
|
||||||
|
patch: [
|
||||||
|
{ op: "replace", path: "/elements/0/x", value: 42 },
|
||||||
|
{ op: "remove", path: "/elements/1" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patched.revision).toBe(2);
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||||
|
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing drawing IDs return tool errors", () => {
|
||||||
|
const { tools } = createTempTools();
|
||||||
|
|
||||||
|
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
|
||||||
|
expect(() =>
|
||||||
|
tools.replace_drawing({
|
||||||
|
id: "missing",
|
||||||
|
scene: testScene(),
|
||||||
|
}),
|
||||||
|
).toThrow("Drawing not found: missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { applyPatch, type Operation } from "fast-json-patch";
|
||||||
|
import { createMcpHandler } from "mcp-handler";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { normalizeScene, normalizeTitle } from "../core/scene";
|
||||||
|
import { type ScenePayload } from "../core/shared";
|
||||||
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
|
|
||||||
|
const sceneSchema = z.object({
|
||||||
|
elements: z.array(z.unknown()),
|
||||||
|
appState: z.record(z.string(), z.unknown()),
|
||||||
|
files: z.record(z.string(), z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchOperationSchema = z.object({
|
||||||
|
op: z.enum(["add", "remove", "replace", "move", "copy", "test"]),
|
||||||
|
path: z.string(),
|
||||||
|
from: z.string().optional(),
|
||||||
|
value: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const idInput = z.object({ id: z.string().min(1) });
|
||||||
|
const createDrawingInput = z.object({ title: z.string(), scene: sceneSchema });
|
||||||
|
const replaceDrawingInput = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().optional(),
|
||||||
|
scene: sceneSchema,
|
||||||
|
});
|
||||||
|
const patchDrawingInput = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().optional(),
|
||||||
|
patch: z.array(patchOperationSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ToolResult = {
|
||||||
|
content: Array<{ type: "text"; text: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimTrailingSlash(url: string): string {
|
||||||
|
return url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string {
|
||||||
|
const value = raw?.trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||||
|
return trimTrailingSlash(url.toString());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawingUrl(baseUrl: string, id: string): string {
|
||||||
|
return `${trimTrailingSlash(baseUrl)}/d/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonText(data: unknown): ToolResult {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(data, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(id: string): never {
|
||||||
|
throw new Error(`Drawing not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
|
||||||
|
return {
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
revision: drawing.revision,
|
||||||
|
url: drawingUrl(publicBaseUrl, drawing.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExistingDrawing(
|
||||||
|
store: DrawingStore,
|
||||||
|
id: string,
|
||||||
|
update: { scene?: ScenePayload; title?: string },
|
||||||
|
) {
|
||||||
|
const result = store.updateDrawing(id, update);
|
||||||
|
if (!result.ok) {
|
||||||
|
return notFound(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.drawing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
|
||||||
|
try {
|
||||||
|
const result = applyPatch(structuredClone(scene), patch, true, true, true);
|
||||||
|
return normalizeScene(result.newDocument);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) {
|
||||||
|
return {
|
||||||
|
list_drawings() {
|
||||||
|
return store.listDrawings();
|
||||||
|
},
|
||||||
|
|
||||||
|
get_drawing(input: unknown) {
|
||||||
|
const { id } = idInput.parse(input);
|
||||||
|
const drawing = store.getDrawing(id) ?? notFound(id);
|
||||||
|
return {
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
createdAt: drawing.createdAt,
|
||||||
|
updatedAt: drawing.updatedAt,
|
||||||
|
revision: drawing.revision,
|
||||||
|
url: drawingUrl(publicBaseUrl, drawing.id),
|
||||||
|
scene: drawing.scene,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
create_drawing(input: unknown) {
|
||||||
|
const { title, scene } = createDrawingInput.parse(input);
|
||||||
|
const created = store.createDrawing(normalizeTitle(title));
|
||||||
|
const updated = updateExistingDrawing(store, created.id, {
|
||||||
|
title,
|
||||||
|
scene: normalizeScene(scene),
|
||||||
|
});
|
||||||
|
return mutationResult(publicBaseUrl, updated);
|
||||||
|
},
|
||||||
|
|
||||||
|
replace_drawing(input: unknown) {
|
||||||
|
const { id, title, scene } = replaceDrawingInput.parse(input);
|
||||||
|
const updated = updateExistingDrawing(store, id, {
|
||||||
|
title,
|
||||||
|
scene: normalizeScene(scene),
|
||||||
|
});
|
||||||
|
return mutationResult(publicBaseUrl, updated);
|
||||||
|
},
|
||||||
|
|
||||||
|
patch_drawing(input: unknown) {
|
||||||
|
const { id, title, patch } = patchDrawingInput.parse(input);
|
||||||
|
const drawing = store.getDrawing(id) ?? notFound(id);
|
||||||
|
const updated = updateExistingDrawing(store, id, {
|
||||||
|
title,
|
||||||
|
scene: applyScenePatch(drawing.scene, patch as Operation[]),
|
||||||
|
});
|
||||||
|
return mutationResult(publicBaseUrl, updated);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExcaliMcpHandler(store: DrawingStore, publicBaseUrl: string) {
|
||||||
|
const tools = createMcpToolHandlers(store, publicBaseUrl);
|
||||||
|
|
||||||
|
return createMcpHandler(
|
||||||
|
(server) => {
|
||||||
|
server.registerTool(
|
||||||
|
"list_drawings",
|
||||||
|
{
|
||||||
|
title: "List Drawings",
|
||||||
|
description: "Returns drawing metadata ordered like the app list.",
|
||||||
|
},
|
||||||
|
async () => jsonText(tools.list_drawings()),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_drawing",
|
||||||
|
{
|
||||||
|
title: "Get Drawing",
|
||||||
|
description: "Returns drawing metadata, browser URL, and raw ScenePayload.",
|
||||||
|
inputSchema: idInput.shape,
|
||||||
|
},
|
||||||
|
async (input) => jsonText(tools.get_drawing(input)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"create_drawing",
|
||||||
|
{
|
||||||
|
title: "Create Drawing",
|
||||||
|
description: "Creates a drawing from an explicit Excalidraw ScenePayload.",
|
||||||
|
inputSchema: createDrawingInput.shape,
|
||||||
|
},
|
||||||
|
async (input) => jsonText(tools.create_drawing(input)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"replace_drawing",
|
||||||
|
{
|
||||||
|
title: "Replace Drawing",
|
||||||
|
description: "Replaces a drawing scene and optionally its title.",
|
||||||
|
inputSchema: replaceDrawingInput.shape,
|
||||||
|
},
|
||||||
|
async (input) => jsonText(tools.replace_drawing(input)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"patch_drawing",
|
||||||
|
{
|
||||||
|
title: "Patch Drawing",
|
||||||
|
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
|
||||||
|
inputSchema: patchDrawingInput.shape,
|
||||||
|
},
|
||||||
|
async (input) => jsonText(tools.patch_drawing(input)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverInfo: {
|
||||||
|
name: "excali",
|
||||||
|
version: "0.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
basePath: "",
|
||||||
|
disableSse: true,
|
||||||
|
maxDuration: 60,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpServer() {
|
||||||
|
const store = createDrawingStore(process.env.DATABASE_PATH);
|
||||||
|
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl());
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(process.env.MCP_PORT ?? "3001"),
|
||||||
|
hostname: process.env.MCP_HOST ?? "127.0.0.1",
|
||||||
|
fetch(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname !== "/mcp") {
|
||||||
|
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(request);
|
||||||
|
},
|
||||||
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = Bun.serve(createMcpServer());
|
||||||
|
console.log(`MCP listening on http://${server.hostname}:${server.port}/mcp`);
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import index from "./index.html";
|
|
||||||
import { createApi } from "./api";
|
|
||||||
import { getDefaultDrawingStore } from "./db";
|
|
||||||
|
|
||||||
export function createServer() {
|
|
||||||
const drawingStore = getDefaultDrawingStore();
|
|
||||||
const api = createApi(drawingStore);
|
|
||||||
|
|
||||||
return {
|
|
||||||
port: Number(process.env.PORT ?? "3000"),
|
|
||||||
hostname: process.env.HOST ?? "localhost",
|
|
||||||
routes: {
|
|
||||||
"/": (request: Request) => {
|
|
||||||
const drawing = drawingStore.ensureInitialDrawing();
|
|
||||||
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
|
||||||
},
|
|
||||||
...(process.cwd().endsWith("/dist") ? {
|
|
||||||
"/d/:id": async (req: Request) => {
|
|
||||||
let file = Bun.file("./public/index.html");
|
|
||||||
return new Response(file, {
|
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} : {
|
|
||||||
"/d/:id": index
|
|
||||||
}),
|
|
||||||
"/api/health": {
|
|
||||||
GET: () => api.health(),
|
|
||||||
},
|
|
||||||
"/api/drawings": {
|
|
||||||
GET: () => api.listDrawings(),
|
|
||||||
POST: () => api.createDrawing(),
|
|
||||||
},
|
|
||||||
"/api/drawings/:id": {
|
|
||||||
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
|
||||||
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
|
||||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async fetch(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const isProd = process.cwd().endsWith("/dist");
|
|
||||||
|
|
||||||
if (!isProd) {
|
|
||||||
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any "/public" prefix from the request URL if it accidentally got included
|
|
||||||
let reqPath = url.pathname;
|
|
||||||
if (reqPath.startsWith("/public/")) {
|
|
||||||
reqPath = reqPath.substring(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In dist, static files are inside public
|
|
||||||
const path = "./public" + reqPath;
|
|
||||||
const file = Bun.file(path);
|
|
||||||
if (await file.exists()) {
|
|
||||||
// Cache indefinitely except index.html
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (!reqPath.endsWith("index.html")) {
|
|
||||||
// Add etag logic or use bun's hashes by only caching files that actually have hashes in them.
|
|
||||||
// Bun generates hashes like chunk-02ahh9r5.js, we can check for that pattern
|
|
||||||
// If we wanted to do ETag we could use Bun.file(path).size + lastModified but hashing is safer
|
|
||||||
const isHashed = reqPath.match(/-[a-zA-Z0-9]{8}\.(js|css)$/);
|
|
||||||
if (isHashed) {
|
|
||||||
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
|
||||||
} else {
|
|
||||||
headers["Cache-Control"] = "no-cache";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
headers["Content-Type"] = "text/html; charset=utf-8";
|
|
||||||
headers["Cache-Control"] = "no-cache";
|
|
||||||
}
|
|
||||||
return new Response(file, { headers });
|
|
||||||
}
|
|
||||||
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
|
||||||
},
|
|
||||||
} satisfies Parameters<typeof Bun.serve>[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
const server = Bun.serve(createServer());
|
|
||||||
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createApi } from "./api";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
function withApi() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
||||||
|
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
const api = createApi(store);
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
cleanup() {
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("api", () => {
|
||||||
|
test("creates a drawing with dark theme by default", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
expect(created.appState.theme).toBe("dark");
|
||||||
|
expect(created.revision).toBe(0);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const drawing = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${drawing.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: "{",
|
||||||
|
}),
|
||||||
|
{ params: { id: drawing.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for missing drawing", () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const response = api.getDrawing(
|
||||||
|
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a drawing scene", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: created.revision,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not increment revisions for equivalent normalized scenes", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { gridSize: 20 },
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { gridSize: 20, selectedElementIds: { shape: true } },
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates scene and title with a matching expected revision", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Synced",
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.title).toBe("Synced");
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 409 for stale expected revisions without overwriting", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "server" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflict = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "stale" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflict.status).toBe(409);
|
||||||
|
const body = await conflict.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Drawing changed externally",
|
||||||
|
drawing: {
|
||||||
|
id: created.id,
|
||||||
|
title: created.title,
|
||||||
|
createdAt: body.drawing.createdAt,
|
||||||
|
updatedAt: body.drawing.updatedAt,
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = await api.getDrawing(
|
||||||
|
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
|
||||||
|
).json();
|
||||||
|
expect(stored.elements).toEqual([{ id: "server" }]);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type DrawingStore } from "./db";
|
import { type DrawingStore } from "./db";
|
||||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
|
import { type DrawingMeta } from "../core/shared";
|
||||||
|
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
|
||||||
|
|
||||||
type RouteRequest = Request & {
|
type RouteRequest = Request & {
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
@@ -14,10 +15,37 @@ function errorResponse(error: unknown): Response {
|
|||||||
return json({ ok: false, error: error.message }, { status: error.status });
|
return json({ ok: false, error: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return json({ ok: false, error: "Request aborted" }, { status: 499 });
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMeta(drawing: DrawingMeta): DrawingMeta {
|
||||||
|
return {
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
createdAt: drawing.createdAt,
|
||||||
|
updatedAt: drawing.updatedAt,
|
||||||
|
revision: drawing.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
|
||||||
|
if (!Object.hasOwn(raw, "expectedRevision")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = raw.expectedRevision;
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new HttpError(400, "expectedRevision must be a non-negative integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApi(store: DrawingStore) {
|
export function createApi(store: DrawingStore) {
|
||||||
return {
|
return {
|
||||||
health() {
|
health() {
|
||||||
@@ -32,10 +60,7 @@ export function createApi(store: DrawingStore) {
|
|||||||
const drawing = store.createDrawing();
|
const drawing = store.createDrawing();
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
id: drawing.id,
|
...toMeta(drawing),
|
||||||
title: drawing.title,
|
|
||||||
createdAt: drawing.createdAt,
|
|
||||||
updatedAt: drawing.updatedAt,
|
|
||||||
...drawing.scene,
|
...drawing.scene,
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
@@ -43,6 +68,7 @@ export function createApi(store: DrawingStore) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getDrawing(request: RouteRequest) {
|
getDrawing(request: RouteRequest) {
|
||||||
|
try {
|
||||||
const id = request.params?.id;
|
const id = request.params?.id;
|
||||||
const drawing = id ? store.getDrawing(id) : null;
|
const drawing = id ? store.getDrawing(id) : null;
|
||||||
|
|
||||||
@@ -51,12 +77,12 @@ export function createApi(store: DrawingStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
id: drawing.id,
|
...toMeta(drawing),
|
||||||
title: drawing.title,
|
|
||||||
createdAt: drawing.createdAt,
|
|
||||||
updatedAt: drawing.updatedAt,
|
|
||||||
...drawing.scene,
|
...drawing.scene,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDrawing(request: RouteRequest) {
|
async updateDrawing(request: RouteRequest) {
|
||||||
@@ -73,29 +99,38 @@ export function createApi(store: DrawingStore) {
|
|||||||
Object.hasOwn(raw, "elements") ||
|
Object.hasOwn(raw, "elements") ||
|
||||||
Object.hasOwn(raw, "appState") ||
|
Object.hasOwn(raw, "appState") ||
|
||||||
Object.hasOwn(raw, "files");
|
Object.hasOwn(raw, "files");
|
||||||
|
const expectedRevision = parseExpectedRevision(raw);
|
||||||
|
|
||||||
if (!hasTitle && !hasScene) {
|
if (!hasTitle && !hasScene) {
|
||||||
throw new HttpError(400, "Nothing to update");
|
throw new HttpError(400, "Nothing to update");
|
||||||
}
|
}
|
||||||
|
|
||||||
const scene = hasScene ? parseSceneText(text) : undefined;
|
const scene = hasScene ? parseSceneText(text) : undefined;
|
||||||
const updated = store.updateDrawing(id, {
|
const result = store.updateDrawing(id, {
|
||||||
scene,
|
scene,
|
||||||
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
||||||
|
expectedRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!result.ok && result.reason === "not_found") {
|
||||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.ok && result.reason === "conflict") {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Drawing changed externally",
|
||||||
|
drawing: toMeta(result.drawing),
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = result.drawing;
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
drawing: {
|
drawing: toMeta(updated),
|
||||||
id: updated.id,
|
|
||||||
title: updated.title,
|
|
||||||
createdAt: updated.createdAt,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanup.length > 0) {
|
||||||
|
const dir = cleanup.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTempStore() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
||||||
|
cleanup.push(dir);
|
||||||
|
return createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("drawing store", () => {
|
||||||
|
test("creates, updates, lists, and deletes drawings", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
expect(created.title).toBe("Untitled");
|
||||||
|
expect(created.revision).toBe(0);
|
||||||
|
expect(created.scene.appState.theme).toBe("dark");
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(store.listDrawings()[0]?.revision).toBe(0);
|
||||||
|
|
||||||
|
const updated = store.updateDrawing(created.id, {
|
||||||
|
title: "Flow",
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "one" }],
|
||||||
|
appState: { gridSize: 20 },
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.ok).toBe(true);
|
||||||
|
expect(updated.ok ? updated.drawing.title : null).toBe("Flow");
|
||||||
|
expect(updated.ok ? updated.drawing.revision : null).toBe(1);
|
||||||
|
expect(updated.ok ? updated.drawing.scene.elements : []).toHaveLength(1);
|
||||||
|
|
||||||
|
const removed = store.deleteDrawing(created.id);
|
||||||
|
expect(removed.deleted).toBe(true);
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(removed.next?.id).not.toBe(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects stale expected revisions without overwriting the scene", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "first" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const stale = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "stale" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stale.ok).toBe(false);
|
||||||
|
expect(stale.ok ? null : stale.reason).toBe("conflict");
|
||||||
|
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the revision unchanged when the scene is already stored", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const repeated = store.updateDrawing(created.id, { scene });
|
||||||
|
expect(repeated.ok).toBe(true);
|
||||||
|
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts stale expected revisions when the scene is already stored", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(repeated.ok).toBe(true);
|
||||||
|
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("increments the revision when the title changes but the scene does not", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
|
||||||
|
expect(renamed.ok).toBe(true);
|
||||||
|
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
|
||||||
|
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@ import { Database } from "bun:sqlite";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
|
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared";
|
||||||
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
|
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
|
||||||
|
|
||||||
type DrawingRow = {
|
type DrawingRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,10 +11,16 @@ type DrawingRow = {
|
|||||||
data: string;
|
data: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||||
|
|
||||||
|
export type DrawingUpdateResult =
|
||||||
|
| { ok: true; drawing: DrawingRecord }
|
||||||
|
| { ok: false; reason: "not_found" }
|
||||||
|
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
||||||
|
|
||||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||||
|
|
||||||
function createId(): string {
|
function createId(): string {
|
||||||
@@ -44,6 +50,7 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
revision: row.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +59,17 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scene: ScenePayload;
|
||||||
|
try {
|
||||||
|
scene = normalizeScene(JSON.parse(row.data));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||||
|
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...readMeta(row)!,
|
...readMeta(row)!,
|
||||||
scene: coerceStoredScene(JSON.parse(row.data)),
|
scene,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +85,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||||
@@ -82,7 +98,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
created_at AS createdAt,
|
created_at AS createdAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
revision
|
||||||
FROM drawings
|
FROM drawings
|
||||||
ORDER BY updated_at DESC, created_at DESC
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
`);
|
`);
|
||||||
@@ -93,7 +110,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
created_at AS createdAt,
|
created_at AS createdAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
revision
|
||||||
FROM drawings
|
FROM drawings
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
@@ -105,22 +123,40 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
|
|
||||||
const updateSceneQuery = db.query(`
|
const updateSceneQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET data = ?2, updated_at = CURRENT_TIMESTAMP
|
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateTitleQuery = db.query(`
|
const updateTitleQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET title = ?2, updated_at = CURRENT_TIMESTAMP
|
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateBothQuery = db.query(`
|
const updateBothQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
|
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const updateSceneExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?3
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateTitleExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?3
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateBothExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?4
|
||||||
|
`);
|
||||||
|
|
||||||
const deleteQuery = db.query(`
|
const deleteQuery = db.query(`
|
||||||
DELETE FROM drawings
|
DELETE FROM drawings
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
@@ -148,28 +184,64 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
return getLatestDrawing() ?? createDrawing();
|
return getLatestDrawing() ?? createDrawing();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
|
function updateDrawing(
|
||||||
const existing = getDrawing(id);
|
id: string,
|
||||||
if (!existing) {
|
update: { scene?: ScenePayload; title?: string; expectedRevision?: number },
|
||||||
return null;
|
): DrawingUpdateResult {
|
||||||
}
|
|
||||||
|
|
||||||
const hasScene = update.scene !== undefined;
|
const hasScene = update.scene !== undefined;
|
||||||
const hasTitle = update.title !== undefined;
|
const hasTitle = update.title !== undefined;
|
||||||
|
const existingRow = getQuery.get(id) as DrawingRow | null | undefined;
|
||||||
|
|
||||||
|
if (!existingRow) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readRow(existingRow)!;
|
||||||
if (!hasScene && !hasTitle) {
|
if (!hasScene && !hasTitle) {
|
||||||
return existing;
|
return { ok: true, drawing: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScene && hasTitle) {
|
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
|
||||||
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
|
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
|
||||||
} else if (hasScene) {
|
const titleChanged = nextTitle !== existingRow.title;
|
||||||
updateSceneQuery.run(id, JSON.stringify(update.scene));
|
const sceneChanged = nextScene !== existingRow.data;
|
||||||
|
|
||||||
|
if (!titleChanged && !sceneChanged) {
|
||||||
|
return { ok: true, drawing: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
|
||||||
|
return { ok: false, reason: "conflict", drawing: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
let changes: number;
|
||||||
|
if (sceneChanged && titleChanged) {
|
||||||
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
|
||||||
|
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
|
||||||
|
} else if (sceneChanged) {
|
||||||
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateSceneQuery.run(id, nextScene).changes)
|
||||||
|
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
|
||||||
} else {
|
} else {
|
||||||
updateTitleQuery.run(id, normalizeTitle(update.title));
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateTitleQuery.run(id, nextTitle).changes)
|
||||||
|
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDrawing(id);
|
const drawing = getDrawing(id);
|
||||||
|
if (changes > 0 && drawing) {
|
||||||
|
return { ok: true, drawing };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawing) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: "conflict", drawing };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import index from "../client/index.html";
|
||||||
|
import { createServer } from "./http-server";
|
||||||
|
|
||||||
|
export function createDevServer() {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
routes: {
|
||||||
|
...server.routes,
|
||||||
|
"/d/:id": index,
|
||||||
|
},
|
||||||
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = Bun.serve(createDevServer());
|
||||||
|
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDrawingStore, type DrawingStore } from "./db";
|
||||||
|
import { createServer } from "./http-server";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
const stores: DrawingStore[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (stores.length > 0) {
|
||||||
|
stores.pop()?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cleanup.length > 0) {
|
||||||
|
const dir = cleanup.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createServerFixture() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
|
||||||
|
cleanup.push(dir);
|
||||||
|
|
||||||
|
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
stores.push(store);
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
server: createServer({ drawingStore: store }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createServer", () => {
|
||||||
|
test("redirects / to the latest drawing", () => {
|
||||||
|
const { server, store } = createServerFixture();
|
||||||
|
|
||||||
|
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
|
||||||
|
const created = store.listDrawings();
|
||||||
|
|
||||||
|
expect(created).toHaveLength(1);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps Bun responsible for API routes", async () => {
|
||||||
|
const { server } = createServerFixture();
|
||||||
|
const response = await server.routes["/api/health"]?.GET?.();
|
||||||
|
|
||||||
|
expect(response?.status).toBe(200);
|
||||||
|
expect(await response?.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not expose a Bun static route for drawings in production", () => {
|
||||||
|
const { server } = createServerFixture();
|
||||||
|
|
||||||
|
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns JSON 404 for unmatched requests", async () => {
|
||||||
|
const { server } = createServerFixture();
|
||||||
|
const response = await server.fetch(new Request("http://local/index-abc123.js"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createApi } from "./api";
|
||||||
|
import { type DrawingStore, getDefaultDrawingStore } from "./db";
|
||||||
|
|
||||||
|
type CreateServerOptions = {
|
||||||
|
drawingStore?: DrawingStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createServer(options: CreateServerOptions = {}) {
|
||||||
|
const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
|
||||||
|
const api = createApi(drawingStore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(process.env.PORT ?? "3000"),
|
||||||
|
hostname: process.env.HOST ?? "localhost",
|
||||||
|
routes: {
|
||||||
|
"/": (request: Request) => {
|
||||||
|
const drawing = drawingStore.ensureInitialDrawing();
|
||||||
|
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
||||||
|
},
|
||||||
|
"/api/health": {
|
||||||
|
GET: () => api.health(),
|
||||||
|
},
|
||||||
|
"/api/drawings": {
|
||||||
|
GET: () => api.listDrawings(),
|
||||||
|
POST: () => api.createDrawing(),
|
||||||
|
},
|
||||||
|
"/api/drawings/:id": {
|
||||||
|
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
||||||
|
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
||||||
|
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||||
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = Bun.serve(createServer());
|
||||||
|
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user