stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,13 @@ import * as errors from '../../custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { mountPrefix } from '../../plugins/manifest';
|
||||
import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { installPlugin, pluginProcessStatus, setPluginRunning, uninstallPlugin } from '../../plugins/install';
|
||||
import {
|
||||
installPlugin,
|
||||
pluginProcessStatus,
|
||||
setPluginRunning,
|
||||
uninstallPlugin,
|
||||
type PluginActionResult,
|
||||
} from '../../plugins/install';
|
||||
|
||||
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
||||
//
|
||||
@@ -90,3 +96,70 @@ pluginsRouter.post('/:appName/disable', async (ctx) => {
|
||||
const result = await setPluginRunning(ctx.req.param('appName'), false);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
// ── The same four verbs, streamed ──
|
||||
//
|
||||
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
|
||||
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
|
||||
// not". Streaming each step as it completes turns the same work into something you can watch, and — more
|
||||
// usefully — leaves the log on screen naming the step that failed.
|
||||
//
|
||||
// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these
|
||||
// routes are owner-only. The client reads the body and parses frames itself, which is exactly what
|
||||
// `useCompanionLogStream` already does for the headscale container logs.
|
||||
|
||||
const VERBS = {
|
||||
install: (appName: string, onStep: (s: string) => Promise<void>) => installPlugin(appName, onStep),
|
||||
uninstall: (appName: string, onStep: (s: string) => Promise<void>) => uninstallPlugin(appName, onStep),
|
||||
enable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, true, onStep),
|
||||
disable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, false, onStep),
|
||||
} as const;
|
||||
|
||||
type Verb = keyof typeof VERBS;
|
||||
const isVerb = (v: string): v is Verb => v in VERBS;
|
||||
|
||||
pluginsRouter.post('/:appName/:verb/stream', async (ctx) => {
|
||||
const appName = ctx.req.param('appName');
|
||||
const verb = ctx.req.param('verb');
|
||||
if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
// The client may have navigated away mid-install. The work continues — it is the server's job
|
||||
// now — but writing to a closed stream would throw and abort it halfway.
|
||||
try {
|
||||
controller.enqueue(frame(event, data));
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
};
|
||||
|
||||
let result: PluginActionResult;
|
||||
try {
|
||||
result = await VERBS[verb](appName, async (step) => send('step', { step }));
|
||||
} catch (err) {
|
||||
result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
send('done', result);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
// Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until
|
||||
// the response ends, which is precisely the behaviour this endpoint exists to avoid.
|
||||
'x-accel-buffering': 'no',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user