Skip to content

Commit e62ab45

Browse files
committed
refactor(gateway): consolidate client contracts
1 parent 534ace4 commit e62ab45

11 files changed

Lines changed: 208 additions & 239 deletions

File tree

packages/gateway-client/src/client.ts

Lines changed: 25 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import { randomUUID } from "node:crypto";
33
import type {
44
ConnectParams,
5+
ErrorShape,
56
EventFrame,
67
HelloOk,
78
RequestFrame,
8-
ResponseFrame,
99
} from "@openclaw/gateway-protocol";
1010
import {
1111
GATEWAY_CLIENT_MODES,
@@ -21,6 +21,10 @@ import {
2121
readPairingConnectErrorDetails,
2222
type ConnectErrorRecoveryAdvice,
2323
} from "@openclaw/gateway-protocol/connect-error-details";
24+
import {
25+
isGatewayEventFrame,
26+
isGatewayResponseFrame,
27+
} from "@openclaw/gateway-protocol/frame-guards";
2428
import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable";
2529
import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
2630
import ipaddr from "ipaddr.js";
@@ -78,63 +82,6 @@ function normalizeOptionalString(value: unknown): string | undefined {
7882
return trimmed || undefined;
7983
}
8084

81-
function isRecord(value: unknown): value is Record<string, unknown> {
82-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
83-
}
84-
85-
function isNonEmptyString(value: unknown): value is string {
86-
return typeof value === "string" && value.length > 0;
87-
}
88-
89-
function isNonNegativeInteger(value: unknown): value is number {
90-
return typeof value === "number" && Number.isInteger(value) && value >= 0;
91-
}
92-
93-
function isGatewayClientErrorShape(value: unknown): boolean {
94-
if (!isRecord(value)) {
95-
return false;
96-
}
97-
if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) {
98-
return false;
99-
}
100-
if (value.retryable !== undefined && typeof value.retryable !== "boolean") {
101-
return false;
102-
}
103-
if (value.retryAfterMs !== undefined && !isNonNegativeInteger(value.retryAfterMs)) {
104-
return false;
105-
}
106-
return true;
107-
}
108-
109-
function isGatewayEventFrame(value: unknown): value is EventFrame {
110-
if (!isRecord(value) || value.type !== "event" || !isNonEmptyString(value.event)) {
111-
return false;
112-
}
113-
return value.seq === undefined || isNonNegativeInteger(value.seq);
114-
}
115-
116-
function isGatewayResponseFrame(value: unknown): value is ResponseFrame {
117-
if (
118-
!isRecord(value) ||
119-
value.type !== "res" ||
120-
!isNonEmptyString(value.id) ||
121-
typeof value.ok !== "boolean"
122-
) {
123-
return false;
124-
}
125-
return value.error === undefined || isGatewayClientErrorShape(value.error);
126-
}
127-
128-
function validateClientRequestFrame(frame: RequestFrame): string | null {
129-
if (!isNonEmptyString(frame.id)) {
130-
return "id must be a non-empty string";
131-
}
132-
if (!isNonEmptyString(frame.method)) {
133-
return "method must be a non-empty string";
134-
}
135-
return null;
136-
}
137-
13885
function normalizeLowercaseStringOrEmpty(value: unknown): string {
13986
return typeof value === "string" ? value.trim().toLowerCase() : "";
14087
}
@@ -314,14 +261,6 @@ export type GatewayClientRequestOptions = {
314261
onAccepted?: (payload: unknown) => void;
315262
};
316263

