feat(desktop): push publish tasks via AMQP topic dispatch WebSocket
Introduce a desktop.task.dispatch topic exchange with per-client routing keys. Tenant-api publishes a task_available frame keyed by target client ID when a publish job is created; every instance binds its own transient queue and forwards to the matching WebSocket (/api/desktop/dispatch) it owns. Desktop-client now prefers this push channel and only falls back to HTTP /lease polling when the socket is down. Also drop admin-web monitoring-plugin remnants and scope the publish modal account list to publish platforms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import WebSocket, { type RawData } from "ws";
|
||||
|
||||
export type DispatchEventHandler = (payload: unknown) => void;
|
||||
|
||||
export interface DispatchWsClientOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
initialRetryDelayMs?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
}
|
||||
|
||||
type DispatchWsState = "idle" | "connecting" | "open";
|
||||
|
||||
/**
|
||||
* DispatchWsClient receives per-client task dispatch messages from the server
|
||||
* over a single authenticated WebSocket. It mirrors {@link SseClient} so the
|
||||
* runtime-controller can treat it as a drop-in replacement for the publish
|
||||
* side of the SSE channel.
|
||||
*
|
||||
* The underlying contract is intentionally narrow:
|
||||
* - The server pushes newline-free JSON frames whose shape matches
|
||||
* `DesktopDispatchEvent` on the Go side.
|
||||
* - The client never sends application frames back; acknowledgement is done
|
||||
* out-of-band via `/tasks/lease`.
|
||||
*
|
||||
* Reconnect uses exponential backoff with jitter. On every fresh TCP
|
||||
* connection the server replays a synthetic "connected" frame so callers can
|
||||
* observe the auth + transport handshake is healthy.
|
||||
*/
|
||||
export class DispatchWsClient {
|
||||
readonly #listeners = new Map<string, Set<DispatchEventHandler>>();
|
||||
readonly #options: DispatchWsClientOptions;
|
||||
#running = false;
|
||||
#socket: WebSocket | null = null;
|
||||
#retryDelayMs: number;
|
||||
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
|
||||
constructor(options: DispatchWsClientOptions) {
|
||||
this.#options = options;
|
||||
this.#retryDelayMs = options.initialRetryDelayMs ?? 1_000;
|
||||
}
|
||||
|
||||
on(event: string, handler: DispatchEventHandler): () => void {
|
||||
const bucket = this.#listeners.get(event) ?? new Set<DispatchEventHandler>();
|
||||
bucket.add(handler);
|
||||
this.#listeners.set(event, bucket);
|
||||
return () => {
|
||||
bucket.delete(handler);
|
||||
if (bucket.size === 0) {
|
||||
this.#listeners.delete(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.#running) {
|
||||
return;
|
||||
}
|
||||
this.#running = true;
|
||||
this.#connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#running = false;
|
||||
this.#clearReconnect();
|
||||
this.#setState("idle");
|
||||
|
||||
const socket = this.#socket;
|
||||
this.#socket = null;
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach every listener we registered so their handlers don't re-fire our
|
||||
// own reconnect / error callbacks during teardown, but keep a noop error
|
||||
// listener around: `ws` emits an asynchronous "error" when a socket still
|
||||
// in the CONNECTING state is closed, and without a listener Node turns it
|
||||
// into an uncaughtException.
|
||||
socket.removeAllListeners("open");
|
||||
socket.removeAllListeners("message");
|
||||
socket.removeAllListeners("close");
|
||||
socket.removeAllListeners("error");
|
||||
socket.on("error", () => {
|
||||
// swallow: teardown path, we no longer care about the socket's fate.
|
||||
});
|
||||
|
||||
try {
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
// `close()` on a CONNECTING socket throws asynchronously; `terminate()`
|
||||
// destroys the underlying TCP connection and is always safe.
|
||||
socket.terminate();
|
||||
} else if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "client_stop");
|
||||
}
|
||||
} catch {
|
||||
// Fallback: ignore; the reference is already detached.
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, payload: unknown): void {
|
||||
for (const handler of this.#listeners.get(event) ?? []) {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch {
|
||||
// Swallow handler errors; listeners must not kill the transport.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#connect(): void {
|
||||
if (!this.#running) {
|
||||
return;
|
||||
}
|
||||
this.#setState("connecting");
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(this.#options.url, {
|
||||
headers: this.#options.headers,
|
||||
handshakeTimeout: 15_000,
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#socket = socket;
|
||||
|
||||
socket.on("open", () => {
|
||||
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000;
|
||||
this.#setState("open");
|
||||
this.emit("open", null);
|
||||
});
|
||||
|
||||
socket.on("message", (data: RawData, isBinary: boolean) => {
|
||||
if (isBinary) {
|
||||
return;
|
||||
}
|
||||
const raw = typeof data === "string" ? data : data.toString("utf-8");
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
this.emit("message", { event: "message", data: raw });
|
||||
return;
|
||||
}
|
||||
const event = extractEventType(parsed) ?? "message";
|
||||
this.emit(event, parsed);
|
||||
this.emit("message", { event, data: parsed });
|
||||
});
|
||||
|
||||
socket.on("error", (error: Error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
socket.on("close", (code: number, reason: Buffer) => {
|
||||
this.#socket = null;
|
||||
this.emit("close", { code, reason: reason.toString() });
|
||||
if (!this.#running) {
|
||||
return;
|
||||
}
|
||||
this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#scheduleReconnect(): void {
|
||||
if (!this.#running) {
|
||||
return;
|
||||
}
|
||||
this.#clearReconnect();
|
||||
|
||||
const baseDelay = this.#retryDelayMs;
|
||||
const maxDelay = this.#options.maxRetryDelayMs ?? 30_000;
|
||||
const jitter = Math.floor(baseDelay * 0.2 * Math.random());
|
||||
const delay = Math.min(baseDelay + jitter, maxDelay);
|
||||
|
||||
this.emit("reconnect", { delayMs: delay });
|
||||
this.#reconnectTimer = globalThis.setTimeout(() => {
|
||||
this.#reconnectTimer = null;
|
||||
this.#connect();
|
||||
}, delay);
|
||||
|
||||
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, maxDelay);
|
||||
}
|
||||
|
||||
#clearReconnect(): void {
|
||||
if (this.#reconnectTimer) {
|
||||
globalThis.clearTimeout(this.#reconnectTimer);
|
||||
this.#reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
#setState(state: DispatchWsState): void {
|
||||
this.emit("state", state);
|
||||
}
|
||||
}
|
||||
|
||||
function extractEventType(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybe = (payload as { type?: unknown }).type;
|
||||
if (typeof maybe === "string" && maybe.length > 0) {
|
||||
return maybe;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user