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.
This commit is contained in:
2026-03-27 22:30:25 +03:00
parent d40cf10f88
commit 5558396bb7
16 changed files with 844 additions and 73 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* 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
});
}
+70
View File
@@ -0,0 +1,70 @@
import { writable, get } from 'svelte/store';
import type { InstanceStatusPayload, DeployStatusPayload } from '$lib/sse';
/**
* Global store for real-time instance status updates received via SSE.
*
* Components can subscribe to this store to reactively update when
* instance statuses change without polling.
*/
interface InstanceStatusState {
/** Map of instance ID to latest status. */
statuses: Record<string, string>;
/** Timestamp of last update, useful for triggering reactive refreshes. */
lastUpdate: number;
/** Latest deploy status events, keyed by deploy ID. */
deployStatuses: Record<string, DeployStatusPayload>;
}
function createInstanceStatusStore() {
const { subscribe, set, update: storeUpdate } = writable<InstanceStatusState>({
statuses: {},
lastUpdate: 0,
deployStatuses: {}
});
return {
subscribe,
/** Update an instance's status from an SSE event. */
update(payload: InstanceStatusPayload) {
storeUpdate((state) => ({
...state,
statuses: {
...state.statuses,
[payload.instance_id]: payload.status
},
lastUpdate: Date.now()
}));
},
/** Record a deploy status change from an SSE event. */
notifyDeploy(payload: DeployStatusPayload) {
storeUpdate((state) => ({
...state,
deployStatuses: {
...state.deployStatuses,
[payload.deploy_id]: payload
},
lastUpdate: Date.now()
}));
},
/** Get the current status of an instance, or undefined if not tracked. */
getStatus(instanceId: string): string | undefined {
return get({ subscribe }).statuses[instanceId];
},
/** Reset the store (e.g., on disconnect). */
reset() {
set({
statuses: {},
lastUpdate: 0,
deployStatuses: {}
});
}
};
}
export const instanceStatusStore = createInstanceStatusStore();