refactor(gateway): consolidate TypeScript WebSocket clients (#106113)

* refactor(gateway): unify websocket clients

* fix(gateway): preserve adapter lifecycle behavior

* test(gateway): keep reconnect proof black-box

* chore(gateway): satisfy shared client lint

* refactor(gateway): keep protocol decisions internal

* refactor(gateway): preserve shared reconnect supervision

* test(gateway): use canonical browser barrel

* fix(gateway): resolve browser retry workspace source

* refactor(gateway): tighten shared protocol types
This commit is contained in:
Peter Steinberger
2026-07-13 03:08:21 -07:00
committed by GitHub
parent 581cf6601a
commit 7c9b8baa2b
17 changed files with 1645 additions and 1647 deletions
+7 -2
View File
@@ -14,6 +14,11 @@
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./browser": {
"types": "./dist/browser.d.mts",
"import": "./dist/browser.mjs",
"default": "./dist/browser.mjs"
},
"./readiness": {
"types": "./dist/readiness.d.mts",
"import": "./dist/readiness.mjs",
@@ -26,12 +31,12 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/readiness.ts src/timeouts.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/timeouts.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/gateway-protocol": "workspace:*",
"@openclaw/net-policy": "workspace:*",
"@openclaw/retry": "workspace:*",
"ipaddr.js": "2.4.0",
"ws": "8.21.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
// Browser-safe gateway client surface. Keep Node transport/TLS dependencies out
// of this entry so browser consumers share the wire engine without polyfills.
export * from "./device-auth.js";
export * from "./protocol-client.js";
export * from "./reconnect-policy.js";
export * from "@openclaw/gateway-protocol/client-info";
export * from "@openclaw/gateway-protocol/connect-error-details";
export * from "@openclaw/gateway-protocol/startup-unavailable";
export * from "@openclaw/gateway-protocol/version";
export type { ConnectParams, ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol";
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import { createServer } from "node:net";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { GatewayClient, resolveGatewayClientConnectChallengeTimeoutMs } from "./client.js";
import type { GatewayProtocolSocket } from "./protocol-client.js";
import {
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
MAX_SAFE_TIMEOUT_DELAY_MS,
@@ -45,20 +46,44 @@ function createOpenGatewayClient(requestTimeoutMs: number): {
requestTimeoutMs,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
installSyntheticSocket(client, send, vi.fn());
return { client, send };
}
function getPendingCount(client: GatewayClient): number {
return (client as unknown as { pending: Map<string, unknown> }).pending.size;
return protocolHarness(client).requests.pending.size;
}
type ProtocolHarness = {
socket: GatewayProtocolSocket | null;
stopped: boolean;
generation: number;
reconnectSupervisor: { reset(initialMs?: number): void };
requests: { pending: Map<string, unknown> };
handleMessage: (socket: GatewayProtocolSocket, generation: number, raw: string) => void;
};
function protocolHarness(client: GatewayClient): ProtocolHarness {
return (client as unknown as { protocol: ProtocolHarness }).protocol;
}
function installSyntheticSocket(
client: GatewayClient,
send: (data: string) => unknown,
close: (code?: number, reason?: string) => unknown,
): void {
const socket: GatewayProtocolSocket = {
isOpen: () => true,
send: (data) => send(data),
close: (code, reason) => close(code, reason),
};
Object.assign(protocolHarness(client), { socket, stopped: false, generation: 1 });
(client as unknown as { ws: unknown }).ws = {
readyState: WebSocket.OPEN,
send,
close,
terminate: vi.fn(),
};
}
function trackSettlement(promise: Promise<unknown>): () => boolean {
@@ -86,13 +111,8 @@ function createWatchedGatewayClient(): {
});
const close = vi.fn();
const send = vi.fn();
Object.assign(client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number }, {
ws: {
readyState: WebSocket.OPEN,
send,
close,
terminate: vi.fn(),
},
installSyntheticSocket(client, send, close);
Object.assign(client as unknown as { tickIntervalMs: number; lastTick: number }, {
tickIntervalMs: 5,
lastTick: Date.now(),
});
@@ -101,9 +121,11 @@ function createWatchedGatewayClient(): {
}
function handleGatewayMessage(client: GatewayClient, payload: Record<string, unknown>): void {
(client as unknown as { handleMessage: (raw: string) => void }).handleMessage(
JSON.stringify(payload),
);
const protocol = protocolHarness(client);
if (!protocol.socket) {
throw new Error("synthetic protocol socket missing");
}
protocol.handleMessage(protocol.socket, protocol.generation, JSON.stringify(payload));
}
async function stopSyntheticClient(client: GatewayClient): Promise<void> {
@@ -116,6 +138,8 @@ describe("GatewayClient", () => {
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
if (wss) {
for (const client of wss.clients) {
client.terminate();
@@ -296,11 +320,7 @@ describe("GatewayClient", () => {
helloCount += 1;
if (helloCount === 1) {
// Keep the real reconnect lifecycle fast without changing production defaults.
(
client as unknown as {
reconnectSupervisor: { reset(initialMs?: number): void };
}
).reconnectSupervisor.reset(10);
protocolHarness(client).reconnectSupervisor.reset(10);
resolveFirstHello();
return;
}
@@ -339,25 +359,21 @@ describe("GatewayClient", () => {
test("lets finite pending requests own their timeout when ticks are missing", async () => {
vi.useFakeTimers();
try {
const { client, close } = createWatchedGatewayClient();
const request = client.request("status", undefined, { timeoutMs: 100 });
const requestExpectation = expect(request).rejects.toThrow(
"gateway request timeout for status",
);
await vi.advanceTimersByTimeAsync(20);
const { client, close } = createWatchedGatewayClient();
const request = client.request("status", undefined, { timeoutMs: 100 });
const requestExpectation = expect(request).rejects.toThrow(
"gateway request timeout for status",
);
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(80);
await requestExpectation;
await vi.advanceTimersByTimeAsync(5);
await vi.advanceTimersByTimeAsync(80);
await requestExpectation;
await vi.advanceTimersByTimeAsync(5);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
} finally {
vi.useRealTimers();
}
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
});
test.each([
@@ -373,218 +389,123 @@ describe("GatewayClient", () => {
},
])("keeps the watchdog active for $label request", async ({ method, options }) => {
vi.useFakeTimers();
try {
const { client, close } = createWatchedGatewayClient();
const request = client.request(method, undefined, options);
const requestExpectation = expect(request).rejects.toThrow("gateway client stopped");
const { client, close } = createWatchedGatewayClient();
const request = client.request(method, undefined, options);
const requestExpectation = expect(request).rejects.toThrow("gateway client stopped");
await vi.advanceTimersByTimeAsync(20);
await vi.advanceTimersByTimeAsync(20);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
await requestExpectation;
} finally {
vi.useRealTimers();
}
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
await requestExpectation;
});
test("keeps the watchdog active for mixed finite and unbounded requests", async () => {
vi.useFakeTimers();
try {
const { client, close } = createWatchedGatewayClient();
const requests = [
client.request("status", undefined, { timeoutMs: 100 }),
client.request("chat.send", undefined, { expectFinal: true }),
];
const settlements = Promise.allSettled(requests);
const { client, close } = createWatchedGatewayClient();
const requests = [
client.request("status", undefined, { timeoutMs: 100 }),
client.request("chat.send", undefined, { expectFinal: true }),
];
const settlements = Promise.allSettled(requests);
await vi.advanceTimersByTimeAsync(20);
await vi.advanceTimersByTimeAsync(20);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
await expect(settlements).resolves.toEqual([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]);
} finally {
vi.useRealTimers();
}
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
await stopSyntheticClient(client);
await expect(settlements).resolves.toEqual([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]);
});
test("keeps an unbounded request alive while inbound ticks continue", async () => {
vi.useFakeTimers();
try {
const { client, close, send } = createWatchedGatewayClient();
const request = client.request<{ status: string }>("chat.send", undefined, {
expectFinal: true,
});
const requestFrame = JSON.parse(String(send.mock.calls[0]?.[0])) as { id: string };
const { client, close, send } = createWatchedGatewayClient();
const request = client.request<{ status: string }>("chat.send", undefined, {
expectFinal: true,
});
const requestFrame = JSON.parse(String(send.mock.calls[0]?.[0])) as { id: string };
for (let seq = 1; seq <= 4; seq += 1) {
await vi.advanceTimersByTimeAsync(5);
handleGatewayMessage(client, { type: "event", event: "tick", seq, payload: {} });
}
expect(close).not.toHaveBeenCalled();
handleGatewayMessage(client, {
type: "res",
id: requestFrame.id,
ok: true,
payload: { status: "ok" },
});
await expect(request).resolves.toEqual({ status: "ok" });
await stopSyntheticClient(client);
} finally {
vi.useRealTimers();
for (let seq = 1; seq <= 4; seq += 1) {
await vi.advanceTimersByTimeAsync(5);
handleGatewayMessage(client, { type: "event", event: "tick", seq, payload: {} });
}
expect(close).not.toHaveBeenCalled();
handleGatewayMessage(client, {
type: "res",
id: requestFrame.id,
ok: true,
payload: { status: "ok" },
});
await expect(request).resolves.toEqual({ status: "ok" });
await stopSyntheticClient(client);
});
test("honors explicit tick watchdog timeout threshold", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
tickWatchTimeoutMs: 50,
});
const close = vi.fn();
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close,
},
tickIntervalMs: 5,
lastTick: Date.now(),
},
);
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
tickWatchTimeoutMs: 50,
});
const close = vi.fn();
installSyntheticSocket(client, vi.fn(), close);
Object.assign(client as unknown as { tickIntervalMs: number; lastTick: number }, {
tickIntervalMs: 5,
lastTick: Date.now(),
});
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(35);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
} finally {
vi.useRealTimers();
}
await vi.advanceTimersByTimeAsync(35);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
});
test("clamps oversized tick watchdog intervals before scheduling", () => {
vi.useFakeTimers();
try {
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
});
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close: vi.fn(),
},
tickIntervalMs: Number.MAX_SAFE_INTEGER,
lastTick: Date.now(),
},
);
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
});
Object.assign(client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number }, {
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close: vi.fn(),
},
tickIntervalMs: Number.MAX_SAFE_INTEGER,
lastTick: Date.now(),
});
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
client.stop();
} finally {
vi.useRealTimers();
}
});
test("times out unresolved requests and clears pending state", async () => {
vi.useFakeTimers();
try {
const { client, send } = createOpenGatewayClient(25);
const requestPromise = client.request("status");
const requestExpectation = expect(requestPromise).rejects.toThrow(
"gateway request timeout for status",
);
expect(send).toHaveBeenCalledTimes(1);
expect(getPendingCount(client)).toBe(1);
await vi.advanceTimersByTimeAsync(25);
await requestExpectation;
expect(getPendingCount(client)).toBe(0);
} finally {
vi.useRealTimers();
}
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
client.stop();
});
test("cleans pending request state when websocket send throws", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
const { client, send } = createOpenGatewayClient(25);
send.mockImplementationOnce(() => {
throw new Error("synthetic send failure");
});
const sendError = new Error("synthetic send failure");
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send: vi.fn(() => {
throw sendError;
}),
close: vi.fn(),
};
await expect(client.request("status")).rejects.toThrow("synthetic send failure");
expect(getPendingCount(client)).toBe(0);
});
test("does not auto-timeout expectFinal requests", async () => {
vi.useFakeTimers();
try {
const { client, send } = createOpenGatewayClient(25);
const requestPromise = client.request("chat.send", undefined, { expectFinal: true });
const isSettled = trackSettlement(requestPromise);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(25);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
test("notifies accepted expectFinal requests while continuing to wait for final", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: (data: string) => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client, send } = createOpenGatewayClient(25);
const onAccepted = vi.fn();
const requestPromise = client.request<{ status: string }>("agent", undefined, {
@@ -593,53 +514,29 @@ describe("GatewayClient", () => {
});
const frame = JSON.parse(String(send.mock.calls[0]?.[0])) as { id: string };
(
client as unknown as {
handleMessage: (raw: string) => void;
}
).handleMessage(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: { status: "accepted", runId: "run-1" },
}),
);
handleGatewayMessage(client, {
type: "res",
id: frame.id,
ok: true,
payload: { status: "accepted", runId: "run-1" },
});
expect(onAccepted).toHaveBeenCalledWith({ status: "accepted", runId: "run-1" });
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(getPendingCount(client)).toBe(1);
(
client as unknown as {
handleMessage: (raw: string) => void;
}
).handleMessage(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: { status: "ok" },
}),
);
handleGatewayMessage(client, {
type: "res",
id: frame.id,
ok: true,
payload: { status: "ok" },
});
await expect(requestPromise).resolves.toEqual({ status: "ok" });
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
expect(getPendingCount(client)).toBe(0);
});
test("aborts in-flight requests from caller AbortSignal", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client, send } = createOpenGatewayClient(25);
const controller = new AbortController();
const requestPromise = client.request("status", undefined, {
@@ -647,20 +544,24 @@ describe("GatewayClient", () => {
timeoutMs: null,
});
expect(send).toHaveBeenCalledTimes(1);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(getPendingCount(client)).toBe(1);
controller.abort();
await expect(requestPromise).rejects.toThrow("gateway request aborted for status");
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
expect(getPendingCount(client)).toBe(0);
});
test("clamps oversized explicit request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const { client } = createOpenGatewayClient(25);
test.each([
{ defaultTimeoutMs: 25, options: { timeoutMs: 2_592_010_000 } },
{ defaultTimeoutMs: 2_592_010_000, options: undefined },
])(
"clamps oversized request timeouts before scheduling",
async ({ defaultTimeoutMs, options }) => {
vi.useFakeTimers();
const { client } = createOpenGatewayClient(defaultTimeoutMs);
const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 });
const requestPromise = client.request("status", undefined, options);
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
@@ -670,56 +571,29 @@ describe("GatewayClient", () => {
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
test("clamps oversized default request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const { client } = createOpenGatewayClient(2_592_010_000);
const requestPromise = client.request("status");
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
},
);
test("clamps oversized stopAndWait timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({});
const ws = {
readyState: WebSocket.OPEN,
close: vi.fn(),
terminate: vi.fn(),
};
(client as unknown as { ws: unknown }).ws = ws;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const client = new GatewayClient({});
const ws = {
readyState: WebSocket.OPEN,
close: vi.fn(),
terminate: vi.fn(),
};
(client as unknown as { ws: unknown }).ws = ws;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const stopPromise = client.stopAndWait({ timeoutMs: Number.MAX_SAFE_INTEGER });
const stopPromise = client.stopAndWait({ timeoutMs: Number.MAX_SAFE_INTEGER });
await vi.advanceTimersByTimeAsync(1);
expect(ws.terminate).not.toHaveBeenCalled();
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
await vi.advanceTimersByTimeAsync(1);
expect(ws.terminate).not.toHaveBeenCalled();
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
await vi.advanceTimersByTimeAsync(249);
await expect(stopPromise).resolves.toBeUndefined();
expect(ws.terminate).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
await vi.advanceTimersByTimeAsync(249);
await expect(stopPromise).resolves.toBeUndefined();
expect(ws.terminate).toHaveBeenCalledTimes(1);
});
test("rejects mismatched tls fingerprint", async () => {
@@ -0,0 +1,129 @@
import type { ErrorShape, EventFrame, HelloOk } from "@openclaw/gateway-protocol";
export type GatewayProtocolSocket = {
isOpen: () => boolean;
send: (data: string) => void;
close: (code?: number, reason?: string) => void;
};
export type GatewayProtocolSocketHandlers = {
open: () => void;
message: (data: string) => void;
close: (code: number, reason: string) => void;
error: (error: Error) => void;
};
export type GatewayProtocolRequestOptions = {
timeoutMs?: number | null;
expectFinal?: boolean;
onAccepted?: (payload: unknown) => void;
signal?: AbortSignal;
};
type GatewayProtocolConnectContext<TPlan> = {
generation: number;
nonce: string | null;
plan: TPlan;
};
export type GatewayProtocolCloseContext = {
code: number;
reason: string;
generation: number;
socketOpened: boolean;
helloReceived: boolean;
connectRequestSent: boolean;
connectFailure?: { error: Error; reconnectDelayMs?: number };
};
type GatewayProtocolConnectDecision = {
closeCode: number;
closeReason: string;
reconnectDelayMs?: number;
stop?: boolean;
error?: Error;
};
type GatewayProtocolCloseDecision = {
retry: boolean;
notify: boolean;
reconnectDelayMs?: number;
pendingError?: Error;
};
export type GatewayProtocolTiming<TPlan> = {
phase:
| "socket-open"
| "challenge"
| "fallback"
| "device-identity-ready"
| "connect-plan-ready"
| "request-sent"
| "hello"
| "failed";
generation: number;
durationMs: number;
phaseDurationMs: number;
hasChallenge: boolean;
usedFallback: boolean;
plan?: TPlan;
detail?: unknown;
};
export type GatewayProtocolRequestTiming = {
id: string;
method: string;
ok: boolean;
durationMs: number;
startedAtMs: number;
endedAtMs: number;
errorCode?: string;
};
export type GatewayProtocolClientOptions<TPlan> = {
createSocket: (handlers: GatewayProtocolSocketHandlers) => GatewayProtocolSocket;
createRequestId: () => string;
createRequestError?: (error: Partial<ErrorShape>) => GatewayProtocolRequestError;
createRequestTimeoutError?: (method: string, timeoutMs: number) => Error;
createRequestAbortError?: (method: string) => Error;
buildConnectPlan: (params: {
nonce: string | null;
generation: number;
}) => TPlan | Promise<TPlan>;
buildConnectParams: (plan: TPlan) => unknown;
onConnectPlanError?: (error: Error) => GatewayProtocolConnectDecision;
onConnectHello?: (hello: HelloOk, context: GatewayProtocolConnectContext<TPlan>) => void;
onHello?: (hello: HelloOk) => void;
onConnectFailure?: (
error: GatewayProtocolRequestError,
context: GatewayProtocolConnectContext<TPlan>,
) => GatewayProtocolConnectDecision;
resolveClose: (context: GatewayProtocolCloseContext) => GatewayProtocolCloseDecision;
onClose?: (context: GatewayProtocolCloseContext, decision: GatewayProtocolCloseDecision) => void;
notifyStoppedClose?: boolean;
onConnectError?: (error: Error) => void;
onSocketFactoryError?: (error: Error) => void;
onParseError?: (error: unknown) => void;
onEvent?: (event: EventFrame) => void;
onGap?: (info: { expected: number; received: number }) => void;
onActivity?: () => void;
onTiming?: (timing: GatewayProtocolTiming<TPlan>) => void;
onRequestTiming?: (timing: GatewayProtocolRequestTiming) => void;
onCallbackError?: (label: string, error: unknown) => void;
handshake:
| { mode: "fallback"; timeoutMs: number }
| {
mode: "require-challenge";
timeoutMs: number;
timeoutMessage?: (elapsedMs: number) => string;
};
reconnect: { initialMs: number; multiplier: number; maxMs: number };
requestTimeoutMs?: number;
nowMs?: () => number;
rethrowSocketFactoryError?: (error: Error) => boolean;
};
export class GatewayProtocolRequestError extends Error {
readonly code: string;
readonly details?: unknown;
readonly retryable?: boolean;
readonly retryAfterMs?: number;
constructor(error: Partial<ErrorShape>) {
super(error.message ?? "request failed");
this.name = "GatewayProtocolRequestError";
this.code = error.code ?? "UNAVAILABLE";
this.details = error.details;
this.retryable = error.retryable;
this.retryAfterMs = error.retryAfterMs;
}
}
@@ -0,0 +1,462 @@
import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol";
import {
isGatewayEventFrame,
isGatewayResponseFrame,
} from "@openclaw/gateway-protocol/frame-guards";
import { RetrySupervisor, sleepWithAbort } from "@openclaw/retry";
import {
GatewayProtocolRequestError,
type GatewayProtocolClientOptions,
type GatewayProtocolCloseContext,
type GatewayProtocolRequestOptions,
type GatewayProtocolSocket,
type GatewayProtocolTiming,
} from "./protocol-client-types.js";
import { GatewayProtocolRequests } from "./protocol-requests.js";
export * from "./protocol-client-types.js";
type ConnectTimingState = {
generation: number;
startedAtMs: number;
lastAtMs: number;
hasChallenge: boolean;
usedFallback: boolean;
};
type CloseSnapshot = Omit<GatewayProtocolCloseContext, "code" | "reason">;
/**
* Browser-safe gateway wire client. Environment adapters own transport and auth
* policy; this class owns the single socket/handshake/reconnect/frame state machine.
*/
export class GatewayProtocolClient<TPlan> {
private socket: GatewayProtocolSocket | null = null;
private readonly requests: GatewayProtocolRequests<TPlan>;
private listeners = new Set<(event: EventFrame) => void>();
private stopped = true;
private generation = 0;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectRequestSent = false;
private handshakeTimer: ReturnType<typeof setTimeout> | null = null;
private readonly reconnectSupervisor: RetrySupervisor;
private socketOpened = false;
private helloReceived = false;
private connectFailure: GatewayProtocolCloseContext["connectFailure"];
private connectTiming: ConnectTimingState | null = null;
private stoppedSocket?: { socket: GatewayProtocolSocket; context: CloseSnapshot };
constructor(private readonly opts: GatewayProtocolClientOptions<TPlan>) {
this.reconnectSupervisor = new RetrySupervisor({
initialMs: opts.reconnect.initialMs,
maxMs: opts.reconnect.maxMs,
factor: opts.reconnect.multiplier,
jitter: 0,
});
this.requests = new GatewayProtocolRequests(opts);
}
get connected(): boolean {
return this.socket?.isOpen() ?? false;
}
get hasPendingRequests(): boolean {
return this.requests.hasPending;
}
get connecting(): boolean {
return this.connectSent && !this.helloReceived;
}
get hasUnboundedPendingRequests(): boolean {
return this.requests.hasUnboundedPending;
}
start(): void {
this.stopped = false;
this.reconnectSupervisor.cancel();
this.connect();
}
stop(): void {
this.stopped = true;
this.clearHandshakeTimer();
this.reconnectSupervisor.reset();
const socket = this.socket;
if (socket && this.opts.notifyStoppedClose) {
// Node callers observe the transport's final close during explicit stop;
// browser callers intentionally suppress it.
this.stoppedSocket = { socket, context: this.closeContext() };
}
this.socket = null;
this.connectFailure = undefined;
this.connectTiming = null;
this.requests.flush(new Error("gateway client stopped"));
if (!socket) {
return;
}
socket.close();
}
request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayProtocolRequestOptions,
): Promise<T> {
const socket = this.socket;
if (!socket?.isOpen()) {
return Promise.reject(new Error("gateway not connected"));
}
if (typeof method !== "string" || method.length === 0) {
return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));
}
return this.requests.request<T>(socket, method, params, options);
}
addEventListener(listener: (event: EventFrame) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
closeSocket(code?: number, reason?: string): void {
this.socket?.close(code, reason);
}
resetReconnectBackoff(initialMs: number): void {
this.reconnectSupervisor.reset(initialMs);
}
recordTiming(
phase: GatewayProtocolTiming<TPlan>["phase"],
generation: number,
plan?: TPlan,
detail?: unknown,
): void {
const now = this.nowMs();
const state = this.connectTiming;
if (!state || state.generation !== generation) {
return;
}
state.hasChallenge ||= phase === "challenge";
state.usedFallback ||= phase === "fallback";
this.invoke("connect timing", () =>
this.opts.onTiming?.({
phase,
generation,
durationMs: Math.max(0, now - state.startedAtMs),
phaseDurationMs: Math.max(0, now - state.lastAtMs),
hasChallenge: state.hasChallenge,
usedFallback: state.usedFallback,
plan,
detail,
}),
);
state.lastAtMs = now;
if (phase === "hello" || phase === "failed") {
this.connectTiming = null;
}
}
private connect(): void {
if (this.stopped) {
return;
}
const generation = this.generation + 1;
this.connectNonce = null;
this.connectSent = false;
this.connectRequestSent = false;
this.socketOpened = false;
this.helloReceived = false;
this.connectFailure = undefined;
let socket: GatewayProtocolSocket;
try {
socket = this.opts.createSocket({
open: () => this.handleOpen(socket, generation),
message: (data) => this.handleMessage(socket, generation, data),
close: (code, reason) => this.handleClose(socket, generation, code, reason),
error: (error) => this.handleSocketError(socket, generation, error),
});
} catch (error) {
const normalized = error instanceof Error ? error : new Error(String(error));
this.opts.onSocketFactoryError?.(normalized);
this.opts.onConnectError?.(normalized);
if (this.opts.rethrowSocketFactoryError?.(normalized)) {
throw normalized;
}
return;
}
this.generation = generation;
this.socket = socket;
const now = this.nowMs();
this.connectTiming = {
generation,
startedAtMs: now,
lastAtMs: now,
hasChallenge: false,
usedFallback: false,
};
}
private handleOpen(socket: GatewayProtocolSocket, generation: number): void {
if (!this.isActive(socket, generation)) {
return;
}
this.socketOpened = true;
this.recordTiming("socket-open", generation);
if (this.connectNonce) {
this.sendConnect(socket, generation);
return;
}
this.armHandshakeTimer(socket, generation);
}
private armHandshakeTimer(socket: GatewayProtocolSocket, generation: number): void {
this.clearHandshakeTimer();
const armedAt = Date.now();
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null;
if (!this.isActive(socket, generation) || this.connectSent || !socket.isOpen()) {
return;
}
if (this.opts.handshake.mode === "fallback") {
this.recordTiming("fallback", generation);
this.sendConnect(socket, generation);
return;
}
const elapsedMs = Date.now() - armedAt;
const error = new Error(
this.opts.handshake.timeoutMessage?.(elapsedMs) ??
`gateway connect challenge timeout after ${elapsedMs}ms`,
);
this.opts.onConnectError?.(error);
socket.close(1008, "connect challenge timeout");
}, this.opts.handshake.timeoutMs);
this.handshakeTimer.unref?.();
}
private sendConnect(socket: GatewayProtocolSocket, generation: number): void {
if (!this.isActive(socket, generation) || !socket.isOpen() || this.connectSent) {
return;
}
this.connectSent = true;
this.clearHandshakeTimer();
let planOrPromise: TPlan | Promise<TPlan>;
try {
planOrPromise = this.opts.buildConnectPlan({ nonce: this.connectNonce, generation });
} catch (error) {
this.handleConnectPlanError(socket, generation, error);
return;
}
if (planOrPromise instanceof Promise) {
void planOrPromise
.then((plan) => this.sendConnectPlan(socket, generation, plan))
.catch((error: unknown) => this.handleConnectPlanError(socket, generation, error));
return;
}
this.sendConnectPlan(socket, generation, planOrPromise);
}
private handleConnectPlanError(
socket: GatewayProtocolSocket,
generation: number,
error: unknown,
): void {
if (!this.isActive(socket, generation)) {
return;
}
const normalized = error instanceof Error ? error : new Error(String(error));
const outcome = this.opts.onConnectPlanError?.(normalized) ?? {
closeCode: 1008,
closeReason: "connect failed",
};
this.opts.onConnectError?.(outcome.error ?? normalized);
if (outcome.stop) {
this.stopped = true;
}
socket.close(outcome.closeCode, outcome.closeReason);
}
private sendConnectPlan(socket: GatewayProtocolSocket, generation: number, plan: TPlan): void {
if (!this.isActive(socket, generation) || !socket.isOpen()) {
return;
}
const context = { generation, nonce: this.connectNonce, plan };
this.recordTiming("connect-plan-ready", generation, plan);
this.recordTiming("request-sent", generation, plan);
this.connectRequestSent = true;
void this.requests
.request<HelloOk>(socket, "connect", this.opts.buildConnectParams(plan))
.then((hello) => {
if (!this.isActive(socket, generation)) {
return;
}
this.helloReceived = true;
this.connectFailure = undefined;
this.reconnectSupervisor.reset();
this.recordTiming("hello", generation, plan);
this.opts.onConnectHello?.(hello, context);
this.invoke("hello", () => this.opts.onHello?.(hello));
})
.catch((error: unknown) => {
if (!this.isActive(socket, generation)) {
return;
}
const requestError =
error instanceof GatewayProtocolRequestError
? error
: new GatewayProtocolRequestError({ message: String(error) });
const outcome = this.opts.onConnectFailure?.(requestError, context) ?? {
closeCode: 1008,
closeReason: "connect failed",
};
this.connectFailure = { error: requestError, reconnectDelayMs: outcome.reconnectDelayMs };
if (outcome.stop) {
this.stopped = true;
}
socket.close(outcome.closeCode, outcome.closeReason);
});
}
private handleMessage(socket: GatewayProtocolSocket, generation: number, raw: string): void {
if (!this.isActive(socket, generation)) {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
this.opts.onParseError?.(error);
return;
}
if (isGatewayEventFrame(parsed)) {
this.opts.onActivity?.();
if (parsed.event === "connect.challenge") {
const payload = parsed.payload as { nonce?: unknown } | undefined;
const nonce = typeof payload?.nonce === "string" ? payload.nonce.trim() : "";
if (!nonce) {
if (this.opts.handshake.mode === "require-challenge") {
const error = new Error("gateway connect challenge missing nonce");
this.opts.onConnectError?.(error);
socket.close(1008, "connect challenge missing nonce");
}
return;
}
this.connectNonce = nonce;
this.recordTiming("challenge", generation);
this.sendConnect(socket, generation);
return;
}
const seq = typeof parsed.seq === "number" ? parsed.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
const expected = this.lastSeq + 1;
this.invoke("gap", () => this.opts.onGap?.({ expected, received: seq }));
}
this.lastSeq = seq;
}
this.invoke("event", () => this.opts.onEvent?.(parsed));
for (const listener of this.listeners) {
this.invoke("event listener", () => listener(parsed));
}
return;
}
if (!isGatewayResponseFrame(parsed)) {
return;
}
this.opts.onActivity?.();
this.requests.handleResponse(parsed);
}
private handleClose(
socket: GatewayProtocolSocket,
generation: number,
code: number,
reason: string,
): void {
if (this.socket !== socket) {
if (this.stoppedSocket?.socket === socket) {
const context = { ...this.stoppedSocket.context, code, reason };
this.stoppedSocket = undefined;
this.invoke("close", () => this.opts.onClose?.(context, { retry: false, notify: true }));
}
return;
}
this.socket = null;
this.clearHandshakeTimer();
const context: GatewayProtocolCloseContext = {
...this.closeContext(),
code,
reason,
generation,
};
this.connectFailure = undefined;
const decision = this.opts.resolveClose(context);
this.requests.flush(
decision.pendingError ??
context.connectFailure?.error ??
new Error(`gateway closed (${code}): ${reason}`),
);
this.invoke("close", () => this.opts.onClose?.(context, decision));
if (decision.retry && !this.stopped) {
this.scheduleReconnect(decision.reconnectDelayMs ?? context.connectFailure?.reconnectDelayMs);
}
}
private handleSocketError(socket: GatewayProtocolSocket, generation: number, error: Error): void {
if (!this.isActive(socket, generation) || this.connectSent) {
return;
}
this.opts.onConnectError?.(error);
}
private scheduleReconnect(overrideMs?: number): void {
if (overrideMs !== undefined) {
// Retry-After is a floor for this wait, not a failed attempt. Preserve
// the exponential sequence for the next transport failure.
this.reconnectSupervisor.nextDelayOverrideMs = overrideMs;
}
const retry = this.reconnectSupervisor.next();
if (!retry) {
return;
}
// Ignore cancelled sleeps only; reconnect start failures stay observable.
void sleepWithAbort(retry.delayMs, retry.signal).then(
() => this.connect(),
() => {},
);
}
private closeContext(): CloseSnapshot {
return {
generation: this.generation,
socketOpened: this.socketOpened,
helloReceived: this.helloReceived,
connectRequestSent: this.connectRequestSent,
connectFailure: this.connectFailure,
};
}
private isActive(socket: GatewayProtocolSocket, generation: number): boolean {
return !this.stopped && this.socket === socket && this.generation === generation;
}
private nowMs(): number {
return this.opts.nowMs?.() ?? Date.now();
}
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
}
}
private invoke(label: string, callback: () => void): void {
try {
callback();
} catch (error) {
this.opts.onCallbackError?.(label, error);
}
}
}
@@ -0,0 +1,164 @@
import type { ResponseFrame } from "@openclaw/gateway-protocol";
import {
GatewayProtocolRequestError,
type GatewayProtocolClientOptions,
type GatewayProtocolRequestOptions,
type GatewayProtocolSocket,
} from "./protocol-client-types.js";
type Pending = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
expectFinal: boolean;
acceptedNotified: boolean;
onAccepted?: (payload: unknown) => void;
cleanup?: () => void;
unbounded: boolean;
method: string;
startedAtMs: number;
};
export class GatewayProtocolRequests<TPlan> {
private readonly pending = new Map<string, Pending>();
constructor(private readonly opts: GatewayProtocolClientOptions<TPlan>) {}
get hasPending(): boolean {
return this.pending.size > 0;
}
get hasUnboundedPending(): boolean {
return [...this.pending.values()].some((pending) => pending.unbounded);
}
request<T>(
socket: GatewayProtocolSocket,
method: string,
params?: unknown,
options?: GatewayProtocolRequestOptions,
): Promise<T> {
const id = this.opts.createRequestId();
const frame = { type: "req", id, method, params };
const timeoutMs =
options?.timeoutMs === null ? undefined : (options?.timeoutMs ?? this.opts.requestTimeoutMs);
return new Promise<T>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const pending: Pending = {
resolve: (value) => resolve(value as T),
reject,
expectFinal: options?.expectFinal === true,
acceptedNotified: false,
onAccepted: options?.onAccepted,
unbounded: timeoutMs === undefined,
method,
startedAtMs: this.opts.nowMs?.() ?? Date.now(),
};
const onAbort = () => {
this.pending.delete(id);
if (timeout) {
clearTimeout(timeout);
}
this.finishTiming(id, pending, false, "CLIENT_ABORTED");
reject(
this.opts.createRequestAbortError?.(method) ??
new Error(`gateway request aborted for ${method}`),
);
};
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
}
options?.signal?.removeEventListener("abort", onAbort);
};
if (options?.signal?.aborted) {
reject(
this.opts.createRequestAbortError?.(method) ??
new Error(`gateway request aborted for ${method}`),
);
return;
}
pending.cleanup = cleanup;
if (timeoutMs !== undefined && timeoutMs >= 0) {
timeout = setTimeout(() => {
this.pending.delete(id);
options?.signal?.removeEventListener("abort", onAbort);
this.finishTiming(id, pending, false, "CLIENT_TIMEOUT");
reject(
this.opts.createRequestTimeoutError?.(method, timeoutMs) ??
new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`),
);
}, timeoutMs);
timeout.unref?.();
}
options?.signal?.addEventListener("abort", onAbort, { once: true });
this.pending.set(id, pending);
try {
socket.send(JSON.stringify(frame));
} catch (error) {
this.pending.delete(id);
cleanup();
this.finishTiming(id, pending, false, "CLIENT_SEND_ERROR");
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
handleResponse(frame: ResponseFrame): void {
const pending = this.pending.get(frame.id);
if (!pending) {
return;
}
const status = (frame.payload as { status?: unknown } | undefined)?.status;
if (pending.expectFinal && status === "accepted") {
if (!pending.acceptedNotified) {
pending.acceptedNotified = true;
this.invoke("accepted", () => pending.onAccepted?.(frame.payload));
}
return;
}
this.pending.delete(frame.id);
pending.cleanup?.();
if (frame.ok) {
this.finishTiming(frame.id, pending, true);
pending.resolve(frame.payload);
return;
}
this.finishTiming(frame.id, pending, false, frame.error?.code);
pending.reject(
this.opts.createRequestError?.(frame.error ?? {}) ??
new GatewayProtocolRequestError(frame.error ?? {}),
);
}
flush(error: Error): void {
for (const [id, pending] of this.pending) {
this.finishTiming(id, pending, false, "CLIENT_CLOSED");
pending.cleanup?.();
pending.reject(error);
}
this.pending.clear();
}
private finishTiming(id: string, pending: Pending, ok: boolean, errorCode?: string): void {
const endedAtMs = this.opts.nowMs?.() ?? Date.now();
this.invoke("request timing", () =>
this.opts.onRequestTiming?.({
id,
method: pending.method,
ok,
durationMs: Math.max(0, endedAtMs - pending.startedAtMs),
startedAtMs: pending.startedAtMs,
endedAtMs,
errorCode,
}),
);
}
private invoke(label: string, callback: () => void): void {
try {
callback();
} catch (error) {
this.opts.onCallbackError?.(label, error);
}
}
}
@@ -0,0 +1,48 @@
import {
ConnectErrorDetailCodes,
readConnectErrorDetailCode,
readPairingConnectErrorDetails,
} from "@openclaw/gateway-protocol/connect-error-details";
const NON_RECOVERABLE_AUTH_ERRORS = new Set<string>([
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH,
ConnectErrorDetailCodes.PAIRING_REQUIRED,
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
export function shouldPauseGatewayReconnect(params: {
details?: unknown;
deviceTokenRetryPending?: boolean;
tokenMismatchIsTerminal?: boolean;
protocolMismatchIsTerminal?: boolean;
clientVersionMismatchIsTerminal?: boolean;
}): boolean {
const code = readConnectErrorDetailCode(params.details);
if (!code) {
return false;
}
const pairing = readPairingConnectErrorDetails(params.details);
if (
code === ConnectErrorDetailCodes.PAIRING_REQUIRED &&
(pairing?.pauseReconnect === false || pairing?.recommendedNextStep === "wait_then_retry")
) {
return false;
}
if (code === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
return params.tokenMismatchIsTerminal === true && !params.deviceTokenRetryPending;
}
return (
NON_RECOVERABLE_AUTH_ERRORS.has(code) ||
(params.protocolMismatchIsTerminal === true &&
code === ConnectErrorDetailCodes.PROTOCOL_MISMATCH) ||
(params.clientVersionMismatchIsTerminal === true &&
code === ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH)
);
}
+6 -3
View File
@@ -1991,12 +1991,12 @@ importers:
'@openclaw/gateway-protocol':
specifier: workspace:*
version: link:../gateway-protocol
'@openclaw/net-policy':
specifier: workspace:*
version: link:../net-policy
'@openclaw/retry':
specifier: workspace:*
version: link:../retry
ipaddr.js:
specifier: 2.4.0
version: 2.4.0
ws:
specifier: 8.21.0
version: 8.21.0
@@ -2097,6 +2097,9 @@ importers:
ui:
dependencies:
'@openclaw/gateway-client':
specifier: workspace:*
version: link:../packages/gateway-client
'@codemirror/commands':
specifier: 6.10.4
version: 6.10.4
+84 -78
View File
@@ -276,6 +276,10 @@ beforeAll(async () => {
await loadGatewayClientModule();
});
afterEach(() => {
vi.useRealTimers();
});
describe("GatewayClient security checks", () => {
const envSnapshot = captureEnv([
"OPENCLAW_ALLOW_INSECURE_PRIVATE_WS",
@@ -822,25 +826,21 @@ describe("GatewayClient close handling", () => {
it("keeps a managed reconnect timer after gateway restart closes", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
client.start();
getLatestWs().emitClose(1012, "service restart");
client.start();
getLatestWs().emitClose(1012, "service restart");
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(999);
expect(wsInstances).toHaveLength(1);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(999);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(1);
expect(wsInstances).toHaveLength(2);
client.stop();
} finally {
vi.useRealTimers();
}
expect(wsInstances).toHaveLength(2);
client.stop();
});
it("reconnects quietly after one clean pre-hello close with a pending connect", async () => {
@@ -987,99 +987,86 @@ describe("GatewayClient close handling", () => {
it("clears pending reconnect timers on stop", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
client.start();
getLatestWs().emitClose(1012, "service restart");
client.stop();
client.start();
getLatestWs().emitClose(1012, "service restart");
client.stop();
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
} finally {
vi.useRealTimers();
}
expect(wsInstances).toHaveLength(1);
});
it("force-terminates a lingering socket after stop", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
client.start();
const ws = getLatestWs();
ws.autoCloseOnClose = false;
client.start();
const ws = getLatestWs();
ws.autoCloseOnClose = false;
client.stop();
client.stop();
expect(ws.closeCalls).toBe(1);
expect(ws.terminateCalls).toBe(0);
expect(ws.closeCalls).toBe(1);
expect(ws.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(250);
await vi.advanceTimersByTimeAsync(250);
expect(ws.terminateCalls).toBe(1);
} finally {
vi.useRealTimers();
}
expect(ws.terminateCalls).toBe(1);
});
it("does not force-terminate a socket that closes during stop", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
const onClose = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
onClose,
});
client.start();
const ws = getLatestWs();
client.start();
const ws = getLatestWs();
client.stop();
client.stop();
expect(ws.closeCalls).toBe(1);
await vi.advanceTimersByTimeAsync(250);
expect(ws.closeCalls).toBe(1);
expect(onClose).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(250);
expect(ws.terminateCalls).toBe(0);
} finally {
vi.useRealTimers();
}
expect(ws.terminateCalls).toBe(0);
});
it("waits for a lingering socket to terminate in stopAndWait", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
client.start();
const ws = getLatestWs();
ws.autoCloseOnClose = false;
client.start();
const ws = getLatestWs();
ws.autoCloseOnClose = false;
let settled = false;
const stopPromise = client.stopAndWait().then(() => {
settled = true;
});
let settled = false;
const stopPromise = client.stopAndWait().then(() => {
settled = true;
});
expect(ws.closeCalls).toBe(1);
expect(settled).toBe(false);
expect(ws.closeCalls).toBe(1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(249);
expect(ws.terminateCalls).toBe(0);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(249);
expect(ws.terminateCalls).toBe(0);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await stopPromise;
await vi.advanceTimersByTimeAsync(1);
await stopPromise;
expect(ws.terminateCalls).toBe(1);
expect(settled).toBe(true);
} finally {
vi.useRealTimers();
}
expect(ws.terminateCalls).toBe(1);
expect(settled).toBe(true);
});
it("does not clear persisted device auth when explicit shared token is provided", () => {
@@ -1347,6 +1334,8 @@ describe("GatewayClient connect auth payload", () => {
expect(logDebugMock).toHaveBeenCalledWith(
"gateway client hello-ok handler error: Error: hello callback failed",
);
ws.emitClose(1012, "service restart");
expect(onConnectError).not.toHaveBeenCalled();
} finally {
client.stop();
}
@@ -1536,6 +1525,23 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("reports a transport close while the connect request is pending", () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
onConnectError,
});
const { ws } = startClientAndConnect({ client });
ws.emitClose(1006, "socket lost");
expect(firstMockArg(onConnectError, "connect error")).toMatchObject({
message: "gateway closed (1006): socket lost",
});
client.stop();
});
it("logs stopped connect handshakes at debug level during teardown", async () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
+4
View File
@@ -193,6 +193,10 @@ export const sharedVitestConfig = {
find: "@openclaw/whatsapp/api.js",
replacement: path.join(repoRoot, "extensions", "whatsapp", "api.ts"),
},
{
find: "@openclaw/gateway-client/browser",
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "browser.ts"),
},
{
find: "@openclaw/gateway-client/readiness",
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "readiness.ts"),
+1
View File
@@ -66,6 +66,7 @@
],
"@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"],
"@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"],
"@openclaw/gateway-client/browser": ["./packages/gateway-client/src/browser.ts"],
"@openclaw/gateway-client/*": ["./packages/gateway-client/src/*"],
"@openclaw/gateway-protocol": ["./packages/gateway-protocol/src/index.ts"],
"@openclaw/gateway-protocol/client-info": [
+3 -5
View File
@@ -383,6 +383,7 @@ function buildGatewayClientDistEntries(): Record<string, string> {
// Keep package entrypoints explicit so package.json exports and root build
// config cannot drift when client internals are split again.
index: "packages/gateway-client/src/index.ts",
browser: "packages/gateway-client/src/browser.ts",
readiness: "packages/gateway-client/src/readiness.ts",
timeouts: "packages/gateway-client/src/timeouts.ts",
};
@@ -566,11 +567,8 @@ function shouldExternalizeGatewayProtocolDependency(id: string): boolean {
}
function shouldExternalizeGatewayClientDependency(id: string): boolean {
return (
id === "ws" ||
id.startsWith("ws/") ||
id === "@openclaw/gateway-protocol" ||
id.startsWith("@openclaw/gateway-protocol/")
return ["ws", "@openclaw/net-policy", "@openclaw/gateway-protocol"].some(
(dependency) => id === dependency || id.startsWith(`${dependency}/`),
);
}
+1
View File
@@ -20,6 +20,7 @@
"@modelcontextprotocol/ext-apps": "1.7.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@noble/ed25519": "3.1.0",
"@openclaw/gateway-client": "workspace:*",
"@openclaw/libterminal": "0.3.1",
"@openclaw/media-core": "workspace:*",
"@openclaw/normalization-core": "workspace:*",
+17 -5
View File
@@ -1,11 +1,11 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js";
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
/** @vitest-environment node */
import {
ConnectErrorDetailCodes,
GATEWAY_CLIENT_CAPS,
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../../packages/gateway-protocol/src/version.js";
} from "@openclaw/gateway-client/browser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DeviceIdentity } from "../lib/nodes/index.ts";
import {
loadDeviceAuthToken as loadScopedDeviceAuthToken,
@@ -646,6 +646,9 @@ describe("GatewayBrowserClient", () => {
it("reports connect phase timing without credentials or nonce values", async () => {
const onConnectTiming = vi.fn();
vi.stubGlobal("performance", {
now: vi.fn().mockReturnValueOnce(10).mockReturnValueOnce(35).mockReturnValue(40),
});
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token",
@@ -661,6 +664,7 @@ describe("GatewayBrowserClient", () => {
"connect-plan-ready",
"request-sent",
]);
expect([sentPayloads[0]?.durationMs, sentPayloads[0]?.phaseDurationMs]).toEqual([25, 25]);
for (const payload of sentPayloads) {
expect(payload.generation).toBe(1);
expect(payload.durationMs).toBeTypeOf("number");
@@ -1566,11 +1570,13 @@ describe("GatewayBrowserClient", () => {
it("reports willRetry=false on credential rejections so the UI can fall back to the login gate", async () => {
useNodeFakeTimers();
const onClose = vi.fn();
const onConnectTiming = vi.fn();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
password: "wrong-password",
onClose,
onConnectTiming,
});
const { ws, connectFrame } = await startConnect(client);
@@ -1589,6 +1595,12 @@ describe("GatewayBrowserClient", () => {
const close = requireFirstMockArg(onClose, "close");
expect(close.willRetry).toBe(false);
expect(connectTimingPayloads(onConnectTiming).at(-1)).toMatchObject({
phase: "failed",
errorCode: "INVALID_REQUEST",
hasDeviceIdentity: true,
hasPassword: true,
});
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
+189 -661
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -14,6 +14,18 @@ import {
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const workspaceSourceAliases = [
{
find: "@openclaw/gateway-client/browser",
replacement: path.resolve(repoRoot, "packages/gateway-client/src/browser.ts"),
},
{
find: /^@openclaw\/gateway-protocol\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/gateway-protocol/src/$1.ts"),
},
{
find: /^@openclaw\/(gateway-protocol|retry)$/u,
replacement: path.resolve(repoRoot, "packages/$1/src/index.ts"),
},
{
find: "../logging/redact.js",
replacement: path.resolve(here, "src/lib/browser-redact.ts"),