From b1557d2368c04ca81535f25cd6ac9398e71e412d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:42:55 +0000 Subject: [PATCH] Hot-swap Fastify routes on widget install/delete instead of restarting the process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A widget's own custom routes previously required a full process restart to register — impractical in practice, since the admin API key regenerates on every restart and would log the admin out of the panel they were just using to install the widget. New backend/src/server.ts owns building and swapping the Fastify instance, split into two steps: validateRoutesBuildable() builds a candidate app and listens on a throwaway ephemeral port to catch a broken widget's route registration (e.g. a path collision) before anything live is touched, and swapLiveServer() does the real close-old/build-new/listen-new cycle on the actual port. Only the HTTP server and its router are rebuilt — the DB connection, in-memory widget registry, scheduler intervals, Telegram session, and admin API key all stay untouched in the same running process. The split exists because of a real bug hit in testing: the admin routes that trigger install/delete are themselves served by the live Fastify instance, so awaiting the full swap inline closed the connection before the response could be sent (a DELETE that should have returned 204 came back as a bare connection reset). Now install/uninstall only awaiit the safe ephemeral-port validation inline (letting a broken widget be rejected and rolled back within its own request), and the admin routes schedule the actual swap via setImmediate after their response is already on the wire. Verified live: uploaded a widget with a custom route, confirmed a clean 201 and the route working moments later with no restart (same PID, same admin API key); deleted it and confirmed a clean 204, the route gone, and core widget routes unaffected; and uploaded a deliberately broken widget whose route collided with /health, confirming it was rejected with a 400, fully rolled back, and /health kept responding normally throughout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/api/admin.ts | 20 +++- backend/src/index.ts | 122 ++------------------- backend/src/server.ts | 178 +++++++++++++++++++++++++++++++ backend/src/widgets/install.ts | 50 +++++++-- backend/src/widgets/uninstall.ts | 12 ++- 5 files changed, 252 insertions(+), 130 deletions(-) create mode 100644 backend/src/server.ts diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 0912941..24820ca 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -13,8 +13,20 @@ import * as telegramClient from '../telegram/client.js'; import { loadedWidgets } from '../widgets/registry.js'; import { installUploadedWidget } from '../widgets/install.js'; import { uninstallWidget } from '../widgets/uninstall.js'; +import { swapLiveServer } from '../server.js'; import type { GlobalSettings } from '../storage/db/types.js'; +// Rebuilds and swaps in the live Fastify instance to pick up a widget's newly +// (de)registered routes — MUST run after the triggering request has already sent its +// response, never awaited inline in that handler, since the swap closes the very +// instance serving it (see widgets/install.ts's and uninstall.ts's doc comments for +// why — this dropped the response entirely when tried inline during testing). +function scheduleServerSwap(context: string) { + setImmediate(() => { + swapLiveServer().catch((err) => logger.error('server', `Route swap after ${context} failed: ${(err as Error).message}`)); + }); +} + // Not part of GlobalSettings itself (nothing to persist) — computed fresh on every // settings read/write so the Retention tab's "currently using" line and usage bar // always reflect the real total, not whatever was true when the row was last saved. @@ -235,7 +247,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { const { manifest, files } = req.body as { manifest?: unknown; files?: unknown }; const result = await installUploadedWidget(manifest, files); if (!result.ok) return reply.code(400).send({ error: result.error }); - return reply.code(201).send({ id: result.id }); + reply.code(201).send({ id: result.id }); + if (result.needsServerSwap) scheduleServerSwap(`installing "${result.id}"`); }); app.patch('/api/admin/widgets/:id', async (req, reply) => { @@ -263,8 +276,9 @@ export async function registerAdminRoutes(app: FastifyInstance) { const widget = installedWidgetsDb.getInstalled(id); if (!widget) return reply.code(404).send({ error: 'not found' }); if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' }); - await uninstallWidget(id); - return reply.code(204).send(); + const hadRoutes = await uninstallWidget(id); + reply.code(204).send(); + if (hadRoutes) scheduleServerSwap(`deleting "${id}"`); }); // --- Logs --- diff --git a/backend/src/index.ts b/backend/src/index.ts index 5563f60..e51250c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,25 +1,11 @@ -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import cookie from '@fastify/cookie'; -import fs from 'node:fs'; -import path from 'node:path'; import { migrate } from './storage/db/index.js'; import { ADMIN_API_KEY } from './api/apiKey.js'; -import { registerAuth } from './api/auth.js'; -import { registerPublicRoutes } from './api/public.js'; -import { registerAdminRoutes } from './api/admin.js'; -import { registerMediaProxy } from './api/mediaProxy.js'; -import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; -import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js'; +import { privateAccessConfigured } from './api/privateAccess.js'; import { startScheduler } from './queue/scheduler.js'; import { initFromSavedSession } from './telegram/client.js'; import { logger } from './storage/db/logs.js'; -import { loadAllWidgets, loadedWidgets } from './widgets/registry.js'; - -const PORT = Number(process.env.PORT) || 4000; -const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; -const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; -const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; +import { loadAllWidgets } from './widgets/registry.js'; +import { reloadServerRoutes } from './server.js'; function printApiKeyBanner() { const line = '='.repeat(64); @@ -29,7 +15,9 @@ function printApiKeyBanner() { console.log(`\n${line}`); console.log(' Homefeed admin API key (required for every /api/admin/* request)'); console.log(` ${ADMIN_API_KEY}`); - console.log(' This key is generated fresh on every restart — it will not be the same next time.'); + console.log(' This key is generated fresh on every process restart — it will not be'); + console.log(' the same next time. Installing/deleting a widget does NOT restart the'); + console.log(' process (see server.ts) and does not change this key.'); console.log(`${line}\n`); } @@ -38,103 +26,7 @@ async function main() { printApiKeyBanner(); await initFromSavedSession(); await loadAllWidgets(); - - const app = Fastify({ logger: false }); - - // Cross-origin is expected — see project-structure.md "Cross-origin and security - // implications". Not a wildcard: only the configured frontend origin is allowed. - // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, - // every PATCH (settings saves) and DELETE (removing sources/events) gets silently - // blocked by the browser at the CORS preflight stage, before the request ever - // reaches a route handler. - // credentials: true is required for the browser to send/accept the private-category - // login cookie cross-origin — safe only because origin is a specific value above, - // never a wildcard (the two are mutually exclusive per the CORS spec anyway). - await app.register(cors, { - origin: FRONTEND_ORIGIN, - credentials: true, - methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] - }); - - await app.register(cookie); - - // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty - // when content-type is set to 'application/json'" for any bodyless request (DELETE, - // or POST with no payload) that still carries a Content-Type header — exactly what - // browsers' fetch() does when a client sets that header unconditionally. An empty - // body is just as valid as `{}` for routes that don't read req.body at all. - app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { - if (typeof body !== 'string' || body.trim() === '') return done(null, {}); - try { - done(null, JSON.parse(body)); - } catch (err) { - done(err as Error, undefined); - } - }); - - await registerAuth(app); - await registerPublicRoutes(app); - await registerAdminRoutes(app); - await registerPrivateAccess(app); - - // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its - // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after - // registerAuth so any /api/admin/* route a widget registers is gated by the same - // X-Api-Key preHandler automatically. - for (const plugin of loadedWidgets.values()) { - plugin.registerPublicRoutes?.(app); - plugin.registerAdminRoutes?.(app); - } - - // Fastify's own logger is off (see below) — without this, an unhandled exception - // in any route handler produces a bare 500 with zero trace anywhere, including the - // admin panel's own Logs tab. This is what "Save failed" with no log entry was. - app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { - logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); - reply.code(err.statusCode ?? 500).send({ error: err.message }); - }); - - // Locally hosted media (see storage/media) — served directly rather than via a - // heavier static-file plugin, since this is a small, flat directory. - app.get('/media/:filename', async (req, reply) => { - const { filename } = req.params as { filename: string }; - if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); - const filePath = path.join(MEDIA_DIR, filename); - if (!fs.existsSync(filePath)) return reply.code(404).send(); - return reply.send(fs.createReadStream(filePath)); - }); - - // A widget's optional pre-built frontend bundle (see widgets/manifest.ts's - // frontendEntry) — one generic wildcard route rather than one per widget, so it works - // for a widget uploaded after this process started, with no restart (unlike a - // widget's own custom API routes, which do need one — see widgets/install.ts). - // Explicit Content-Type is required here (unlike /media/:filename above) — browsers - // reject a dynamically-imported module whose response isn't served as a JS MIME - // type. The wildcard also lets a bundle's own relative imports (e.g. `import - // './helper.mjs'`) resolve automatically, since the browser requests those against - // this same route. - app.get('/widget-assets/:id/*', async (req, reply) => { - const { id } = req.params as { id: string }; - const rel = (req.params as { '*': string })['*']; - if (rel.includes('..')) return reply.code(400).send(); - const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel); - if (!fs.existsSync(filePath)) return reply.code(404).send(); - if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript'); - else if (rel.endsWith('.css')) reply.type('text/css'); - return reply.send(fs.createReadStream(filePath)); - }); - - // Static "/media/proxy" and "/media/telegram-proxy" take priority over the - // "/media/:filename" param route above regardless of registration order - // (find-my-way, Fastify's router, always prefers a static segment over a parametric - // one at the same depth). - await registerMediaProxy(app); - await registerTelegramMediaProxy(app); - - app.get('/health', async () => ({ ok: true })); - - await app.listen({ port: PORT, host: '0.0.0.0' }); - logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`); + await reloadServerRoutes(); if (!privateAccessConfigured()) { logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable'); } diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3b01a44 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import cookie from '@fastify/cookie'; +import fs from 'node:fs'; +import path from 'node:path'; +import { registerAuth } from './api/auth.js'; +import { registerPublicRoutes } from './api/public.js'; +import { registerAdminRoutes } from './api/admin.js'; +import { registerMediaProxy } from './api/mediaProxy.js'; +import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; +import { registerPrivateAccess } from './api/privateAccess.js'; +import { loadedWidgets } from './widgets/registry.js'; +import { logger } from './storage/db/logs.js'; + +const PORT = Number(process.env.PORT) || 4000; +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; +const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; + +let currentApp: FastifyInstance | null = null; + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + // Cross-origin is expected — see project-structure.md "Cross-origin and security + // implications". Not a wildcard: only the configured frontend origin is allowed. + // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, + // every PATCH (settings saves) and DELETE (removing sources/events) gets silently + // blocked by the browser at the CORS preflight stage, before the request ever + // reaches a route handler. + // credentials: true is required for the browser to send/accept the private-category + // login cookie cross-origin — safe only because origin is a specific value above, + // never a wildcard (the two are mutually exclusive per the CORS spec anyway). + await app.register(cors, { + origin: FRONTEND_ORIGIN, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] + }); + + await app.register(cookie); + + // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty + // when content-type is set to 'application/json'" for any bodyless request (DELETE, + // or POST with no payload) that still carries a Content-Type header — exactly what + // browsers' fetch() does when a client sets that header unconditionally. An empty + // body is just as valid as `{}` for routes that don't read req.body at all. + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + if (typeof body !== 'string' || body.trim() === '') return done(null, {}); + try { + done(null, JSON.parse(body)); + } catch (err) { + done(err as Error, undefined); + } + }); + + await registerAuth(app); + await registerPublicRoutes(app); + await registerAdminRoutes(app); + await registerPrivateAccess(app); + + // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its + // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after + // registerAuth so any /api/admin/* route a widget registers is gated by the same + // X-Api-Key preHandler automatically. + for (const plugin of loadedWidgets.values()) { + plugin.registerPublicRoutes?.(app); + plugin.registerAdminRoutes?.(app); + } + + // Fastify's own logger is off (see below) — without this, an unhandled exception + // in any route handler produces a bare 500 with zero trace anywhere, including the + // admin panel's own Logs tab. This is what "Save failed" with no log entry was. + app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { + logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); + reply.code(err.statusCode ?? 500).send({ error: err.message }); + }); + + // Locally hosted media (see storage/media) — served directly rather than via a + // heavier static-file plugin, since this is a small, flat directory. + app.get('/media/:filename', async (req, reply) => { + const { filename } = req.params as { filename: string }; + if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); + const filePath = path.join(MEDIA_DIR, filename); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + return reply.send(fs.createReadStream(filePath)); + }); + + // A widget's optional pre-built frontend bundle (see widgets/manifest.ts's + // frontendEntry) — one generic wildcard route rather than one per widget, so it works + // for a widget uploaded after this process started, with no restart (unlike a + // widget's own custom API routes, which need reloadServerRoutes() below to activate). + // Explicit Content-Type is required here (unlike /media/:filename above) — browsers + // reject a dynamically-imported module whose response isn't served as a JS MIME + // type. The wildcard also lets a bundle's own relative imports (e.g. `import + // './helper.mjs'`) resolve automatically, since the browser requests those against + // this same route. + app.get('/widget-assets/:id/*', async (req, reply) => { + const { id } = req.params as { id: string }; + const rel = (req.params as { '*': string })['*']; + if (rel.includes('..')) return reply.code(400).send(); + const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript'); + else if (rel.endsWith('.css')) reply.type('text/css'); + return reply.send(fs.createReadStream(filePath)); + }); + + // Static "/media/proxy" and "/media/telegram-proxy" take priority over the + // "/media/:filename" param route above regardless of registration order + // (find-my-way, Fastify's router, always prefers a static segment over a parametric + // one at the same depth). + await registerMediaProxy(app); + await registerTelegramMediaProxy(app); + + app.get('/health', async () => ({ ok: true })); + + return app; +} + +/** + * Builds a fresh Fastify instance with every currently loaded widget's routes and swaps + * it in for the running one — the only way to pick up a route a live-uploaded widget + * declares, since Fastify refuses to add routes to an already-listening instance (throws + * "instance is already listening" synchronously). Called once at process startup (with no + * previous instance to close) and again after any widget install/uninstall that changes + * the route set (see widgets/install.ts, uninstall.ts). + * + * Deliberately reuses everything else already live in this process — the DB connection, + * the in-memory widget registry, the scheduler's setInterval loops, the Telegram client's + * session, and the admin API key all stay untouched. Only the HTTP server + its router are + * rebuilt, which is what makes this meaningfully better than a full process restart: none + * of that state is lost, and in particular the admin API key (regenerated only on true + * process start) stays valid, so installing a widget never logs the admin out. + * + * Split into two steps rather than one, because of a real deadlock/dropped-response bug + * hit in testing: the install/delete admin routes that trigger a reload are themselves + * served BY the live app instance. Awaiting the full swap (which closes that very instance) + * from inside its own still-executing request handler closed the connection before the + * response could be flushed — the client saw a bare connection reset, not a 204/201. + * + * validateRoutesBuildable() never touches the live server at all (builds on an OS-assigned + * ephemeral port and closes it again), so it's safe to await synchronously inside a + * request handler — that's what lets a broken widget's install be rejected/rolled back in + * the same response. swapLiveServer() is the part that actually closes the current + * instance; callers that are themselves inside a request handler for the live instance + * MUST defer this past sending their response (e.g. via setImmediate — see + * api/admin.ts's widget install/delete routes). Boot-time startup (see index.ts) has no + * in-flight request to worry about, so it just awaits both in sequence via + * reloadServerRoutes() below. + */ +export async function validateRoutesBuildable(): Promise { + const candidate = await buildApp(); + await candidate.listen({ port: 0, host: '127.0.0.1' }); + await candidate.close(); +} + +export async function swapLiveServer(): Promise { + const oldApp = currentApp; + // The new instance binds the same fixed PORT the old one holds, so the old one has to + // let go of it first — there's a brief window (typically well under a second) where + // nothing is listening on PORT. Acceptable for a self-hosted single-admin tool where + // this only fires right after an admin's own widget install/delete action. + if (oldApp) await oldApp.close(); + const newApp = await buildApp(); + await newApp.listen({ port: PORT, host: '0.0.0.0' }); + currentApp = newApp; + + logger.info( + 'server', + `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN}) — ${loadedWidgets.size} widget(s) registered` + ); +} + +/** Boot-time convenience — validate then swap in one call. Only safe when there's no in-flight request being served by the instance being replaced (i.e. process startup). */ +export async function reloadServerRoutes(): Promise { + await validateRoutesBuildable(); + await swapLiveServer(); +} diff --git a/backend/src/widgets/install.ts b/backend/src/widgets/install.ts index 789e4f5..11b2a1a 100644 --- a/backend/src/widgets/install.ts +++ b/backend/src/widgets/install.ts @@ -1,25 +1,36 @@ import fs from 'node:fs'; import path from 'node:path'; import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; -import { loadUploadedWidget } from './registry.js'; +import { loadUploadedWidget, loadedWidgets } from './registry.js'; import { startWidgetPolling } from '../queue/scheduler.js'; import { validateManifest, type WidgetManifest } from './manifest.js'; +import { validateRoutesBuildable } from '../server.js'; +import { logger } from '../storage/db/logs.js'; const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; -export type InstallResult = { ok: true; id: string } | { ok: false; error: string }; +export type InstallResult = + | { ok: true; id: string; needsServerSwap: boolean } + | { ok: false; error: string }; // Installs and hot-loads a widget uploaded live to the running backend (see // api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never // dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate() -// runs and its poll interval (if declared) starts immediately, with no restart -// required. NOTE: its registerPublicRoutes/registerAdminRoutes, if declared, do NOT -// take effect until the next restart — Fastify throws "instance is already -// listening" if you try to add a route after app.listen() has resolved, and there's -// no supported way around that short of a much larger request-dispatch redesign. A -// live-installed widget's data/poll side works immediately; its custom HTTP routes -// don't until the process restarts (see widgets/registry.ts's startup discovery, -// which re-registers everything, routes included, on every boot). +// runs and its poll interval (if declared) starts immediately. If it declares +// registerPublicRoutes/registerAdminRoutes, `needsServerSwap` comes back true — the +// caller (api/admin.ts's POST route) must call server.ts's swapLiveServer() itself, +// AFTER sending its own response, never inline here: this function runs inside the +// very request handler whose underlying Fastify instance a swap would close, so +// awaiting the swap here would drop the response before the client ever sees it (hit +// this for real in testing). validateRoutesBuildable() is safe to await here — it +// never touches the live server, only a throwaway instance on an ephemeral port — so +// a widget whose routes are actually broken (e.g. a path collision) is still caught +// and rolled back within this same call, before anything user-visible commits. +// +// Either way this is a full HTTP-server rebuild within the running process, not a +// process restart — the DB connection, scheduler intervals, Telegram session, and +// (critically) the admin API key all survive; a process restart would regenerate the +// key and log the admin out. export async function installUploadedWidget(manifest: unknown, files: unknown): Promise { const validationError = validateManifest(manifest, files); if (validationError) return { ok: false, error: validationError }; @@ -55,6 +66,23 @@ export async function installUploadedWidget(manifest: unknown, files: unknown): frontendEntry: m.frontendEntry ?? null }); + const needsServerSwap = !!(plugin.registerPublicRoutes || plugin.registerAdminRoutes); + if (needsServerSwap) { + try { + await validateRoutesBuildable(); + } catch (err) { + // A widget whose routes break Fastify's registration (e.g. a path collision) + // isn't a successful install — the site itself was never at risk since this + // only ever touched a throwaway ephemeral-port instance, but this widget still + // needs to be fully rolled back rather than left half-installed. + logger.error('widgets', `Install of "${m.id}" rolled back — its routes failed to register: ${(err as Error).message}`); + loadedWidgets.delete(m.id); + installedWidgetsDb.deleteInstalled(m.id); + fs.rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: `widget's routes failed to register: ${(err as Error).message}` }; + } + } + startWidgetPolling(plugin); - return { ok: true, id: m.id }; + return { ok: true, id: m.id, needsServerSwap }; } diff --git a/backend/src/widgets/uninstall.ts b/backend/src/widgets/uninstall.ts index 46f5b1a..6d6426e 100644 --- a/backend/src/widgets/uninstall.ts +++ b/backend/src/widgets/uninstall.ts @@ -15,10 +15,18 @@ const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data'; // guarantee, matching every table/kv row/on-disk file regardless of the widget's own // cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are // responsible for rejecting built-in widgets before calling this. -export async function uninstallWidget(id: string): Promise { +// +// Returns whether the deleted widget had declared routes, i.e. whether the caller needs +// to swap the live server afterward (see server.ts's swapLiveServer()) — deliberately NOT +// done inline here: this runs inside the DELETE route's own request handler, and that +// handler is served by the very Fastify instance a swap would close, which drops the +// response before the client ever sees it (hit this for real in testing). The caller must +// send its response first, then swap — see api/admin.ts. +export async function uninstallWidget(id: string): Promise { stopWidgetPolling(id); const plugin = loadedWidgets.get(id); + const hadRoutes = !!(plugin?.registerPublicRoutes || plugin?.registerAdminRoutes); if (plugin?.uninstall) { try { plugin.uninstall(db); @@ -35,4 +43,6 @@ export async function uninstallWidget(id: string): Promise { loadedWidgets.delete(id); installedWidgetsDb.deleteInstalled(id); + + return hadRoutes; }