app store: publish a sidecar's assets to public/plugins/<id>/

Real PNG icons are coming, so this is the path they arrive by: a sidecar ships its assets beside its own
code, and install copies them to public/plugins/<id>/ where one static route serves them.

Copied rather than served in place because a sidecar shipping from its own repository has its assets
wherever that repository was unpacked, which is not a path the web server can be taught at build time.
One predictable destination means the serving rule never has to know how many plugins exist or where any
came from. It also makes assets a property of the INSTALL: uninstall removes them, and a plugin nobody
installed serves nothing.

Needed a new route, and the reason is a trap worth recording. `publicRoutes` in server.tsx is built by
globbing ./public at BOOT, so anything copied there afterwards is invisible to it — the first install of
a plugin would show a broken image until the server was restarted, and "install it, then restart to see
the icon" is not an install. `/plugins/*` resolves per request, like /novnc/* and /vendor/* already do.

Unlike those two it answers 404 rather than 500 for a missing file: an unpublished icon is an ordinary
state on a fresh machine, and a 500 would put a red line in the log for every dock render.

Proven end to end with a real asset: slskd's icon moved from public/slskd.png into the sidecar's own
assets/, published against an ALREADY RUNNING server, and fetched at 200 with the right bytes and
content-type — 404 before publishing, no restart between.

public/plugins/ is gitignored: it holds copies, and the originals live with each sidecar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 18:20:46 +00:00
co-authored by Claude Opus 5
parent 9084fabbf6
commit 209e916343
5 changed files with 92 additions and 1 deletions
+14
View File
@@ -221,6 +221,20 @@ const server = serve({
const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file);
},
// Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
//
// Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
// globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
// so its icon would 404 until the next restart — and "install it, then restart the server to see the
// icon" is not an install.
'/plugins/*': async (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
// 404 rather than letting a missing file surface as a 500. An icon that has not been published
// yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
// log for every dock render on a fresh machine.
if (!(await file.exists())) return new Response('Not found', { status: 404 });
return new Response(file);
},
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
'/api/vault/notifications/*': (req, server) => {