Skip to content

Commit 676513c

Browse files
Kris Zypclaude
andcommitted
feat(deploy): live SSE progress for deploy_component
When the CLI sends `Accept: text/event-stream` on `deploy_component`, the operations API now returns Server-Sent Events instead of a single buffered response. A ProgressEmitter is attached to the operation request and the handler emits `phase` events at the extract → install → load → replicate → restart boundaries; the stream terminates with a `done` event (carrying the operation result) or an `error` event. The CLI parses the stream live, rendering each phase as it happens so multi-minute deploys no longer look hung. Non-SSE callers see no behavior change — the emitter is undefined on that path and every emission is optional-chained. Builds on #530. First slice of #526. Follow-ups: streaming live npm install stdout/stderr as `install` events, and re-emitting per-peer SSE events once the direct-HTTPS replication relay lands in #524 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b9780eb commit 676513c

9 files changed

Lines changed: 541 additions & 10 deletions

File tree

bin/cliOperations.js

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ const fs = require('fs-extra');
99
const YAML = require('yaml');
1010
const { streamPackagedDirectory } = require('../components/packageComponent.ts');
1111
const { buildMultipartBody } = require('./multipartBuilder.ts');
12+
const { parseSSE, renderDeployProgress } = require('./sseConsumer.ts');
1213
const { getHdbPid } = require('../utility/processManagement/processManagement.js');
1314
const { initConfig, getConfigPath } = require('../config/configUtils.js');
1415

1516
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
1617

