Files
tiny-forge/web/src/lib/sse.ts
T
alexei.dolgolyov 5558396bb7 feat(docker-watcher): phase 11 - frontend embed & SSE
Embed SvelteKit static build in Go binary via go:embed. Event bus
for pub/sub with deploy log, instance status, and deploy status events.
SSE endpoints for real-time streaming. Frontend SSE client with
exponential backoff reconnection. Makefile for build pipeline.
Update Phase 12 auth plan with OAuth2/OIDC support.
2026-03-27 22:30:25 +03:00

194 lines
5.1 KiB
TypeScript

/**
* SSE client helper with auto-reconnect and exponential backoff.
*
* Provides type-safe event handling for Docker Watcher's real-time
* event streams (deploy logs and instance status changes).
*/
// ── Types ──────────────────────────────────────────────────────────
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status';
export interface SSEEvent<T = unknown> {
type: SSEEventType;
payload: T;
}
export interface DeployLogPayload {
deploy_id: string;
message: string;
level: 'info' | 'warn' | 'error';
}
export interface InstanceStatusPayload {
instance_id: string;
project_id: string;
stage_id: string;
status: string;
}
export interface DeployStatusPayload {
deploy_id: string;
project_id: string;
stage_id: string;
image_tag: string;
status: string;
error?: string;
}
type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload;
export interface SSEOptions {
/** Called for each SSE event received. */
onEvent: (event: SSEEvent<SSEPayload>) => void;
/** Called when the connection is established. */
onOpen?: () => void;
/** Called when the connection is lost. Receives the retry attempt number. */
onError?: (attempt: number) => void;
/** Called when reconnection is given up (max retries exceeded). */
onGiveUp?: () => void;
/** Maximum number of reconnect attempts. 0 = infinite. Default: 0 */
maxRetries?: number;
/** Initial backoff delay in ms. Default: 1000 */
initialDelay?: number;
/** Maximum backoff delay in ms. Default: 30000 */
maxDelay?: number;
}
// ── SSE Connection ─────────────────────────────────────────────────
export interface SSEConnection {
/** Close the connection and stop reconnecting. */
close: () => void;
}
/**
* Creates an SSE connection to the given URL with auto-reconnect.
*
* Uses exponential backoff with jitter for reconnection.
* Returns an object with a `close` method to cleanly shut down.
*/
export function connectSSE(url: string, options: SSEOptions): SSEConnection {
const {
onEvent,
onOpen,
onError,
onGiveUp,
maxRetries = 0,
initialDelay = 1000,
maxDelay = 30000
} = options;
let eventSource: EventSource | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let closed = false;
function connect(): void {
if (closed) return;
eventSource = new EventSource(url);
eventSource.onopen = () => {
retryCount = 0;
onOpen?.();
};
eventSource.onmessage = (messageEvent: MessageEvent) => {
try {
const parsed: SSEEvent<SSEPayload> = JSON.parse(messageEvent.data);
onEvent(parsed);
} catch {
// Ignore malformed events.
}
};
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
if (closed) return;
retryCount++;
onError?.(retryCount);
if (maxRetries > 0 && retryCount > maxRetries) {
onGiveUp?.();
return;
}
// Exponential backoff with jitter.
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), maxDelay);
const jitter = delay * 0.2 * Math.random();
const totalDelay = delay + jitter;
retryTimeout = setTimeout(connect, totalDelay);
};
}
connect();
return {
close() {
closed = true;
if (retryTimeout !== null) {
clearTimeout(retryTimeout);
retryTimeout = null;
}
eventSource?.close();
eventSource = null;
}
};
}
// ── Convenience Factories ──────────────────────────────────────────
/**
* Connect to deploy log SSE stream for a specific deploy.
* Streams existing logs first, then real-time updates.
*/
export function connectDeployLogs(
deployId: string,
callbacks: {
onLog: (log: DeployLogPayload) => void;
onStatus?: (status: DeployStatusPayload) => void;
onOpen?: () => void;
onError?: (attempt: number) => void;
}
): SSEConnection {
return connectSSE(`/api/deploys/${deployId}/logs`, {
onEvent(event) {
if (event.type === 'deploy_log') {
callbacks.onLog(event.payload as DeployLogPayload);
} else if (event.type === 'deploy_status') {
callbacks.onStatus?.(event.payload as DeployStatusPayload);
}
},
onOpen: callbacks.onOpen,
onError: callbacks.onError
});
}
/**
* Connect to the global events SSE stream.
* Receives instance status changes and deploy status updates.
*/
export function connectGlobalEvents(callbacks: {
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
onDeployStatus?: (payload: DeployStatusPayload) => void;
onOpen?: () => void;
onError?: (attempt: number) => void;
}): SSEConnection {
return connectSSE('/api/events', {
onEvent(event) {
if (event.type === 'instance_status') {
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
} else if (event.type === 'deploy_status') {
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
}
},
onOpen: callbacks.onOpen,
onError: callbacks.onError
});
}