-
Notifications
You must be signed in to change notification settings - Fork 10
feat(deploy): live SSE progress for deploy_component #531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 3 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
bf19a2a
feat(deploy): live SSE progress for deploy_component
b573165
Merge feat/deploy-component-multipart into feat/deploy-component-prog…
127f14e
Merge remote-tracking branch 'origin/feat/deploy-component-multipart'…
kriszyp b5177e0
Merge remote-tracking branch 'origin/feat/deploy-component-multipart'…
kriszyp 1f69610
fix(deploy): strip req.progress before replicating; guard handler's h…
bd61561
feat(deploy): real upload progress bar + live npm install output
a320af5
fix: drain SSE response stream when server returns non-SSE on SSE path
kriszyp 31c4098
fix(sse): StringDecoder for split UTF-8, cleanup on client disconnect…
kriszyp d9740e8
style: prettier format cliOperations errMsg line
kriszyp b70bd41
fix(sse): split multi-line data into per-line data: fields per spec
kriszyp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import type { Readable } from 'node:stream'; | ||
|
kriszyp marked this conversation as resolved.
|
||
|
|
||
| export interface SSEMessage { | ||
| event: string; | ||
| data: string; | ||
| id?: string; | ||
| retry?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Parse a Readable carrying Server-Sent Events into structured messages. | ||
| * | ||
| * Yields one `SSEMessage` per blank-line-terminated record. Handles split data: lines, | ||
| * CRLF or LF line endings, and arbitrary chunk boundaries — the underlying Node http | ||
| * Readable does not guarantee chunks align with SSE record boundaries. | ||
| */ | ||
| export async function* parseSSE(stream: Readable): AsyncGenerator<SSEMessage> { | ||
| let buffer = ''; | ||
| for await (const chunk of stream) { | ||
| buffer += chunk.toString('utf8'); | ||
|
kriszyp marked this conversation as resolved.
Outdated
kriszyp marked this conversation as resolved.
Outdated
|
||
| while (true) { | ||
| const recordEnd = buffer.indexOf('\n\n'); | ||
| const crlfEnd = buffer.indexOf('\r\n\r\n'); | ||
| let endIdx = -1; | ||
| let delimLen = 0; | ||
| if (recordEnd !== -1 && (crlfEnd === -1 || recordEnd < crlfEnd)) { | ||
| endIdx = recordEnd; | ||
| delimLen = 2; | ||
| } else if (crlfEnd !== -1) { | ||
| endIdx = crlfEnd; | ||
| delimLen = 4; | ||
| } | ||
| if (endIdx === -1) break; | ||
| const record = buffer.slice(0, endIdx); | ||
| buffer = buffer.slice(endIdx + delimLen); | ||
| const msg = parseRecord(record); | ||
| if (msg) yield msg; | ||
| } | ||
| } | ||
| // Any trailing record without a terminating blank line is treated as a final message, | ||
| // matching the looser behavior browsers exhibit on connection close. | ||
| if (buffer.trim()) { | ||
|
kriszyp marked this conversation as resolved.
|
||
| const msg = parseRecord(buffer); | ||
| if (msg) yield msg; | ||
| } | ||
| } | ||
|
|
||
| function parseRecord(record: string): SSEMessage | null { | ||
| const lines = record.split(/\r?\n/); | ||
| let event = 'message'; | ||
| let id: string | undefined; | ||
| let retry: number | undefined; | ||
| const dataLines: string[] = []; | ||
| for (const line of lines) { | ||
| if (line === '' || line.startsWith(':')) continue; | ||
| const colon = line.indexOf(':'); | ||
| const field = colon === -1 ? line : line.slice(0, colon); | ||
| // Per spec, a leading space after the colon is stripped. | ||
| let value = colon === -1 ? '' : line.slice(colon + 1); | ||
| if (value.startsWith(' ')) value = value.slice(1); | ||
| switch (field) { | ||
| case 'event': | ||
| event = value; | ||
| break; | ||
| case 'data': | ||
| dataLines.push(value); | ||
| break; | ||
| case 'id': | ||
| id = value; | ||
| break; | ||
| case 'retry': { | ||
| const n = Number(value); | ||
| if (Number.isFinite(n)) retry = n; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (dataLines.length === 0 && event === 'message') return null; | ||
| return { event, data: dataLines.join('\n'), id, retry }; | ||
| } | ||
|
|
||
| interface RenderState { | ||
| currentPhase?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Render SSE deploy events as terse, line-oriented progress to stderr (so stdout stays | ||
| * reserved for the final JSON/YAML response document). Phase transitions print once. | ||
| */ | ||
| export function renderDeployProgress(message: SSEMessage, state: RenderState, output: NodeJS.WritableStream): void { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(message.data); | ||
| } catch { | ||
| parsed = message.data; | ||
| } | ||
| switch (message.event) { | ||
| case 'phase': { | ||
| const p = parsed as { phase?: string; status?: string; rolling?: boolean }; | ||
| const label = p.phase ?? '?'; | ||
| if (p.status === 'start') { | ||
| if (state.currentPhase !== label) { | ||
| output.write(`${label}…\n`); | ||
| state.currentPhase = label; | ||
| } | ||
| } else if (p.status === 'done') { | ||
| output.write(`${label} done\n`); | ||
| } else if (p.status === 'error') { | ||
| const msg = (parsed as { message?: string }).message ?? 'failed'; | ||
| output.write(`${label} ERROR: ${msg}\n`); | ||
| } | ||
| break; | ||
| } | ||
| case 'error': { | ||
| const e = parsed as { message?: string; code?: string | number }; | ||
| output.write(`error: ${e.message ?? message.data}${e.code ? ` (${e.code})` : ''}\n`); | ||
| break; | ||
| } | ||
| case 'done': | ||
| // Caller picks up the final result via the SSE iterator; nothing to render here. | ||
| break; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { PassThrough, Readable } from 'node:stream'; | ||
|
|
||
| export interface ProgressEvent { | ||
| event: string; | ||
| data: unknown; | ||
| } | ||
|
|
||
| export type ProgressListener = (event: ProgressEvent) => void; | ||
|
|
||
| /** | ||
| * Lightweight pub-sub used to report phase/install/replicate events from a long-running | ||
| * operation back to the HTTP layer. We deliberately don't use Node's EventEmitter here: | ||
| * we only need broadcast semantics for a small set of event types, and we want the | ||
| * `emit(event, data)` shape that matches the SSE wire format directly. | ||
| */ | ||
| export class ProgressEmitter { | ||
| private listeners: ProgressListener[] = []; | ||
|
|
||
| emit(event: string, data: unknown): void { | ||
| // Snapshot before iteration so a listener that unsubscribes itself during dispatch | ||
| // doesn't shift indexes underneath us. | ||
| const snapshot = this.listeners.slice(); | ||
| for (const listener of snapshot) { | ||
| try { | ||
| listener({ event, data }); | ||
| } catch { | ||
| // A buggy listener must never break the operation. Swallow and continue. | ||
| } | ||
| } | ||
| } | ||
|
|
||
| subscribe(listener: ProgressListener): () => void { | ||
| this.listeners.push(listener); | ||
| return () => { | ||
| const i = this.listeners.indexOf(listener); | ||
| if (i !== -1) this.listeners.splice(i, 1); | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Wrap a long-running operation so its progress events stream back as Server-Sent Events. | ||
| * | ||
| * The returned Readable emits one SSE message per `emitter.emit(...)` call, then a final | ||
| * `done` (or `error`) event with the operation's result, then ends. The caller is | ||
| * expected to set Content-Type: text/event-stream on the response. | ||
| */ | ||
| export function createSSEResponseStream(emitter: ProgressEmitter, operation: () => Promise<unknown>): Readable { | ||
| const stream = new PassThrough(); | ||
|
|
||
| const unsubscribe = emitter.subscribe((event) => { | ||
| writeSSE(stream, event); | ||
| }); | ||
|
|
||
| operation() | ||
|
kriszyp marked this conversation as resolved.
|
||
| .then((result) => { | ||
| writeSSE(stream, { event: 'done', data: { result } }); | ||
| }) | ||
| .catch((err) => { | ||
| writeSSE(stream, { | ||
| event: 'error', | ||
| data: { | ||
| message: err?.message ?? String(err), | ||
| code: err?.statusCode ?? err?.code, | ||
| }, | ||
| }); | ||
| }) | ||
| .finally(() => { | ||
| unsubscribe(); | ||
| stream.end(); | ||
| }); | ||
|
|
||
| return stream; | ||
| } | ||
|
|
||
| function writeSSE(stream: PassThrough, event: ProgressEvent): void { | ||
| const data = typeof event.data === 'string' ? event.data : JSON.stringify(event.data); | ||
| stream.write(`event: ${event.event}\ndata: ${data}\n\n`); | ||
|
kriszyp marked this conversation as resolved.
Outdated
|
||
| } | ||
|
kriszyp marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.