Hot-swap Fastify routes on widget install/delete instead of restarting the process

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-27 01:42:55 +00:00
parent 837aa77bfc
commit b1557d2368
5 changed files with 252 additions and 130 deletions
+17 -3
View File
@@ -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 ---
+7 -115
View File
@@ -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');
}
+178
View File
@@ -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<FastifyInstance> {
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<void> {
const candidate = await buildApp();
await candidate.listen({ port: 0, host: '127.0.0.1' });
await candidate.close();
}
export async function swapLiveServer(): Promise<void> {
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<void> {
await validateRoutesBuildable();
await swapLiveServer();
}
+39 -11
View File
@@ -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<InstallResult> {
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 };
}
+11 -1
View File
@@ -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<void> {
//
// 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<boolean> {
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<void> {
loadedWidgets.delete(id);
installedWidgetsDb.deleteInstalled(id);
return hadRoutes;
}