feat: add security headers to caddyfile

This commit is contained in:
ruinivist
2026-06-02 10:41:45 +00:00
parent fcb2c499cf
commit e27e5e4c41
32 changed files with 1593 additions and 146 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish GHCR Image
on: on:
push: push:
tags: tags:
- 'release-*' - "release-*"
permissions: permissions:
contents: read contents: read
+3
View File
@@ -0,0 +1,3 @@
dist
data
excali-box-data
+4
View File
@@ -5,3 +5,7 @@
- Keep commit messages terse. - Keep commit messages terse.
Example: `feat: dark mode by default` Example: `feat: dark mode by default`
# Decisions on architecture and design
Read [DECISIONS.md](DECISIONS.md)
+4 -10
View File
@@ -12,25 +12,19 @@
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
} }
@mcp path /mcp handle /mcp {
handle @mcp {
reverse_proxy 127.0.0.1:3001 reverse_proxy 127.0.0.1:3001
} }
@api path /api/* @app path / /api/*
handle @api { handle @app {
reverse_proxy 127.0.0.1:3000
}
@root path /
handle @root {
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }
handle { handle {
root * /srv/public root * /srv/public
@html path /index.html /d/* @html path /index.html /d/* /p/*
header @html Cache-Control "no-cache" header @html Cache-Control "no-cache"
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$ @assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
+8
View File
@@ -0,0 +1,8 @@
# a list of architectural decisions
( to remind me when I over-engineer and be fickle minded )
## Image is NOT meant to be exposed directly to the internet
- the defaults MUST take care of this
- and hence AUTH won't be a part of the image, whatever the user is using for reverse proxy should handle auth.
+1
View File
@@ -2,6 +2,7 @@ FROM oven/bun:1.3.11-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json bun.lock tsconfig.json ./ COPY package.json bun.lock tsconfig.json ./
COPY patches ./patches
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY src ./src COPY src ./src
+1 -2
View File
@@ -5,8 +5,6 @@ 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 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
## Tl;dr ## Tl;dr
When building the image yourself When building the image yourself
@@ -52,6 +50,7 @@ Open `http://localhost:3000`.
## Test ## Test
```bash ```bash
bun run format
bun run test bun run test
bun run typecheck bun run typecheck
``` ```
+28 -2
View File
@@ -17,10 +17,16 @@
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
}, },
}, },
"patchedDependencies": {
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch",
},
"packages": { "packages": {
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
@@ -66,6 +72,8 @@
"@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=="], "@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=="],
"@pkgr/core": ["@pkgr/core@0.2.10", "", {}, "sha512-x6fFWCeak8aCGfqZfe6CXYt5xVjxe9Os1cIPmVRcToInKLjhJkRVXvJ/L3/1KxFkjDQdbZV/YsuLKqa8t/xKpA=="],
"@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=="],
@@ -426,6 +434,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"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=="],
"immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="], "immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="],
@@ -492,6 +502,8 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "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=="],
@@ -528,7 +540,9 @@
"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=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
@@ -542,6 +556,10 @@
"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=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-quick": ["pretty-quick@4.2.2", "", { "dependencies": { "@pkgr/core": "^0.2.7", "ignore": "^7.0.5", "mri": "^1.2.0", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "tinyexec": "^0.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.0" }, "bin": { "pretty-quick": "lib/cli.mjs" } }, "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w=="],
"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=="], "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=="],
@@ -600,6 +618,8 @@
"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=="], "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=="],
"simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="],
"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=="],
@@ -608,7 +628,7 @@
"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@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"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=="],
@@ -664,6 +684,8 @@
"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=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"@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=="],
"@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -708,6 +730,8 @@
"@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="], "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], "chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -732,6 +756,8 @@
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"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=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+11
View File
@@ -8,10 +8,15 @@
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html", "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: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", "build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
"format": "prettier --write .",
"postinstall": "simple-git-hooks",
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js", "start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
"test": "bun test", "test": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"simple-git-hooks": {
"pre-commit": "bunx pretty-quick --staged"
},
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "^0.18.1", "@excalidraw/excalidraw": "^0.18.1",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
@@ -25,6 +30,12 @@
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
},
"patchedDependencies": {
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch"
} }
} }
File diff suppressed because one or more lines are too long
+46 -1
View File
@@ -1,10 +1,27 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar"; import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import { EditorCanvas } from "./components/EditorCanvas"; import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession"; import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync"; import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
export function App() { type AppRoute = { type: "private" } | { type: "public"; slug: string };
function routeFromPath(pathname = window.location.pathname): AppRoute {
if (!pathname.startsWith("/p/")) {
return { type: "private" };
}
const slug = pathname.slice(3);
if (slug.length === 0 || slug.includes("/")) {
return { type: "private" };
}
return { type: "public", slug: decodeURIComponent(slug) };
}
function PrivateApp() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const appShellRef = useRef<HTMLDivElement | null>(null); const appShellRef = useRef<HTMLDivElement | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef); const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
@@ -17,12 +34,18 @@ export function App() {
error, error,
toastMessage, toastMessage,
editorReloadNonce, editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle, setActiveTitle,
submitTitle, submitTitle,
handleSceneChange, handleSceneChange,
selectDrawing, selectDrawing,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
} = useDrawingSession(); } = useDrawingSession();
useEffect(() => { useEffect(() => {
@@ -98,13 +121,35 @@ export function App() {
drawings={drawings} drawings={drawings}
activeId={activeId} activeId={activeId}
activeTitle={activeTitle} activeTitle={activeTitle}
publication={publication}
publicationSlug={publicationSlug}
publicationBusy={publicationBusy}
onClose={closeSidebar} onClose={closeSidebar}
onCreate={handleCreateDrawing} onCreate={handleCreateDrawing}
onSelect={handleSelectDrawing} onSelect={handleSelectDrawing}
onDelete={(drawingId) => void deleteDrawing(drawingId)} onDelete={(drawingId) => void deleteDrawing(drawingId)}
onTitleChange={setActiveTitle} onTitleChange={setActiveTitle}
onTitleSubmit={() => void submitTitle()} onTitleSubmit={() => void submitTitle()}
onPublicationSlugChange={setPublicationSlug}
onPublish={() => void publishPublication()}
onDisablePublication={() => void disablePublication()}
/> />
</div> </div>
); );
} }
function PublicApp({ slug }: { slug: string }) {
const { drawing, loading, error } = usePublicDrawing(slug);
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
}
export function App() {
const route = routeFromPath();
if (route.type === "public") {
return <PublicApp slug={route.slug} />;
}
return <PrivateApp />;
}
+147 -28
View File
@@ -1,24 +1,51 @@
import { type DrawingMeta } from "../../core/shared"; import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
type DrawingSidebarProps = { type DrawingSidebarProps = {
open: boolean; open: boolean;
drawings: DrawingMeta[]; drawings: DrawingMeta[];
activeId: string | null; activeId: string | null;
activeTitle: string; activeTitle: string;
publication: DrawingPublication;
publicationSlug: string;
publicationBusy: boolean;
onClose: () => void; onClose: () => void;
onCreate: () => void; onCreate: () => void;
onSelect: (drawingId: string) => void; onSelect: (drawingId: string) => void;
onDelete: (drawingId: string) => void; onDelete: (drawingId: string) => void;
onTitleChange: (title: string) => void; onTitleChange: (title: string) => void;
onTitleSubmit: () => void; onTitleSubmit: () => void;
onPublicationSlugChange: (slug: string) => void;
onPublish: () => void;
onDisablePublication: () => void;
}; };
function DrawerIcon() { function DrawerIcon() {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true"> <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" /> <rect
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> x="3.5"
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> 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> </svg>
); );
} }
@@ -42,28 +69,49 @@ export function DrawingSidebar({
drawings, drawings,
activeId, activeId,
activeTitle, activeTitle,
publication,
publicationSlug,
publicationBusy,
onClose, onClose,
onCreate, onCreate,
onSelect, onSelect,
onDelete, onDelete,
onTitleChange, onTitleChange,
onTitleSubmit, onTitleSubmit,
onPublicationSlugChange,
onPublish,
onDisablePublication,
}: DrawingSidebarProps) { }: DrawingSidebarProps) {
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
const publicationStatus = publication.enabled
? `Published at ${publicPath}`
: "Not published";
return ( return (
<> <>
<div <div
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"} className={
open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"
}
onClick={onClose} onClick={onClose}
aria-hidden={!open} aria-hidden={!open}
/> />
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}> <aside
className={open ? "sidebar sidebar-open" : "sidebar"}
aria-hidden={!open}
>
<div className="sidebar-header"> <div className="sidebar-header">
<h1>excali-box</h1> <h1>excali-box</h1>
<div className="sidebar-actions"> <div className="sidebar-actions">
<button type="button" className="primary-button" onClick={onCreate}> <button type="button" className="primary-button" onClick={onCreate}>
New New
</button> </button>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings"> <button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close drawings"
>
× ×
</button> </button>
</div> </div>
@@ -72,35 +120,106 @@ export function DrawingSidebar({
{drawings.map((drawing) => ( {drawings.map((drawing) => (
<div <div
key={drawing.id} key={drawing.id}
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"} className={
drawing.id === activeId
? "drawing-item drawing-item-active"
: "drawing-item"
}
> >
{drawing.id === activeId ? ( {drawing.id === activeId ? (
<div className="drawing-link"> <div className="drawing-link drawing-link-active">
<input <div className="drawing-item-header">
className="title-input" <input
value={activeTitle} className="title-input"
onChange={(event) => onTitleChange(event.target.value)} value={activeTitle}
onBlur={onTitleSubmit} onChange={(event) => onTitleChange(event.target.value)}
onKeyDown={(event) => { onBlur={onTitleSubmit}
if (event.key === "Enter") { onKeyDown={(event) => {
event.currentTarget.blur(); if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<button
type="button"
className="icon-button drawing-delete-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
<div className="publication-panel">
<label
className="publication-label"
htmlFor="publication-slug"
>
Public name
</label>
<input
id="publication-slug"
className="title-input publication-input"
value={publicationSlug}
onChange={(event) =>
onPublicationSlugChange(event.target.value)
} }
}} spellCheck={false}
/> autoCapitalize="none"
autoCorrect="off"
/>
<div className="publication-actions">
<button
type="button"
className="secondary-button"
onClick={onPublish}
disabled={publicationBusy}
>
{publication.enabled ? "Update link" : "Publish"}
</button>
<button
type="button"
className="secondary-button"
onClick={onDisablePublication}
disabled={!publication.enabled || publicationBusy}
>
Unpublish
</button>
</div>
<div className="publication-status">
{publication.enabled ? (
<a
href={publicPath ?? "#"}
target="_blank"
rel="noreferrer"
className="publication-link"
>
{publicationStatus}
</a>
) : (
<span>{publicationStatus}</span>
)}
</div>
</div>
</div> </div>
) : ( ) : (
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}> <button
type="button"
className="drawing-link"
onClick={() => onSelect(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span> <span className="drawing-title">{drawing.title}</span>
</button> </button>
)} )}
<button {drawing.id !== activeId ? (
type="button" <button
className="icon-button" type="button"
onClick={() => onDelete(drawing.id)} className="icon-button"
aria-label={`Delete ${drawing.title}`} onClick={() => onDelete(drawing.id)}
> aria-label={`Delete ${drawing.title}`}
× >
</button> ×
</button>
) : null}
</div> </div>
))} ))}
</div> </div>
+6 -4
View File
@@ -1,10 +1,11 @@
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import type { import type {
AppState, AppState,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared"; import { type ScenePayload } from "../../core/shared";
type EditorCanvasProps = { type EditorCanvasProps = {
@@ -27,13 +28,14 @@ function sceneFromEditor(
files: BinaryFiles, files: BinaryFiles,
): ScenePayload { ): ScenePayload {
return { return {
elements: [...elements], // Excalidraw passes immutable arrays, avoid copying it on every onChange event
elements: elements as unknown[],
appState: appState as unknown as Record<string, unknown>, appState: appState as unknown as Record<string, unknown>,
files: files as unknown as Record<string, unknown>, files: files as unknown as Record<string, unknown>,
}; };
} }
export function EditorCanvas({ export const EditorCanvas = memo(function EditorCanvas({
activeId, activeId,
scene, scene,
loading, loading,
@@ -58,4 +60,4 @@ export function EditorCanvas({
/> />
</div> </div>
); );
} });
+53
View File
@@ -0,0 +1,53 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type PublicDrawing } from "../../core/shared";
type PublicViewerProps = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
};
function drawingToInitialData(
drawing: PublicDrawing,
): ExcalidrawInitialDataState {
return drawing as ExcalidrawInitialDataState;
}
const viewerUiOptions = {
canvasActions: {
changeViewBackgroundColor: false,
clearCanvas: false,
export: false,
loadScene: false,
saveAsImage: false,
saveToActiveFile: false,
toggleTheme: false,
},
tools: {
image: false,
},
} as const;
export const PublicViewer = memo(function PublicViewer({
drawing,
loading,
error,
}: PublicViewerProps) {
if (loading || !drawing) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
}
return (
<div className="public-viewer-shell">
<Excalidraw
initialData={drawingToInitialData(drawing)}
viewModeEnabled={true}
zenModeEnabled={true}
UIOptions={viewerUiOptions}
/>
</div>
);
});
+207 -41
View File
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared"; import {
DEFAULT_TITLE,
type DrawingMeta,
type DrawingPublication,
type ScenePayload,
} from "../../core/shared";
type DrawingResponse = DrawingMeta & ScenePayload; type DrawingResponse = DrawingMeta & ScenePayload;
type PublicationResponse = DrawingPublication;
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta }; type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
type UpdateResponse = { ok: true; drawing: DrawingMeta }; type UpdateResponse = { ok: true; drawing: DrawingMeta };
@@ -23,7 +29,10 @@ function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] { function replaceMeta(
drawings: DrawingMeta[],
drawing: DrawingMeta,
): DrawingMeta[] {
const filtered = drawings.filter((item) => item.id !== drawing.id); const filtered = drawings.filter((item) => item.id !== drawing.id);
return sortDrawings([drawing, ...filtered]); return sortDrawings([drawing, ...filtered]);
} }
@@ -49,8 +58,14 @@ function fetchDrawingList() {
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }); return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
} }
function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } { function isConflictError(
return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined; 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 { function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
@@ -70,10 +85,16 @@ export function useDrawingSession() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [editorReloadNonce, setEditorReloadNonce] = useState(0); const [editorReloadNonce, setEditorReloadNonce] = useState(0);
const [publication, setPublication] = useState<DrawingPublication>({
enabled: false,
});
const [publicationSlug, setPublicationSlugState] = useState("");
const [publicationBusy, setPublicationBusy] = useState(false);
const currentIdRef = useRef<string | null>(activeId); const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle); const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null); const latestSceneRef = useRef<ScenePayload | null>(null);
const publicationEnabledRef = useRef(false);
const ignoreChangeRef = useRef(false); const ignoreChangeRef = useRef(false);
const saveTimeoutRef = useRef<number | null>(null); const saveTimeoutRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null); const toastTimeoutRef = useRef<number | null>(null);
@@ -88,17 +109,23 @@ export function useDrawingSession() {
} }
}, []); }, []);
const showToast = useCallback((message: string | null, timeoutMs?: number) => { const showToast = useCallback(
if (toastTimeoutRef.current !== null) { (message: string | null, timeoutMs?: number) => {
clearTimeout(toastTimeoutRef.current); if (toastTimeoutRef.current !== null) {
toastTimeoutRef.current = null; clearTimeout(toastTimeoutRef.current);
} toastTimeoutRef.current = null;
}
setToastMessage(message); setToastMessage(message);
if (message !== null && timeoutMs !== undefined) { if (message !== null && timeoutMs !== undefined) {
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs); toastTimeoutRef.current = window.setTimeout(
} () => setToastMessage(null),
}, []); timeoutMs,
);
}
},
[],
);
const refreshList = useCallback(async () => { const refreshList = useCallback(async () => {
const list = await fetchDrawingList(); const list = await fetchDrawingList();
@@ -106,6 +133,21 @@ export function useDrawingSession() {
return list; return list;
}, []); }, []);
const applyPublication = useCallback(
(nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
setPublicationSlugState("");
},
[],
);
const loadDrawing = useCallback( const loadDrawing = useCallback(
async (drawingId: string) => { async (drawingId: string) => {
const version = ++loadVersionRef.current; const version = ++loadVersionRef.current;
@@ -113,9 +155,15 @@ export function useDrawingSession() {
setError(null); setError(null);
try { try {
const [list, drawing] = await Promise.all([ const [list, drawing, nextPublication] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }), requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }), requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, {
method: "GET",
}),
requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{ method: "GET" },
),
]); ]);
if (version !== loadVersionRef.current) { if (version !== loadVersionRef.current) {
@@ -134,20 +182,25 @@ export function useDrawingSession() {
setActiveId(drawing.id); setActiveId(drawing.id);
setActiveTitleState(drawing.title); setActiveTitleState(drawing.title);
setScene(nextScene); setScene(nextScene);
applyPublication(nextPublication);
setEditorReloadNonce((current) => current + 1); setEditorReloadNonce((current) => current + 1);
} catch (loadError) { } catch (loadError) {
if (version !== loadVersionRef.current) { if (version !== loadVersionRef.current) {
return; return;
} }
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing"); setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
} finally { } finally {
if (version === loadVersionRef.current) { if (version === loadVersionRef.current) {
setLoading(false); setLoading(false);
} }
} }
}, },
[], [applyPublication],
); );
const saveScene = useCallback( const saveScene = useCallback(
@@ -164,10 +217,13 @@ export function useDrawingSession() {
showToast("Saving..."); showToast("Saving...");
} }
const promise = requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, { const promise = requestJson<UpdateResponse>(
method: "PUT", `/api/drawings/${drawingId}`,
body: JSON.stringify({ ...nextScene, expectedRevision }), {
}) method: "PUT",
body: JSON.stringify({ ...nextScene, expectedRevision }),
},
)
.then((body) => { .then((body) => {
setDrawings((current) => replaceMeta(current, body.drawing)); setDrawings((current) => replaceMeta(current, body.drawing));
if (currentIdRef.current === drawingId) { if (currentIdRef.current === drawingId) {
@@ -181,14 +237,18 @@ export function useDrawingSession() {
if (isConflictError(saveError)) { if (isConflictError(saveError)) {
clearPendingSaveTimeout(); clearPendingSaveTimeout();
setError(null); setError(null);
setDrawings((current) => replaceMeta(current, saveError.body.drawing)); setDrawings((current) =>
replaceMeta(current, saveError.body.drawing),
);
if (isManual) { if (isManual) {
showToast("Reloading latest..."); showToast("Reloading latest...");
} }
return loadDrawing(drawingId); return loadDrawing(drawingId);
} }
setError(saveError instanceof Error ? saveError.message : "Save failed"); setError(
saveError instanceof Error ? saveError.message : "Save failed",
);
if (isManual) { if (isManual) {
showToast("Save failed", 2000); showToast("Save failed", 2000);
} }
@@ -238,7 +298,10 @@ export function useDrawingSession() {
}, [saveScene]); }, [saveScene]);
const navigateToDrawing = useCallback( const navigateToDrawing = useCallback(
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => { async (
drawingId: string,
options?: { replace?: boolean; flush?: boolean },
) => {
const replace = options?.replace ?? false; const replace = options?.replace ?? false;
const flush = options?.flush ?? true; const flush = options?.flush ?? true;
@@ -266,7 +329,9 @@ export function useDrawingSession() {
const createDrawing = useCallback(async () => { const createDrawing = useCallback(async () => {
await flushPendingSave(); await flushPendingSave();
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" }); const created = await requestJson<DrawingResponse>("/api/drawings", {
method: "POST",
});
await navigateToDrawing(created.id, { flush: false }); await navigateToDrawing(created.id, { flush: false });
}, [flushPendingSave, navigateToDrawing]); }, [flushPendingSave, navigateToDrawing]);
@@ -280,12 +345,18 @@ export function useDrawingSession() {
await flushPendingSave(); await flushPendingSave();
} }
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, { const response = await requestJson<{ ok: true; nextId: string | null }>(
method: "DELETE", `/api/drawings/${drawingId}`,
}); {
method: "DELETE",
},
);
if (drawingId === currentIdRef.current && response.nextId) { if (drawingId === currentIdRef.current && response.nextId) {
await navigateToDrawing(response.nextId, { replace: true, flush: false }); await navigateToDrawing(response.nextId, {
replace: true,
flush: false,
});
} else { } else {
await refreshList(); await refreshList();
} }
@@ -305,13 +376,16 @@ export function useDrawingSession() {
} }
try { try {
const body = await requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, { const body = await requestJson<UpdateResponse>(
method: "PUT", `/api/drawings/${drawingId}`,
body: JSON.stringify({ {
title: currentTitleRef.current, method: "PUT",
expectedRevision: loadedRevisionRef.current, body: JSON.stringify({
}), title: currentTitleRef.current,
}); expectedRevision: loadedRevisionRef.current,
}),
},
);
currentTitleRef.current = body.drawing.title; currentTitleRef.current = body.drawing.title;
loadedRevisionRef.current = body.drawing.revision; loadedRevisionRef.current = body.drawing.revision;
@@ -320,12 +394,16 @@ export function useDrawingSession() {
} catch (renameError) { } catch (renameError) {
if (isConflictError(renameError)) { if (isConflictError(renameError)) {
clearPendingSaveTimeout(); clearPendingSaveTimeout();
setDrawings((current) => replaceMeta(current, renameError.body.drawing)); setDrawings((current) =>
replaceMeta(current, renameError.body.drawing),
);
await loadDrawing(drawingId); await loadDrawing(drawingId);
return; return;
} }
setError(renameError instanceof Error ? renameError.message : "Rename failed"); setError(
renameError instanceof Error ? renameError.message : "Rename failed",
);
} }
}, [clearPendingSaveTimeout, loadDrawing]); }, [clearPendingSaveTimeout, loadDrawing]);
@@ -350,6 +428,82 @@ export function useDrawingSession() {
[navigateToDrawing], [navigateToDrawing],
); );
const setPublicationSlug = useCallback((slug: string) => {
setPublicationSlugState(slug);
}, []);
const publishPublication = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
setPublicationBusy(true);
setError(null);
showToast("Publishing...");
try {
await flushPendingSave();
const currentDrawingId = currentIdRef.current;
if (!currentDrawingId) {
return;
}
const body = await requestJson<PublicationResponse>(
`/api/drawings/${currentDrawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: publicationSlug,
}),
},
);
applyPublication(body);
showToast("Published", 2000);
} catch (publishError) {
setError(
publishError instanceof Error ? publishError.message : "Publish failed",
);
showToast("Publish failed", 2000);
} finally {
setPublicationBusy(false);
}
}, [applyPublication, flushPendingSave, publicationSlug, showToast]);
const disablePublication = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
setPublicationBusy(true);
setError(null);
showToast("Disabling...");
try {
const body = await requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({ enabled: false }),
},
);
applyPublication(body);
showToast("Publication disabled", 2000);
} catch (disableError) {
setError(
disableError instanceof Error ? disableError.message : "Disable failed",
);
showToast("Disable failed", 2000);
} finally {
setPublicationBusy(false);
}
}, [applyPublication, showToast]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
@@ -391,9 +545,15 @@ export function useDrawingSession() {
const list = await refreshList(); const list = await refreshList();
const drawingId = currentIdRef.current; const drawingId = currentIdRef.current;
const loadedRevision = loadedRevisionRef.current; const loadedRevision = loadedRevisionRef.current;
const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null; const serverDrawing = drawingId
? list.find((drawing) => drawing.id === drawingId)
: null;
if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) { if (
serverDrawing &&
loadedRevision !== null &&
serverDrawing.revision > loadedRevision
) {
clearPendingSaveTimeout(); clearPendingSaveTimeout();
await loadDrawing(serverDrawing.id); await loadDrawing(serverDrawing.id);
} }
@@ -441,11 +601,17 @@ export function useDrawingSession() {
error, error,
toastMessage, toastMessage,
editorReloadNonce, editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle, setActiveTitle,
submitTitle, submitTitle,
handleSceneChange, handleSceneChange,
selectDrawing, selectDrawing,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
}; };
} }
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { type PublicDrawing } from "../../core/shared";
type PublicDrawingState = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
};
class RequestError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
const response = await fetch(
`/api/public/drawings/${encodeURIComponent(slug)}`,
);
const body = (await response.json()) as PublicDrawing & { error?: string };
if (!response.ok) {
throw new RequestError(
response.status,
body.error ?? `Request failed: ${response.status}`,
);
}
return body;
}
export function usePublicDrawing(slug: string): PublicDrawingState {
const [drawing, setDrawing] = useState<PublicDrawing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void requestPublicDrawing(slug)
.then((nextDrawing) => {
if (cancelled) {
return;
}
setDrawing(nextDrawing);
document.title = nextDrawing.title;
})
.catch((loadError: unknown) => {
if (cancelled) {
return;
}
setDrawing(null);
setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [slug]);
return {
drawing,
loading,
error,
};
}
+13 -1
View File
@@ -21,7 +21,9 @@ const EXCALIDRAW_THEME_TOKEN_MAP = [
["--overlay-bg-color", "--app-overlay-bg"], ["--overlay-bg-color", "--app-overlay-bg"],
] as const; ] as const;
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) { export function useThemeTokenSync(
appShellRef: RefObject<HTMLDivElement | null>,
) {
const themeSyncFrameRef = useRef<number | null>(null); const themeSyncFrameRef = useRef<number | null>(null);
const syncThemeTokens = useCallback(() => { const syncThemeTokens = useCallback(() => {
@@ -31,10 +33,20 @@ export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>)
return; return;
} }
// Read all tokens first to avoid layout thrashing
const computedStyles = window.getComputedStyle(excalidrawRoot); const computedStyles = window.getComputedStyle(excalidrawRoot);
const updates: Array<[string, string]> = [];
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) { for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
const value = computedStyles.getPropertyValue(sourceToken).trim(); const value = computedStyles.getPropertyValue(sourceToken).trim();
if (value) { if (value) {
updates.push([targetToken, value]);
}
}
// Write in a separate pass, skipping unchanged values
for (const [targetToken, value] of updates) {
if (appShell.style.getPropertyValue(targetToken) !== value) {
appShell.style.setProperty(targetToken, value); appShell.style.setProperty(targetToken, value);
} }
} }
+49
View File
@@ -134,6 +134,22 @@ input {
font-size: var(--app-sidebar-font-size); font-size: var(--app-sidebar-font-size);
} }
.drawing-link-active {
display: grid;
gap: 10px;
}
.drawing-item-header {
display: flex;
align-items: center;
gap: 8px;
}
.drawing-delete-button {
flex: 0 0 32px;
margin-left: auto;
}
.drawing-title, .drawing-title,
.title-input { .title-input {
display: block; display: block;
@@ -152,6 +168,38 @@ input {
padding: 6px 8px; padding: 6px 8px;
} }
.publication-panel {
display: grid;
gap: 8px;
padding: 8px 0 4px;
}
.publication-label,
.publication-status {
color: color-mix(in srgb, var(--app-text-color) 75%, white);
font-size: 0.75rem;
}
.publication-input {
font-family:
ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
}
.publication-actions {
display: flex;
gap: 8px;
}
.publication-link {
color: inherit;
text-decoration: none;
}
.publication-link:hover {
text-decoration: underline;
}
.editor-shell { .editor-shell {
position: relative; position: relative;
height: 100%; height: 100%;
@@ -270,6 +318,7 @@ input {
} }
.editor-frame, .editor-frame,
.public-viewer-shell,
.editor-loading { .editor-loading {
height: 100%; height: 100%;
} }
+25
View File
@@ -0,0 +1,25 @@
import { HttpError } from "./scene";
const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]);
export function normalizePublicationSlug(input: unknown): string {
if (typeof input !== "string") {
throw new HttpError(400, "slug must be a string");
}
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length === 0) {
throw new HttpError(400, "slug is required");
}
if (RESERVED_SLUGS.has(normalized)) {
throw new HttpError(400, "slug is reserved");
}
return normalized;
}
+7 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene"; import {
HttpError,
MAX_SCENE_BYTES,
emptyScene,
normalizeScene,
parseJsonBody,
} from "./scene";
describe("scene helpers", () => { describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => { test("creates empty scenes with dark theme by default", () => {
+3 -1
View File
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
} }
const appState = Object.fromEntries( const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)), Object.entries(input.appState).filter(
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
),
); );
return { return {
+13
View File
@@ -4,6 +4,19 @@ export type ScenePayload = {
files: Record<string, unknown>; files: Record<string, unknown>;
}; };
export type DrawingPublication =
| {
enabled: false;
}
| {
enabled: true;
slug: string;
};
export type PublicDrawing = {
title: string;
} & ScenePayload;
export type DrawingMeta = { export type DrawingMeta = {
id: string; id: string;
title: string; title: string;
+18 -5
View File
@@ -41,7 +41,9 @@ function testScene(): ScenePayload {
describe("mcp tool handlers", () => { describe("mcp tool handlers", () => {
test("PUBLIC_BASE_URL is required", () => { test("PUBLIC_BASE_URL is required", () => {
expect(() => resolvePublicBaseUrl("")).toThrow("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", () => { test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
@@ -79,14 +81,21 @@ describe("mcp tool handlers", () => {
test("replace_drawing updates the existing drawing", () => { test("replace_drawing updates the existing drawing", () => {
const { store, tools } = createTempTools(); const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "replace", scene: testScene() }); const result = tools.create_drawing({
title: "replace",
scene: testScene(),
});
const nextScene: ScenePayload = { const nextScene: ScenePayload = {
elements: [{ id: "manual", type: "rectangle" }], elements: [{ id: "manual", type: "rectangle" }],
appState: { theme: "dark" }, appState: { theme: "dark" },
files: {}, files: {},
}; };
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene }); const replaced = tools.replace_drawing({
id: result.id,
title: "replaced",
scene: nextScene,
});
expect(store.listDrawings()).toHaveLength(1); expect(store.listDrawings()).toHaveLength(1);
expect(replaced.revision).toBe(2); expect(replaced.revision).toBe(2);
@@ -121,13 +130,17 @@ describe("mcp tool handlers", () => {
expect(patched.revision).toBe(2); expect(patched.revision).toBe(2);
expect(store.getDrawing(result.id)?.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 }]); expect(store.getDrawing(result.id)?.scene.elements).toEqual([
{ id: "one", type: "rectangle", x: 42, y: 20 },
]);
}); });
test("missing drawing IDs return tool errors", () => { test("missing drawing IDs return tool errors", () => {
const { tools } = createTempTools(); const { tools } = createTempTools();
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing"); expect(() => tools.get_drawing({ id: "missing" })).toThrow(
"Drawing not found: missing",
);
expect(() => expect(() =>
tools.replace_drawing({ tools.replace_drawing({
id: "missing", id: "missing",
+50 -16
View File
@@ -47,7 +47,9 @@ function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, ""); return url.replace(/\/+$/, "");
} }
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string { export function resolvePublicBaseUrl(
raw = process.env.PUBLIC_BASE_URL,
): string {
const value = raw?.trim(); const value = raw?.trim();
if (!value) { if (!value) {
throw new Error("PUBLIC_BASE_URL is required for the MCP server"); throw new Error("PUBLIC_BASE_URL is required for the MCP server");
@@ -58,13 +60,14 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string
if (url.protocol === "http:" || url.protocol === "https:") { if (url.protocol === "http:" || url.protocol === "https:") {
return trimTrailingSlash(url.toString()); return trimTrailingSlash(url.toString());
} }
} catch { } catch {}
}
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL"); throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
} }
export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesGuideResource | undefined { export function resolveStylesGuidePath(
raw = DEFAULT_STYLES_GUIDE_PATH,
): StylesGuideResource | undefined {
const value = raw.trim(); const value = raw.trim();
if (!isAbsolute(value)) { if (!isAbsolute(value)) {
@@ -75,7 +78,12 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
try { try {
stats = statSync(value); stats = statSync(value);
} catch (error) { } catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return undefined; return undefined;
} }
@@ -88,7 +96,9 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
const extension = extname(value).toLowerCase(); const extension = extname(value).toLowerCase();
if (extension !== ".md" && extension !== ".markdown") { if (extension !== ".md" && extension !== ".markdown") {
throw new Error("Styles guide path must point to a Markdown file (.md or .markdown)"); throw new Error(
"Styles guide path must point to a Markdown file (.md or .markdown)",
);
} }
if (!stats.isFile()) { if (!stats.isFile()) {
@@ -127,7 +137,10 @@ function notFound(id: string): never {
throw new Error(`Drawing not found: ${id}`); throw new Error(`Drawing not found: ${id}`);
} }
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) { function mutationResult(
publicBaseUrl: string,
drawing: { id: string; title: string; revision: number },
) {
return { return {
id: drawing.id, id: drawing.id,
title: drawing.title, title: drawing.title,
@@ -149,16 +162,26 @@ function updateExistingDrawing(
return result.drawing; return result.drawing;
} }
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload { function applyScenePatch(
scene: ScenePayload,
patch: Operation[],
): ScenePayload {
try { try {
const result = applyPatch(structuredClone(scene), patch, true, true, true); const result = applyPatch(structuredClone(scene), patch, true, true, true);
return normalizeScene(result.newDocument); return normalizeScene(result.newDocument);
} catch (error) { } catch (error) {
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch"); throw new Error(
error instanceof Error
? `Invalid JSON Patch: ${error.message}`
: "Invalid JSON Patch",
);
} }
} }
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) { export function createMcpToolHandlers(
store: DrawingStore,
publicBaseUrl: string,
) {
return { return {
list_drawings() { list_drawings() {
return store.listDrawings(); return store.listDrawings();
@@ -224,7 +247,8 @@ export function createExcaliMcpHandler(
"excali://styles-guide", "excali://styles-guide",
{ {
title: "Styles Guide", title: "Styles Guide",
description: "User-provided drawing styles guide for scene generation", description:
"User-provided drawing styles guide for scene generation",
mimeType: "text/markdown", mimeType: "text/markdown",
}, },
async (uri) => ({ async (uri) => ({
@@ -252,7 +276,8 @@ export function createExcaliMcpHandler(
"get_drawing", "get_drawing",
{ {
title: "Get Drawing", title: "Get Drawing",
description: "Returns drawing metadata, browser URL, and raw ScenePayload.", description:
"Returns drawing metadata, browser URL, and raw ScenePayload.",
inputSchema: idInput.shape, inputSchema: idInput.shape,
}, },
async (input) => jsonText(tools.get_drawing(input)), async (input) => jsonText(tools.get_drawing(input)),
@@ -262,7 +287,8 @@ export function createExcaliMcpHandler(
"create_drawing", "create_drawing",
{ {
title: "Create Drawing", title: "Create Drawing",
description: "Creates a drawing from an explicit Excalidraw ScenePayload.", description:
"Creates a drawing from an explicit Excalidraw ScenePayload.",
inputSchema: createDrawingInput.shape, inputSchema: createDrawingInput.shape,
}, },
async (input) => jsonText(tools.create_drawing(input)), async (input) => jsonText(tools.create_drawing(input)),
@@ -282,7 +308,8 @@ export function createExcaliMcpHandler(
"patch_drawing", "patch_drawing",
{ {
title: "Patch Drawing", title: "Patch Drawing",
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.", description:
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
inputSchema: patchDrawingInput.shape, inputSchema: patchDrawingInput.shape,
}, },
async (input) => jsonText(tools.patch_drawing(input)), async (input) => jsonText(tools.patch_drawing(input)),
@@ -304,7 +331,11 @@ export function createExcaliMcpHandler(
export function createMcpServer() { export function createMcpServer() {
const store = createDrawingStore(process.env.DATABASE_PATH); const store = createDrawingStore(process.env.DATABASE_PATH);
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath()); const handler = createExcaliMcpHandler(
store,
resolvePublicBaseUrl(),
resolveStylesGuidePath(),
);
return { return {
port: Number(process.env.MCP_PORT ?? "3001"), port: Number(process.env.MCP_PORT ?? "3001"),
@@ -312,7 +343,10 @@ export function createMcpServer() {
fetch(request: Request) { fetch(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname !== "/mcp") { if (url.pathname !== "/mcp") {
return Response.json({ ok: false, error: "Not found" }, { status: 404 }); return Response.json(
{ ok: false, error: "Not found" },
{ status: 404 },
);
} }
return handler(request); return handler(request);
+227 -4
View File
@@ -52,7 +52,9 @@ describe("api", () => {
const { api, cleanup } = withApi(); const { api, cleanup } = withApi();
const response = api.getDrawing( const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }), Object.assign(new Request("http://local/api/drawings/missing"), {
params: { id: "missing" },
}),
); );
expect(response.status).toBe(404); expect(response.status).toBe(404);
@@ -199,10 +201,231 @@ describe("api", () => {
}, },
}); });
const stored = await api.getDrawing( const stored = await api
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }), .getDrawing(
).json(); Object.assign(new Request(`http://local/api/drawings/${created.id}`), {
params: { id: created.id },
}),
)
.json();
expect(stored.elements).toEqual([{ id: "server" }]); expect(stored.elements).toEqual([{ id: "server" }]);
cleanup(); cleanup();
}); });
test("returns disabled and enabled publication shapes", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const disabled = await api.getDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(disabled.status).toBe(200);
expect(await disabled.json()).toEqual({ enabled: false });
const publish = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "Flow Chart",
}),
}),
{ params: { id: created.id } },
),
);
expect(publish.status).toBe(200);
expect(await publish.json()).toEqual({
enabled: true,
slug: "flow-chart",
});
const enabled = await api.getDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(await enabled.json()).toEqual({
enabled: true,
slug: "flow-chart",
});
cleanup();
});
test("publishes, renames, and disables publications", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "first",
}),
}),
{ params: { id: created.id } },
),
);
const renamed = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "second",
}),
}),
{ params: { id: created.id } },
),
);
expect(renamed.status).toBe(200);
expect(await renamed.json()).toEqual({
enabled: true,
slug: "second",
});
const disable = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({ enabled: false }),
}),
{ params: { id: created.id } },
),
);
expect(disable.status).toBe(200);
expect(await disable.json()).toEqual({ enabled: false });
cleanup();
});
test("returns 400 for invalid publication slugs", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const response = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "API",
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
ok: false,
error: "slug is reserved",
});
cleanup();
});
test("returns 409 for publication slug collisions", async () => {
const { api, cleanup } = withApi();
const first = await api.createDrawing().json();
const second = await api.createDrawing().json();
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${first.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "shared",
}),
}),
{ params: { id: first.id } },
),
);
const response = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${second.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "shared",
}),
}),
{ params: { id: second.id } },
),
);
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
ok: false,
error: "Published slug already exists",
});
cleanup();
});
test("returns public scene data for enabled slugs and 404 otherwise", 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({
title: "Public Flow",
elements: [{ id: "shape" }],
appState: { theme: "dark" },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "public-flow",
}),
}),
{ params: { id: created.id } },
),
);
const enabled = await api.getPublicDrawing(
Object.assign(
new Request("http://local/api/public/drawings/public-flow"),
{ params: { slug: "public-flow" } },
),
);
expect(enabled.status).toBe(200);
expect(await enabled.json()).toEqual({
title: "Public Flow",
elements: [{ id: "shape" }],
appState: { theme: "dark" },
files: {},
});
const missing = await api.getPublicDrawing(
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(missing.status).toBe(404);
cleanup();
});
}); });
+127 -6
View File
@@ -1,6 +1,12 @@
import { type DrawingStore } from "./db"; import { type DrawingStore } from "./db";
import { type DrawingMeta } from "../core/shared"; import { normalizePublicationSlug } from "../core/publication";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene"; import { type DrawingMeta, type DrawingPublication } 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>;
@@ -33,7 +39,9 @@ function toMeta(drawing: DrawingMeta): DrawingMeta {
}; };
} }
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined { function parseExpectedRevision(
raw: Record<string, unknown>,
): number | undefined {
if (!Object.hasOwn(raw, "expectedRevision")) { if (!Object.hasOwn(raw, "expectedRevision")) {
return undefined; return undefined;
} }
@@ -46,6 +54,10 @@ function parseExpectedRevision(raw: Record<string, unknown>): number | undefined
return value; return value;
} }
function jsonPublication(publication: DrawingPublication): Response {
return json(publication);
}
export function createApi(store: DrawingStore) { export function createApi(store: DrawingStore) {
return { return {
health() { health() {
@@ -73,7 +85,10 @@ export function createApi(store: DrawingStore) {
const drawing = id ? store.getDrawing(id) : null; const drawing = id ? store.getDrawing(id) : null;
if (!drawing) { if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 }); return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
} }
return json({ return json({
@@ -85,6 +100,27 @@ export function createApi(store: DrawingStore) {
} }
}, },
getPublicDrawing(request: RouteRequest) {
try {
const slug = request.params?.slug;
if (!slug) {
throw new HttpError(400, "Missing published slug");
}
const drawing = store.getPublicDrawing(slug);
if (!drawing) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return json(drawing);
} catch (error) {
return errorResponse(error);
}
},
async updateDrawing(request: RouteRequest) { async updateDrawing(request: RouteRequest) {
try { try {
const id = request.params?.id; const id = request.params?.id;
@@ -113,7 +149,10 @@ export function createApi(store: DrawingStore) {
}); });
if (!result.ok && result.reason === "not_found") { 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") { if (!result.ok && result.reason === "conflict") {
@@ -140,7 +179,10 @@ export function createApi(store: DrawingStore) {
deleteDrawing(request: RouteRequest) { deleteDrawing(request: RouteRequest) {
const id = request.params?.id; const id = request.params?.id;
if (!id) { if (!id) {
return json({ ok: false, error: "Missing drawing id" }, { status: 400 }); return json(
{ ok: false, error: "Missing drawing id" },
{ status: 400 },
);
} }
const { deleted, next } = store.deleteDrawing(id); const { deleted, next } = store.deleteDrawing(id);
@@ -150,5 +192,84 @@ export function createApi(store: DrawingStore) {
return json({ ok: true, nextId: next?.id ?? null }); return json({ ok: true, nextId: next?.id ?? null });
}, },
getDrawingPublication(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const publication = store.getDrawingPublication(id);
if (!publication) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(publication);
} catch (error) {
return errorResponse(error);
}
},
async updateDrawingPublication(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const raw = parseJsonBody<Record<string, unknown>>(
await request.text(),
);
if (raw.enabled === false) {
const result = store.disableDrawingPublication(id);
if (!result.ok) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(result.publication);
}
if (raw.enabled !== true) {
throw new HttpError(400, "enabled must be a boolean");
}
const drawing = store.getDrawing(id);
if (!drawing) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
const result = store.publishDrawing(id, {
slug: normalizePublicationSlug(raw.slug),
});
if (!result.ok && result.reason === "not_found") {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
if (!result.ok && result.reason === "slug_conflict") {
return json(
{ ok: false, error: "Published slug already exists" },
{ status: 409 },
);
}
return jsonPublication(result.publication);
} catch (error) {
return errorResponse(error);
}
},
}; };
} }
+117 -6
View File
@@ -79,7 +79,9 @@ describe("drawing store", () => {
expect(stale.ok).toBe(false); expect(stale.ok).toBe(false);
expect(stale.ok ? null : stale.reason).toBe("conflict"); expect(stale.ok ? null : stale.reason).toBe("conflict");
expect(store.getDrawing(created.id)?.revision).toBe(1); expect(store.getDrawing(created.id)?.revision).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]); expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "first" },
]);
}); });
test("keeps the revision unchanged when the scene is already stored", () => { test("keeps the revision unchanged when the scene is already stored", () => {
@@ -109,13 +111,21 @@ describe("drawing store", () => {
files: {}, files: {},
}; };
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene }); const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1); expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene }); const repeated = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(repeated.ok).toBe(true); expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1); expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]); expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "same" },
]);
}); });
test("increments the revision when the title changes but the scene does not", () => { test("increments the revision when the title changes but the scene does not", () => {
@@ -127,12 +137,113 @@ describe("drawing store", () => {
files: {}, files: {},
}; };
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene }); const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1); expect(first.ok ? first.drawing.revision : null).toBe(1);
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene }); const renamed = store.updateDrawing(created.id, {
expectedRevision: 1,
title: "Renamed",
scene,
});
expect(renamed.ok).toBe(true); expect(renamed.ok).toBe(true);
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed"); expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2); expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
}); });
test("persists publication fields and exposes public drawings by slug", () => {
const store = createTempStore();
const created = store.createDrawing("Flow");
store.updateDrawing(created.id, {
scene: {
elements: [{ id: "one" }],
appState: { theme: "dark" },
files: {},
},
});
const published = store.publishDrawing(created.id, { slug: "flow" });
expect(published.ok).toBe(true);
expect(store.getDrawingPublication(created.id)).toEqual({
enabled: true,
slug: "flow",
});
expect(store.getPublicDrawing("flow")).toEqual({
title: "Flow",
elements: [{ id: "one" }],
appState: { theme: "dark" },
files: {},
});
});
test("enforces unique active publication slugs", () => {
const store = createTempStore();
const first = store.createDrawing("First");
const second = store.createDrawing("Second");
expect(
store.publishDrawing(first.id, {
slug: "shared",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "shared",
},
});
expect(
store.publishDrawing(second.id, {
slug: "shared",
}),
).toEqual({
ok: false,
reason: "slug_conflict",
});
expect(store.disableDrawingPublication(first.id)).toEqual({
ok: true,
publication: { enabled: false },
});
expect(
store.publishDrawing(second.id, {
slug: "shared",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "shared",
},
});
});
test("clears public fields on disable", () => {
const store = createTempStore();
const created = store.createDrawing();
expect(
store.publishDrawing(created.id, {
slug: "note",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "note",
},
});
expect(store.disableDrawingPublication(created.id)).toEqual({
ok: true,
publication: { enabled: false },
});
expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false });
expect(store.getPublicDrawing("note")).toBeNull();
});
}); });
+216 -10
View File
@@ -2,7 +2,13 @@ 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 "../core/shared"; import {
DEFAULT_TITLE,
type DrawingMeta,
type DrawingPublication,
type DrawingRecord,
type ScenePayload,
} from "../core/shared";
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene"; import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
type DrawingRow = { type DrawingRow = {
@@ -16,11 +22,26 @@ type DrawingRow = {
type DrawingMetaRow = Omit<DrawingRow, "data">; type DrawingMetaRow = Omit<DrawingRow, "data">;
type DrawingPublicationRow = {
publicEnabled: number;
publicSlug: string | null;
};
type PublicDrawingRow = {
title: string;
data: string;
};
export type DrawingUpdateResult = export type DrawingUpdateResult =
| { ok: true; drawing: DrawingRecord } | { ok: true; drawing: DrawingRecord }
| { ok: false; reason: "not_found" } | { ok: false; reason: "not_found" }
| { ok: false; reason: "conflict"; drawing: DrawingRecord }; | { ok: false; reason: "conflict"; drawing: DrawingRecord };
export type DrawingPublicationUpdateResult =
| { ok: true; publication: DrawingPublication }
| { ok: false; reason: "not_found" }
| { ok: false; reason: "slug_conflict" };
export type DrawingStore = ReturnType<typeof createDrawingStore>; export type DrawingStore = ReturnType<typeof createDrawingStore>;
function createId(): string { function createId(): string {
@@ -54,6 +75,43 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
}; };
} }
function readPublication(
row: DrawingPublicationRow | null | undefined,
): DrawingPublication | null {
if (!row) {
return null;
}
if (row.publicEnabled !== 1 || !row.publicSlug) {
return { enabled: false };
}
return {
enabled: true,
slug: row.publicSlug,
};
}
function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
if (!row) {
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 public scene: ${message}`);
}
return {
title: row.title,
...scene,
};
}
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null { function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) { if (!row) {
return null; return null;
@@ -63,7 +121,8 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
try { try {
scene = normalizeScene(JSON.parse(row.data)); scene = normalizeScene(JSON.parse(row.data));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown scene parsing error"; const message =
error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`); throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
} }
@@ -73,6 +132,30 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
}; };
} }
function ensurePublicationColumns(db: Database) {
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{
name: string;
}>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("public_enabled")) {
db.exec(
"ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0",
);
}
if (!names.has("public_slug")) {
db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT");
}
}
function isSlugConflictError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
);
}
export function createDrawingStore(databasePath = resolveDatabasePath()) { export function createDrawingStore(databasePath = resolveDatabasePath()) {
mkdirSync(dirname(databasePath), { recursive: true }); mkdirSync(dirname(databasePath), { recursive: true });
@@ -86,13 +169,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
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 revision INTEGER NOT NULL DEFAULT 0,
public_enabled INTEGER NOT NULL DEFAULT 0,
public_slug TEXT
); );
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
ON drawings(updated_at DESC, created_at DESC); ON drawings(updated_at DESC, created_at DESC);
`); `);
ensurePublicationColumns(db);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx
ON drawings(public_slug)
WHERE public_enabled = 1 AND public_slug IS NOT NULL;
`);
const listQuery = db.query(` const listQuery = db.query(`
SELECT SELECT
id, id,
@@ -162,6 +254,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
WHERE id = ?1 WHERE id = ?1
`); `);
const getPublicationQuery = db.query(`
SELECT
public_enabled AS publicEnabled,
public_slug AS publicSlug
FROM drawings
WHERE id = ?1
`);
const publishQuery = db.query(`
UPDATE drawings
SET
public_enabled = 1,
public_slug = ?2
WHERE id = ?1
`);
const disablePublicationQuery = db.query(`
UPDATE drawings
SET
public_enabled = 0,
public_slug = NULL
WHERE id = ?1
`);
const getPublicDrawingQuery = db.query(`
SELECT
title,
data
FROM drawings
WHERE public_enabled = 1 AND public_slug = ?1
`);
function listDrawings(): DrawingMeta[] { function listDrawings(): DrawingMeta[] {
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!); return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
} }
@@ -201,8 +325,12 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: true, drawing: existing }; return { ok: true, drawing: existing };
} }
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title; const nextTitle = hasTitle
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data; ? normalizeTitle(update.title)
: existingRow.title;
const nextScene = hasScene
? JSON.stringify(update.scene)
: existingRow.data;
const titleChanged = nextTitle !== existingRow.title; const titleChanged = nextTitle !== existingRow.title;
const sceneChanged = nextScene !== existingRow.data; const sceneChanged = nextScene !== existingRow.data;
@@ -210,7 +338,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: true, drawing: existing }; return { ok: true, drawing: existing };
} }
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) { if (
update.expectedRevision !== undefined &&
update.expectedRevision !== existingRow.revision
) {
return { ok: false, reason: "conflict", drawing: existing }; return { ok: false, reason: "conflict", drawing: existing };
} }
@@ -219,17 +350,36 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
changes = changes =
update.expectedRevision === undefined update.expectedRevision === undefined
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes) ? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes); : Number(
updateBothExpectedQuery.run(
id,
nextTitle,
nextScene,
update.expectedRevision,
).changes,
);
} else if (sceneChanged) { } else if (sceneChanged) {
changes = changes =
update.expectedRevision === undefined update.expectedRevision === undefined
? Number(updateSceneQuery.run(id, nextScene).changes) ? Number(updateSceneQuery.run(id, nextScene).changes)
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes); : Number(
updateSceneExpectedQuery.run(
id,
nextScene,
update.expectedRevision,
).changes,
);
} else { } else {
changes = changes =
update.expectedRevision === undefined update.expectedRevision === undefined
? Number(updateTitleQuery.run(id, nextTitle).changes) ? Number(updateTitleQuery.run(id, nextTitle).changes)
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes); : Number(
updateTitleExpectedQuery.run(
id,
nextTitle,
update.expectedRevision,
).changes,
);
} }
const drawing = getDrawing(id); const drawing = getDrawing(id);
@@ -244,7 +394,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: false, reason: "conflict", drawing }; return { ok: false, reason: "conflict", drawing };
} }
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } { function deleteDrawing(id: string): {
deleted: boolean;
next: DrawingMeta | null;
} {
const changes = Number(deleteQuery.run(id).changes); const changes = Number(deleteQuery.run(id).changes);
if (changes === 0) { if (changes === 0) {
return { deleted: false, next: null }; return { deleted: false, next: null };
@@ -256,6 +409,55 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
}; };
} }
function getDrawingPublication(id: string): DrawingPublication | null {
return readPublication(
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
);
}
function publishDrawing(
id: string,
publication: { slug: string },
): DrawingPublicationUpdateResult {
try {
const changes = Number(publishQuery.run(id, publication.slug).changes);
if (changes === 0) {
return { ok: false, reason: "not_found" };
}
} catch (error) {
if (isSlugConflictError(error)) {
return { ok: false, reason: "slug_conflict" };
}
throw error;
}
return {
ok: true,
publication: getDrawingPublication(id)!,
};
}
function disableDrawingPublication(
id: string,
): DrawingPublicationUpdateResult {
const changes = Number(disablePublicationQuery.run(id).changes);
if (changes === 0) {
return { ok: false, reason: "not_found" };
}
return {
ok: true,
publication: { enabled: false },
};
}
function getPublicDrawing(slug: string) {
return readPublicDrawing(
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
);
}
function close() { function close() {
db.close(false); db.close(false);
} }
@@ -270,6 +472,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
ensureInitialDrawing, ensureInitialDrawing,
updateDrawing, updateDrawing,
deleteDrawing, deleteDrawing,
getDrawingPublication,
publishDrawing,
disableDrawingPublication,
getPublicDrawing,
}; };
} }
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test";
import { createDevServer } from "./dev-server";
describe("createDevServer", () => {
test("serves the spa for private and public drawing routes", () => {
const server = createDevServer();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true);
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true);
});
});
+1
View File
@@ -9,6 +9,7 @@ export function createDevServer() {
routes: { routes: {
...server.routes, ...server.routes,
"/d/:id": index, "/d/:id": index,
"/p/:slug": index,
}, },
} satisfies Parameters<typeof Bun.serve>[0]; } satisfies Parameters<typeof Bun.serve>[0];
} }
+45 -3
View File
@@ -38,12 +38,16 @@ describe("createServer", () => {
test("redirects / to the latest drawing", () => { test("redirects / to the latest drawing", () => {
const { server, store } = createServerFixture(); const { server, store } = createServerFixture();
const response = server.routes["/"]?.(new Request("http://local/")) as Response; const response = server.routes["/"]?.(
new Request("http://local/"),
) as Response;
const created = store.listDrawings(); const created = store.listDrawings();
expect(created).toHaveLength(1); expect(created).toHaveLength(1);
expect(response.status).toBe(302); expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`); expect(response.headers.get("location")).toBe(
`http://local/d/${created[0]?.id}`,
);
}); });
test("keeps Bun responsible for API routes", async () => { test("keeps Bun responsible for API routes", async () => {
@@ -58,11 +62,49 @@ describe("createServer", () => {
const { server } = createServerFixture(); const { server } = createServerFixture();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false); expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false);
});
test("serves public drawing data through the public api", async () => {
const { server, store } = createServerFixture();
const drawing = store.createDrawing("Published");
store.publishDrawing(drawing.id, { slug: "published-note" });
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(
new Request("http://local/api/public/drawings/published-note"),
{
params: { slug: "published-note" },
},
),
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
title: "Published",
elements: [],
appState: { theme: "dark" },
files: {},
});
});
test("returns 404 for unknown or unpublished public slugs", async () => {
const { server } = createServerFixture();
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(response?.status).toBe(404);
}); });
test("returns JSON 404 for unmatched requests", async () => { test("returns JSON 404 for unmatched requests", async () => {
const { server } = createServerFixture(); const { server } = createServerFixture();
const response = await server.fetch(new Request("http://local/index-abc123.js")); const response = await server.fetch(
new Request("http://local/index-abc123.js"),
);
expect(response.status).toBe(404); expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: "Not found" }); expect(await response.json()).toEqual({ ok: false, error: "Not found" });
+18 -4
View File
@@ -25,12 +25,26 @@ export function createServer(options: CreateServerOptions = {}) {
POST: () => api.createDrawing(), POST: () => api.createDrawing(),
}, },
"/api/drawings/:id": { "/api/drawings/:id": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request), GET: (request: Request & { params: Record<string, string> }) =>
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request), api.getDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request), PUT: (request: Request & { params: Record<string, string> }) =>
api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) =>
api.deleteDrawing(request),
},
"/api/public/drawings/:slug": {
GET: (request: Request & { params: Record<string, string> }) =>
api.getPublicDrawing(request),
},
"/api/drawings/:id/publication": {
GET: (request: Request & { params: Record<string, string> }) =>
api.getDrawingPublication(request),
PUT: (request: Request & { params: Record<string, string> }) =>
api.updateDrawingPublication(request),
}, },
}, },
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }), fetch: (_request: Request) =>
Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0]; } satisfies Parameters<typeof Bun.serve>[0];
} }