18+
// Operations whose responses should be consumed as text/event-stream so live phase events
19+
// (extract, install, load, replicate, restart) render as they happen instead of after the
20+
// whole deploy completes. Add an operation here only after wiring its server-side
21+
// SSE_PROGRESS_OPERATIONS entry — otherwise the server returns the buffered JSON path and
22+
// the SSE parser sees no events.
23+
const SSE_OPERATIONS = new Set(['deploy_component']);
24+
1725
// Properties on `req` that the CLI itself uses for transport/UX, not the operations API.
1826
// They never get serialized into the request body.
1927
const TRANSPORT_ONLY_FIELDS = new Set([
@@ -129,6 +137,11 @@ async function cliOperations(req) {
129137
if (target?.username) {
130138
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
131139
}
140+
const useSse = SSE_OPERATIONS.has(req.operation);
141+
if (useSse) {
142+
options.headers.Accept = 'text/event-stream';
143+
options.streamResponse = true;
144+
}
132145
let body;
133146
if (req._multipart) {
134147
const packageStream = req._packageStream;
@@ -154,13 +167,43 @@ async function cliOperations(req) {
154167
let response = await httpRequest(options, body);
155168

156169
let responseData;
157-
try {
158-
responseData = JSON.parse(response.body);
159-
} catch {
160-
responseData = {
161-
status: response.statusCode + ' ' + (response.statusMessage || 'Unknown'),
162-
body: response.body,
163-
};
170+
if (useSse && response.headers['content-type']?.startsWith('text/event-stream')) {
171+
// Consume SSE: render phase events live, capture the final result from the `done`
172+
// event (or the error message from the `error` event). The HTTP status stays 200
173+
// until end-of-stream; failures are signaled in-band.
174+
const state = {};
175+
let finalResult;
176+
let sseError;
177+
for await (const message of parseSSE(response)) {
178+
renderDeployProgress(message, state, process.stderr);
179+
if (message.event === 'done') {
180+
try {
181+
finalResult = JSON.parse(message.data)?.result;
182+
} catch {
183+
finalResult = message.data;
184+
}
185+
} else if (message.event === 'error') {
186+
try {
187+
sseError = JSON.parse(message.data);
188+
} catch {
189+
sseError = { message: message.data };
190+
}
191+
}
192+
}
193+
if (sseError) {
194+
console.error(`error: ${sseError.message ?? sseError}`);
195+
process.exit(1);
196+
}
197+
responseData = finalResult ?? { message: 'Deploy completed (no result payload).' };
198+
} else {
199+
try {
200+
responseData = JSON.parse(response.body);
201+
} catch {
202+
responseData = {
203+
status: response.statusCode + ' ' + (response.statusMessage || 'Unknown'),
204+
body: response.body,
205+
};
206+
}
164207
}
165208

166209
let responseLog;

bin/sseConsumer.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { Readable } from 'node:stream';
2+
3+
export interface SSEMessage {
4+
event: string;
5+
data: string;
6+
id?: string;
7+
retry?: number;
8+
}
9+
10+
/**
11+
* Parse a Readable carrying Server-Sent Events into structured messages.
12+
*
13+
* Yields one `SSEMessage` per blank-line-terminated record. Handles split data: lines,
14+
* CRLF or LF line endings, and arbitrary chunk boundaries — the underlying Node http
15+
* Readable does not guarantee chunks align with SSE record boundaries.
16+
*/
17+
export async function* parseSSE(stream: Readable): AsyncGenerator<SSEMessage> {
18+
let buffer = '';
19+
for await (const chunk of stream) {
20+
buffer += chunk.toString('utf8');
21+
while (true) {
22+
const recordEnd = buffer.indexOf('\n\n');
23+
const crlfEnd = buffer.indexOf('\r\n\r\n');
24+
let endIdx = -1;
25+
let delimLen = 0;
26+
if (recordEnd !== -1 && (crlfEnd === -1 || recordEnd < crlfEnd)) {
27+
endIdx = recordEnd;
28+
delimLen = 2;
29+
} else if (crlfEnd !== -1) {
30+
endIdx = crlfEnd;
31+
delimLen = 4;
32+
}
33+
if (endIdx === -1) break;
34+
const record = buffer.slice(0, endIdx);
35+
buffer = buffer.slice(endIdx + delimLen);
36+
const msg = parseRecord(record);
37+
if (msg) yield msg;
38+
}
39+
}
40+
// Any trailing record without a terminating blank line is treated as a final message,
41+
// matching the looser behavior browsers exhibit on connection close.
42+
if (buffer.trim()) {
43+
const msg = parseRecord(buffer);
44+
if (msg) yield msg;
45+
}
46+
}
47+
48+
function parseRecord(record: string): SSEMessage | null {
49+
const lines = record.split(/\r?\n/);
50+
let event = 'message';
51+
let id: string | undefined;
52+
let retry: number | undefined;
53+
const dataLines: string[] = [];
54+
for (const line of lines) {
55+
if (line === '' || line.startsWith(':')) continue;
56+
const colon = line.indexOf(':');
57+
const field = colon === -1 ? line : line.slice(0, colon);
58+
// Per spec, a leading space after the colon is stripped.
59+
let value = colon === -1 ? '' : line.slice(colon + 1);
60+
if (value.startsWith(' ')) value = value.slice(1);
61+
switch (field) {
62+
case 'event':
63+
event = value;
64+
break;
65+
case 'data':
66+
dataLines.push(value);
67+
break;
68+
case 'id':
69+
id = value;
70+
break;
71+
case 'retry': {
72+
const n = Number(value);
73+
if (Number.isFinite(n)) retry = n;
74+
break;
75+
}
76+
}
77+
}
78+
if (dataLines.length === 0 && event === 'message') return null;
79+
return { event, data: dataLines.join('\n'), id, retry };
80+
}
81+
82+
interface RenderState {
83+
currentPhase?: string;
84+
}
85+
86+
/**
87+
* Render SSE deploy events as terse, line-oriented progress to stderr (so stdout stays
88+
* reserved for the final JSON/YAML response document). Phase transitions print once.
89+
*/
90+
export function renderDeployProgress(message: SSEMessage, state: RenderState, output: NodeJS.WritableStream): void {
91+
let parsed: unknown;
92+
try {
93+
parsed = JSON.parse(message.data);
94+
} catch {
95+
parsed = message.data;
96+
}
97+
switch (message.event) {
98+
case 'phase': {
99+
const p = parsed as { phase?: string; status?: string; rolling?: boolean };
100+
const label = p.phase ?? '?';
101+
if (p.status === 'start') {
102+
if (state.currentPhase !== label) {
103+
output.write(`${label}…\n`);
104+
state.currentPhase = label;
105+
}
106+
} else if (p.status === 'done') {
107+
output.write(`${label} done\n`);
108+
} else if (p.status === 'error') {
109+
const msg = (parsed as { message?: string }).message ?? 'failed';
110+
output.write(`${label} ERROR: ${msg}\n`);
111+
}
112+
break;
113+
}
114+
case 'error': {
115+
const e = parsed as { message?: string; code?: string | number };
116+
output.write(`error: ${e.message ?? message.data}${e.code ? ` (${e.code})` : ''}\n`);
117+
break;
118+
}
119+
case 'done':
120+
// Caller picks up the final result via the SSE iterator; nothing to render here.
121+
break;
122+
}
123+
}

components/Application.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,10 @@ export class Application {
431431
dirPath: string;
432432
logger: Logger;
433433
packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".
434+
// Optional progress emitter for SSE-style reporting. Set by the operations API when the
435+
// caller requested `Accept: text/event-stream`. Undefined for the historical
436+
// single-response code path; phase-event emissions are all optional-chained off this.
437+
progress?: { emit(event: string, data: unknown): void };
434438

435439
constructor({ name, payload, packageIdentifier, install }: ApplicationOptions) {
436440
this.name = name;
@@ -473,7 +477,14 @@ export function derivePackageIdentifier(packageIdentifier: string) {
473477
* @returns A promise that resolves when all preparation steps complete.
474478
*/
475479
export function prepareApplication(application: Application) {
476-
return extractApplication(application).then(() => installApplication(application));
480+
return extractApplication(application).then(() => {
481+
// extractApplication finished; the next phase is install. We emit the boundary here so
482+
// the SSE consumer sees `extract done → install start` in order even though Application
483+
// itself isn't aware of which phase comes next.
484+
application.progress?.emit('phase', { phase: 'extract', status: 'done' });
485+
application.progress?.emit('phase', { phase: 'install', status: 'start' });
486+
return installApplication(application);
487+
});
477488
}
478489

479490
/**

components/operations.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,11 @@ async function packageComponent(req) {
348348
* @returns {Promise<string>}
349349
*/
350350
async function deployComponent(req) {
351+
// `req.progress` is a ProgressEmitter set by handlePostRequest when the client sends
352+
// `Accept: text/event-stream`. Non-SSE callers leave it undefined; the optional-chained
353+
// calls below are no-ops in that case, keeping the historical single-response path intact.
354+
const progress = req.progress;
355+
351356
if (req.project) {
352357
req.project = path.parse(req.project).name;
353358
} else if (req.package) {
@@ -393,12 +398,21 @@ async function deployComponent(req) {
393398
timeout: req.install_timeout,
394399
},
395400
});
401+
if (progress) application.progress = progress;
396402

397-
await prepareApplication(application);
403+
progress?.emit('phase', { phase: 'extract', status: 'start' });
404+
try {
405+
await prepareApplication(application);
406+
} catch (err) {
407+
progress?.emit('phase', { phase: 'extract_or_install', status: 'error', message: err?.message ?? String(err) });
408+
throw err;
409+
}
410+
progress?.emit('phase', { phase: 'install', status: 'done' });
398411

399412
// now we attempt to actually load the component in case there is
400413
// an error we can immediately detect and report, but app code should not run on the main thread
401414
if (!isMainThread && !process.env.HARPER_SAFE_MODE) {
415+
progress?.emit('phase', { phase: 'load', status: 'start' });
402416
const pseudoResources = new Resources();
403417
pseudoResources.isWorker = true;
404418

@@ -415,16 +429,24 @@ async function deployComponent(req) {
415429
req.project
416430
);
417431

418-
if (lastError) throw lastError;
432+
if (lastError) {
433+
progress?.emit('phase', { phase: 'load', status: 'error', message: lastError?.message ?? String(lastError) });
434+
throw lastError;
435+
}
436+
progress?.emit('phase', { phase: 'load', status: 'done' });
419437
}
420438
const rollingRestart = req.restart === 'rolling';
421439
// if doing a rolling restart set restart to false so that other nodes don't also restart.
422440
req.restart = rollingRestart ? false : req.restart;
441+
progress?.emit('phase', { phase: 'replicate', status: 'start' });
423442
let response = await server.replication.replicateOperation(req);
443+
progress?.emit('phase', { phase: 'replicate', status: 'done' });
424444
if (req.restart === true) {
445+
progress?.emit('phase', { phase: 'restart', status: 'start' });
425446
manageThreads.restartWorkers('http');
426447
response.message = `Successfully deployed: ${application.name}, restarting Harper`;
427448
} else if (rollingRestart) {
449+
progress?.emit('phase', { phase: 'restart', status: 'start', rolling: true });
428450
const serverUtilities = require('../server/serverHelpers/serverUtilities.ts');
429451
const jobResponse = await serverUtilities.executeJob({
430452
operation: 'restart_service',
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { PassThrough, Readable } from 'node:stream';
2+
3+
export interface ProgressEvent {
4+
event: string;
5+
data: unknown;
6+
}
7+
8+
export type ProgressListener = (event: ProgressEvent) => void;
9+
10+
/**
11+
* Lightweight pub-sub used to report phase/install/replicate events from a long-running
12+
* operation back to the HTTP layer. We deliberately don't use Node's EventEmitter here:
13+
* we only need broadcast semantics for a small set of event types, and we want the
14+
* `emit(event, data)` shape that matches the SSE wire format directly.
15+
*/
16+
export class ProgressEmitter {
17+
private listeners: ProgressListener[] = [];
18+
19+
emit(event: string, data: unknown): void {
20+
// Snapshot before iteration so a listener that unsubscribes itself during dispatch
21+
// doesn't shift indexes underneath us.
22+
const snapshot = this.listeners.slice();
23+
for (const listener of snapshot) {
24+
try {
25+
listener({ event, data });
26+
} catch {
27+
// A buggy listener must never break the operation. Swallow and continue.
28+
}
29+
}
30+
}
31+
32+
subscribe(listener: ProgressListener): () => void {
33+
this.listeners.push(listener);
34+
return () => {
35+
const i = this.listeners.indexOf(listener);
36+
if (i !== -1) this.listeners.splice(i, 1);
37+
};
38+
}
39+
}
40+
41+
/**
42+
* Wrap a long-running operation so its progress events stream back as Server-Sent Events.
43+
*
44+
* The returned Readable emits one SSE message per `emitter.emit(...)` call, then a final
45+
* `done` (or `error`) event with the operation's result, then ends. The caller is
46+
* expected to set Content-Type: text/event-stream on the response.
47+
*/
48+
export function createSSEResponseStream(emitter: ProgressEmitter, operation: () => Promise<unknown>): Readable {
49+
const stream = new PassThrough();
50+
51+
const unsubscribe = emitter.subscribe((event) => {
52+
writeSSE(stream, event);
53+
});
54+
55+
operation()
56+
.then((result) => {
57+
writeSSE(stream, { event: 'done', data: { result } });
58+
})
59+
.catch((err) => {
60+
writeSSE(stream, {
61+
event: 'error',
62+
data: {
63+
message: err?.message ?? String(err),
64+
code: err?.statusCode ?? err?.code,
65+
},
66+
});
67+
})
68+
.finally(() => {
69+
unsubscribe();
70+
stream.end();
71+
});
72+
73+
return stream;
74+
}
75+
76+
function writeSSE(stream: PassThrough, event: ProgressEvent): void {
77+
const data = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
78+
stream.write(`event: ${event.event}\ndata: ${data}\n\n`);
79+
}

0 commit comments

Comments
 (0)