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:
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user