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:
@@ -11,7 +11,12 @@ import { articlesApi, mediaApi, publishJobsApi, resolveApiURL, tenantAccountsApi
|
||||
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
|
||||
import { formatDateTime } from "@/lib/display";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import { getPublishPlatformMeta, normalizePublishPlatformId, normalizePublishPlatformIds } from "@/lib/publish-platforms";
|
||||
import {
|
||||
getPublishPlatformMeta,
|
||||
isPublishPlatformId,
|
||||
normalizePublishPlatformId,
|
||||
normalizePublishPlatformIds,
|
||||
} from "@/lib/publish-platforms";
|
||||
|
||||
type PublishState = "immediate" | "queued" | "unavailable";
|
||||
|
||||
@@ -109,8 +114,19 @@ const platformMap = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const publishAccounts = computed(() => {
|
||||
return (accountsQuery.data.value ?? []).filter((account) => {
|
||||
if (account.deleted_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platformId = normalizePublishPlatformId(account.platform);
|
||||
return platformMap.value.has(platformId) || isPublishPlatformId(platformId);
|
||||
});
|
||||
});
|
||||
|
||||
const rawAccountMap = computed(() => {
|
||||
return new Map((accountsQuery.data.value ?? []).map((account) => [account.id, account]));
|
||||
return new Map(publishAccounts.value.map((account) => [account.id, account]));
|
||||
});
|
||||
|
||||
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
|
||||
@@ -118,8 +134,7 @@ const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuer
|
||||
const accountCards = computed<PublishAccountCard[]>(() => {
|
||||
const preferredPlatforms = new Set(articlePlatformIds.value);
|
||||
|
||||
return [...(accountsQuery.data.value ?? [])]
|
||||
.filter((account) => !account.deleted_at)
|
||||
return [...publishAccounts.value]
|
||||
.map((account) => {
|
||||
const platformId = normalizePublishPlatformId(account.platform);
|
||||
const platform = platformMap.value.get(platformId);
|
||||
|
||||
@@ -349,7 +349,7 @@ const zhCN = {
|
||||
collectBrandRequired: "请先选择品牌,再发起采集",
|
||||
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
||||
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
||||
collectClientOnlineRequired: "当前登录账号的 desktop-client 未在线,暂时不能立即采集",
|
||||
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
|
||||
pluginMode: "插件采样",
|
||||
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
|
||||
awaitingSnapshot: "等待下一次有效快照",
|
||||
|
||||
@@ -59,12 +59,7 @@ const errorMessageMap: Record<string, string> = {
|
||||
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
||||
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
||||
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
|
||||
monitoring_plugin_timeout: "监测插件响应超时,请稍后重试",
|
||||
monitoring_plugin_invalid_response: "监测插件返回了无效数据,请稍后重试",
|
||||
monitoring_plugin_unknown_error: "监测插件调用失败,请稍后重试",
|
||||
monitoring_plugin_required: "请先安装并打开浏览器插件",
|
||||
monitoring_plugin_register_required: "浏览器插件正在初始化,请稍后重试",
|
||||
installation_offline: "监测插件离线,请打开浏览器插件后重试",
|
||||
installation_offline: "桌面客户端未在线,请打开桌面客户端后重试",
|
||||
network_error: "网络连接失败,请稍后重试",
|
||||
unknown_error: "发生未知错误",
|
||||
};
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import type {
|
||||
MonitoringCycleKickoffResponse,
|
||||
MonitoringCycleResult,
|
||||
MonitoringHeartbeatSyncResult,
|
||||
MonitoringLocalPlatformState,
|
||||
MonitoringPluginPingResponse,
|
||||
MonitoringRuntimeStateSnapshot,
|
||||
} from "@geo/shared-types";
|
||||
|
||||
type MonitoringAction = "ping" | "detectPlatforms" | "syncHeartbeat" | "runCycle" | "kickoffCycle" | "getState";
|
||||
|
||||
type MonitoringPluginSuccessPayload<T> = {
|
||||
source: "geo-extension";
|
||||
type: "geo.monitoring.response";
|
||||
requestId: string;
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type MonitoringPluginErrorPayload = {
|
||||
source: "geo-extension";
|
||||
type: "geo.monitoring.response";
|
||||
requestId: string;
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type MonitoringPluginPayloadMap = {
|
||||
ping: MonitoringPluginPingResponse;
|
||||
detectPlatforms: MonitoringLocalPlatformState[];
|
||||
syncHeartbeat: MonitoringHeartbeatSyncResult;
|
||||
runCycle: MonitoringCycleResult;
|
||||
kickoffCycle: MonitoringCycleKickoffResponse;
|
||||
getState: MonitoringRuntimeStateSnapshot;
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isMonitoringPingPayload(value: unknown): value is MonitoringPluginPingResponse {
|
||||
return isPlainObject(value) && typeof value.installed === "boolean";
|
||||
}
|
||||
|
||||
function isMonitoringDetectPayload(value: unknown): value is MonitoringLocalPlatformState[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isMonitoringHeartbeatPayload(value: unknown): value is MonitoringHeartbeatSyncResult {
|
||||
return isPlainObject(value) && typeof value.success === "boolean" && Array.isArray(value.platform_states);
|
||||
}
|
||||
|
||||
function isMonitoringCyclePayload(value: unknown): value is MonitoringCycleResult {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.success === "boolean" &&
|
||||
typeof value.leased_task_count === "number" &&
|
||||
typeof value.processed_task_count === "number" &&
|
||||
typeof value.failed_task_count === "number" &&
|
||||
typeof value.skipped_task_count === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isMonitoringKickoffPayload(value: unknown): value is MonitoringCycleKickoffResponse {
|
||||
return isPlainObject(value) && value.accepted === true && typeof value.status === "string";
|
||||
}
|
||||
|
||||
function isMonitoringStatePayload(value: unknown): value is MonitoringRuntimeStateSnapshot {
|
||||
return isPlainObject(value) && typeof value.installation_key === "string" && Array.isArray(value.monitoring_platforms);
|
||||
}
|
||||
|
||||
function isMonitoringPayloadValid<T extends MonitoringAction>(action: T, value: unknown): value is MonitoringPluginPayloadMap[T] {
|
||||
switch (action) {
|
||||
case "ping":
|
||||
return isMonitoringPingPayload(value);
|
||||
case "detectPlatforms":
|
||||
return isMonitoringDetectPayload(value);
|
||||
case "syncHeartbeat":
|
||||
return isMonitoringHeartbeatPayload(value);
|
||||
case "runCycle":
|
||||
return isMonitoringCyclePayload(value);
|
||||
case "kickoffCycle":
|
||||
return isMonitoringKickoffPayload(value);
|
||||
case "getState":
|
||||
return isMonitoringStatePayload(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextRequestId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `geo-monitoring-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
async function requestMonitoringPlugin<T extends MonitoringAction>(action: T, timeoutMs = 5000): Promise<MonitoringPluginPayloadMap[T]> {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("plugin bridge is only available in browser contexts");
|
||||
}
|
||||
|
||||
const requestId = nextRequestId();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("monitoring_plugin_timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
const handleMessage = (event: MessageEvent<MonitoringPluginSuccessPayload<MonitoringPluginPayloadMap[T]> | MonitoringPluginErrorPayload>) => {
|
||||
if (event.source !== window || !event.data || event.data.type !== "geo.monitoring.response") {
|
||||
return;
|
||||
}
|
||||
if (event.data.requestId !== requestId || event.data.source !== "geo-extension") {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (event.data.success) {
|
||||
if (!isMonitoringPayloadValid(action, event.data.data)) {
|
||||
reject(new Error("monitoring_plugin_invalid_response"));
|
||||
return;
|
||||
}
|
||||
resolve(event.data.data);
|
||||
return;
|
||||
}
|
||||
reject(new Error(event.data.error || "monitoring_plugin_unknown_error"));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.postMessage(
|
||||
{
|
||||
source: "geo-admin",
|
||||
type: "geo.monitoring.request",
|
||||
requestId,
|
||||
action,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMonitoringCycle(): Promise<MonitoringCycleResult> {
|
||||
return requestMonitoringPlugin("runCycle", 30000);
|
||||
}
|
||||
|
||||
export async function kickoffMonitoringCycle(): Promise<MonitoringCycleKickoffResponse> {
|
||||
return requestMonitoringPlugin("kickoffCycle", 5000);
|
||||
}
|
||||
@@ -24,11 +24,13 @@
|
||||
"pino": "^9.0.0",
|
||||
"playwright-core": "^1.55.0",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"electron": "41.2.0",
|
||||
"electron-builder": "^25.0.0",
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
upsertDesktopAccount,
|
||||
} from "./transport/api-client";
|
||||
import { SseClient, SseClientError } from "./transport/sse-client";
|
||||
import { DispatchWsClient } from "./transport/dispatch-ws-client";
|
||||
|
||||
type RuntimeTone = "info" | "success" | "warn" | "danger";
|
||||
type RuntimeTaskStatus =
|
||||
@@ -184,6 +185,8 @@ interface RuntimeState {
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
sseClient: SseClient | null;
|
||||
dispatchWsClient: DispatchWsClient | null;
|
||||
dispatchWsConnected: boolean;
|
||||
lastHeartbeatAt: number;
|
||||
lastHeartbeatStatus: "idle" | "success" | "failed";
|
||||
lastPullAt: number;
|
||||
@@ -210,6 +213,8 @@ const state: RuntimeState = {
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
sseClient: null,
|
||||
dispatchWsClient: null,
|
||||
dispatchWsConnected: false,
|
||||
lastHeartbeatAt: 0,
|
||||
lastHeartbeatStatus: "idle",
|
||||
lastPullAt: 0,
|
||||
@@ -354,6 +359,7 @@ function startRuntime(): void {
|
||||
recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。");
|
||||
|
||||
connectEventStream();
|
||||
connectDispatchWs();
|
||||
scheduleHeartbeatLoop();
|
||||
schedulePullLoop();
|
||||
|
||||
@@ -383,6 +389,11 @@ function stopRuntime(): void {
|
||||
state.sseClient.stop();
|
||||
state.sseClient = null;
|
||||
}
|
||||
if (state.dispatchWsClient) {
|
||||
state.dispatchWsClient.stop();
|
||||
state.dispatchWsClient = null;
|
||||
}
|
||||
state.dispatchWsConnected = false;
|
||||
|
||||
setTransportSseState("idle");
|
||||
setSchedulerPhase("idle");
|
||||
@@ -521,6 +532,63 @@ function connectEventStream(): void {
|
||||
sseClient.start();
|
||||
}
|
||||
|
||||
function connectDispatchWs(): void {
|
||||
if (!canRunLive(state.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.dispatchWsClient?.stop();
|
||||
|
||||
const wsUrl = buildApiUrl(state.session.api_base_url, "/api/desktop/dispatch")
|
||||
.replace(/^http:\/\//, "ws://")
|
||||
.replace(/^https:\/\//, "wss://");
|
||||
|
||||
const client = new DispatchWsClient({
|
||||
url: wsUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${state.session.client_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
client.on("open", () => {
|
||||
state.dispatchWsConnected = true;
|
||||
setSchedulerError(null);
|
||||
recordActivity("success", "任务分发通道已连接", "AMQP 直推 WebSocket 已就绪,发布任务将以低延迟到达。");
|
||||
});
|
||||
|
||||
client.on("connected", () => {
|
||||
state.dispatchWsConnected = true;
|
||||
});
|
||||
|
||||
client.on("task_available", (payload) => {
|
||||
if (!isDesktopTaskEvent(payload)) {
|
||||
return;
|
||||
}
|
||||
state.dispatchWsConnected = true;
|
||||
handleTaskEvent(payload);
|
||||
});
|
||||
|
||||
client.on("error", (payload) => {
|
||||
state.dispatchWsConnected = false;
|
||||
const message = errorMessage(payload);
|
||||
recordActivity("warn", "任务分发通道异常", message);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
state.dispatchWsConnected = false;
|
||||
});
|
||||
|
||||
client.on("reconnect", (payload) => {
|
||||
if (!isReconnectPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
recordActivity("info", "任务分发通道退避等待", `将在 ${payload.delayMs}ms 后重连 WebSocket。`);
|
||||
});
|
||||
|
||||
state.dispatchWsClient = client;
|
||||
client.start();
|
||||
}
|
||||
|
||||
function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
const existing = state.tasks.get(event.task_id);
|
||||
const next: RuntimeTaskRecord = {
|
||||
@@ -871,11 +939,18 @@ function pumpExecutionLoop(): void {
|
||||
}
|
||||
|
||||
if (state.activeExecutions.size === 0) {
|
||||
void pullNextTask("publish");
|
||||
// Publish task_available is push-delivered over the dispatch WebSocket. We
|
||||
// only fall back to the HTTP pull when the push channel is down, otherwise
|
||||
// every scheduler tick would wake the /lease endpoint for no reason.
|
||||
if (!state.dispatchWsConnected) {
|
||||
void pullNextTask("publish");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
// Monitor has not been migrated to the dispatch channel yet, so it still
|
||||
// relies on the periodic pull as its primary signal.
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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