317-
type GatewayClientErrorShape = {
318-
code?: string;
319-
message?: string;
320-
details?: unknown;
321-
retryable?: boolean;
322-
retryAfterMs?: number;
323-
};
324-
325264
type SelectedConnectAuth = {
326265
authToken?: string;
327266
authBootstrapToken?: string;
@@ -376,7 +315,7 @@ export class GatewayClientRequestError extends Error {
376315
readonly retryable: boolean;
377316
readonly retryAfterMs?: number;
378317

379-
constructor(error: GatewayClientErrorShape) {
318+
constructor(error: Partial<ErrorShape>) {
380319
super(formatConnectErrorMessage({ message: error.message, details: error.details }));
381320
this.name = "GatewayClientRequestError";
382321
this.gatewayCode = error.code ?? "UNAVAILABLE";
@@ -461,6 +400,13 @@ export type GatewayClientOptions = {
461400
onGap?: (info: { expected: number; received: number }) => void;
462401
};
463402

403+
export type GatewayClientConnectionMetadata = {
404+
clientName?: GatewayClientName;
405+
hasDeviceIdentity: boolean;
406+
mode?: GatewayClientMode;
407+
preauthHandshakeTimeoutMs?: number;
408+
};
409+
464410
export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
465411
1000: "normal closure",
466412
1006: "abnormal closure (no close frame)",
@@ -595,6 +541,15 @@ export class GatewayClient {
595541
: 30_000;
596542
}
597543

544+
getConnectionMetadata(): GatewayClientConnectionMetadata {
545+
return {
546+
clientName: this.opts.clientName,
547+
hasDeviceIdentity: Boolean(this.opts.deviceIdentity),
548+
mode: this.opts.mode,
549+
preauthHandshakeTimeoutMs: this.opts.preauthHandshakeTimeoutMs,
550+
};
551+
}
552+
598553
start() {
599554
if (this.closed) {
600555
return;
@@ -1639,12 +1594,11 @@ export class GatewayClient {
16391594
if (opts?.signal?.aborted) {
16401595
throw createGatewayRequestAbortError(method);
16411596
}
1597+
if (typeof method !== "string" || method.length === 0) {
1598+
throw new Error("invalid request frame: method must be a non-empty string");
1599+
}
16421600
const id = randomUUID();
16431601
const frame: RequestFrame = { type: "req", id, method, params };
1644-
const requestFrameError = validateClientRequestFrame(frame);
1645-
if (requestFrameError) {
1646-
throw new Error(`invalid request frame: ${requestFrameError}`);
1647-
}
16481602
const expectFinal = opts?.expectFinal === true;
16491603
const timeoutMs =
16501604
opts?.timeoutMs === null

packages/gateway-client/src/client.watchdog.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ describe("GatewayClient", () => {
149149
).toBe(6_000);
150150
});
151151

152+
test("returns non-sensitive connection metadata", () => {
153+
const client = new GatewayClient({
154+
clientName: "cli",
155+
mode: "backend",
156+
preauthHandshakeTimeoutMs: 30_000,
157+
deviceIdentity: {
158+
deviceId: "device-1",
159+
privateKeyPem: "private-key",
160+
publicKeyPem: "public-key",
161+
},
162+
});
163+
164+
expect(client.getConnectionMetadata()).toEqual({
165+
clientName: "cli",
166+
hasDeviceIdentity: true,
167+
mode: "backend",
168+
preauthHandshakeTimeoutMs: 30_000,
169+
});
170+
});
171+
152172
test("closes on missing ticks", async () => {
153173
const port = await getFreePort();
154174
wss = new WebSocketServer({ port, host: "127.0.0.1" });

packages/gateway-protocol/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
"import": "./dist/connect-error-details.mjs",
2525
"default": "./dist/connect-error-details.mjs"
2626
},
27+
"./frame-guards": {
28+
"types": "./dist/frame-guards.d.mts",
29+
"import": "./dist/frame-guards.mjs",
30+
"default": "./dist/frame-guards.mjs"
31+
},
2732
"./schema": {
2833
"types": "./dist/schema.d.mts",
2934
"import": "./dist/schema.mjs",
@@ -41,7 +46,7 @@
4146
}
4247
},
4348
"scripts": {
44-
"build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
49+
"build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
4550
},
4651
"dependencies": {
4752
"typebox": "1.3.3"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isGatewayEventFrame, isGatewayResponseFrame } from "./frame-guards.js";
3+
4+
describe("gateway frame guards", () => {
5+
it("accepts additive event fields while validating dispatch fields", () => {
6+
expect(
7+
isGatewayEventFrame({
8+
type: "event",
9+
event: "tick",
10+
seq: 0,
11+
payload: { future: true },
12+
futureEnvelopeField: true,
13+
}),
14+
).toBe(true);
15+
expect(isGatewayEventFrame({ type: "event", event: "", seq: 0 })).toBe(false);
16+
expect(isGatewayEventFrame({ type: "event", event: "tick", seq: -1 })).toBe(false);
17+
});
18+
19+
it("accepts additive response fields while validating errors", () => {
20+
expect(
21+
isGatewayResponseFrame({
22+
type: "res",
23+
id: "request-1",
24+
ok: false,
25+
error: {
26+
code: "UNAVAILABLE",
27+
message: "try later",
28+
retryable: true,
29+
retryAfterMs: 10,
30+
},
31+
futureEnvelopeField: true,
32+
}),
33+
).toBe(true);
34+
expect(
35+
isGatewayResponseFrame({
36+
type: "res",
37+
id: "request-1",
38+
ok: false,
39+
error: { code: "UNAVAILABLE", message: "", retryAfterMs: 10 },
40+
}),
41+
).toBe(false);
42+
expect(
43+
isGatewayResponseFrame({
44+
type: "res",
45+
id: "request-1",
46+
ok: false,
47+
error: { code: "UNAVAILABLE", message: "try later", retryAfterMs: -1 },
48+
}),
49+
).toBe(false);
50+
});
51+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { EventFrame, ResponseFrame } from "./schema/types.js";
2+
3+
function isRecord(value: unknown): value is Record<string, unknown> {
4+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5+
}
6+
7+
function isNonEmptyString(value: unknown): value is string {
8+
return typeof value === "string" && value.length > 0;
9+
}
10+
11+
function isNonNegativeInteger(value: unknown): value is number {
12+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
13+
}
14+
15+
function isGatewayErrorShape(value: unknown): boolean {
16+
if (!isRecord(value)) {
17+
return false;
18+
}
19+
if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) {
20+
return false;
21+
}
22+
if (value.retryable !== undefined && typeof value.retryable !== "boolean") {
23+
return false;
24+
}
25+
return value.retryAfterMs === undefined || isNonNegativeInteger(value.retryAfterMs);
26+
}
27+
28+
// These lightweight guards validate dispatch-critical envelope fields without
29+
// compiling the full schemas or rejecting additive payload fields.
30+
export function isGatewayEventFrame(value: unknown): value is EventFrame {
31+
if (!isRecord(value) || value.type !== "event" || !isNonEmptyString(value.event)) {
32+
return false;
33+
}
34+
return value.seq === undefined || isNonNegativeInteger(value.seq);
35+
}
36+
37+
export function isGatewayResponseFrame(value: unknown): value is ResponseFrame {
38+
if (
39+
!isRecord(value) ||
40+
value.type !== "res" ||
41+
!isNonEmptyString(value.id) ||
42+
typeof value.ok !== "boolean"
43+
) {
44+
return false;
45+
}
46+
return value.error === undefined || isGatewayErrorShape(value.error);
47+
}

0 commit comments

Comments
 (0)