diff --git a/bff/src/routes/lifecycle.ts b/bff/src/routes/lifecycle.ts index 30b4441..d090af5 100644 --- a/bff/src/routes/lifecycle.ts +++ b/bff/src/routes/lifecycle.ts @@ -75,11 +75,19 @@ function sendSSE( 'X-Accel-Buffering': 'no', }); + let closed = false; + const heartbeat = setInterval(() => { - res.write(': keepalive\n\n'); + if (!closed) res.write(': keepalive\n\n'); }, 15_000); + res.on('close', () => { + closed = true; + clearInterval(heartbeat); + }); + const onProgress: LifecycleProgressCallback = (steps) => { + if (closed) return; const data = JSON.stringify({ steps: steps.map(s => ({ ...s })) }); res.write(`event: progress\ndata: ${data}\n\n`); }; @@ -87,11 +95,13 @@ function sendSSE( serviceFn(onProgress) .then((result) => { clearInterval(heartbeat); + if (closed) return; res.write(`event: complete\ndata: ${JSON.stringify(result)}\n\n`); res.end(); }) .catch(() => { clearInterval(heartbeat); + if (closed) return; const fallback: LifecycleResponse = { success: false, message: 'Operation failed', diff --git a/src/app/hooks/usePluginLifecycle.ts b/src/app/hooks/usePluginLifecycle.ts index 3bde1e8..0273548 100644 --- a/src/app/hooks/usePluginLifecycle.ts +++ b/src/app/hooks/usePluginLifecycle.ts @@ -3,6 +3,16 @@ import { LifecycleResponse, LifecycleStep, LifecycleOperation, LifecycleProgress const API_BASE = '/community-plugins-admin/api/plugins'; +class StreamInterruptedError extends Error { + constructor() { + super( + 'Connection lost during operation. The operation may still be running. ' + + 'Close this dialog and refresh to check the current status.', + ); + this.name = 'StreamInterruptedError'; + } +} + export interface PluginLifecycleState { loading: boolean; operation: LifecycleOperation | null; @@ -54,7 +64,13 @@ async function lifecycleStreamRequest( let finalResult: LifecycleResponse | null = null; for (;;) { - const { done, value } = await reader.read(); + let done: boolean; + let value: string | undefined; + try { + ({ done, value } = await reader.read()); + } catch { + throw new StreamInterruptedError(); + } if (done) break; buffer += value; @@ -87,7 +103,7 @@ async function lifecycleStreamRequest( } if (!finalResult) { - throw new Error('Stream ended without a complete event'); + throw new StreamInterruptedError(); } return finalResult; @@ -124,7 +140,16 @@ export function usePluginLifecycle(): PluginLifecycle { message, steps: [], }; - setState({ loading: false, operation, steps: [], result: failedResult, error: message }); + setState((prev) => ({ + loading: false, + operation, + steps: err instanceof StreamInterruptedError ? prev.steps : [], + result: { + ...failedResult, + steps: err instanceof StreamInterruptedError ? prev.steps : [], + }, + error: message, + })); return failedResult; } },