feat: WS checker,支持可达性检测和单次请求-响应交互验证
This commit is contained in:
@@ -96,6 +96,8 @@ function normalizeExpect(type: string, expect: unknown): unknown {
|
||||
return normalizeTcpExpect(raw);
|
||||
case "udp":
|
||||
return normalizeUdpExpect(raw);
|
||||
case "ws":
|
||||
return normalizeWsExpect(raw);
|
||||
default:
|
||||
return expect;
|
||||
}
|
||||
@@ -184,4 +186,14 @@ function normalizeValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeWsExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
connected: raw["connected"],
|
||||
connectTimeMs: normalizeValue(raw["connectTimeMs"]),
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
handshakeHeaders: normalizeKeyed(raw["handshakeHeaders"]),
|
||||
message: normalizeContent(raw["message"]),
|
||||
});
|
||||
}
|
||||
|
||||
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LlmChecker } from "./llm";
|
||||
import { CheckerRegistry } from "./registry";
|
||||
import { TcpChecker } from "./tcp";
|
||||
import { UdpChecker } from "./udp";
|
||||
import { WsChecker } from "./ws";
|
||||
|
||||
const checkers = [
|
||||
new HttpChecker(),
|
||||
@@ -17,6 +18,7 @@ const checkers = [
|
||||
new UdpChecker(),
|
||||
new LlmChecker(),
|
||||
new DnsChecker(),
|
||||
new WsChecker(),
|
||||
];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
|
||||
528
src/server/checker/runner/ws/execute.ts
Normal file
528
src/server/checker/runner/ws/execute.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { ResolvedWsExpectConfig, ResolvedWsTarget, WsTargetConfig } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkConnected, checkHandshakeHeaders, checkMessage } from "./expect";
|
||||
import { wsCheckerSchemas } from "./schema";
|
||||
import { validateWsConfig } from "./validate";
|
||||
|
||||
const DEFAULT_MAX_MESSAGE_BYTES = 4096;
|
||||
const DEFAULT_RECEIVE_TIMEOUT = 5000;
|
||||
|
||||
type MessageReceiveResult = { data: string; ok: true; size: number } | { error: string; ok: false };
|
||||
|
||||
type WsConnectResult = { error: string; ok: false } | { headers: Record<string, string>; ok: true; ws: WebSocket };
|
||||
|
||||
export class WsChecker implements CheckerDefinition<ResolvedWsTarget> {
|
||||
readonly configKey = "ws";
|
||||
|
||||
readonly schemas = wsCheckerSchemas;
|
||||
|
||||
readonly type = "ws";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const connected = observation["connected"];
|
||||
if (connected !== true) {
|
||||
const error = observation["error"];
|
||||
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||
}
|
||||
const connectTimeMs = observation["connectTimeMs"];
|
||||
const message = observation["message"];
|
||||
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
|
||||
if (typeof message === "string" && message.length > 0) {
|
||||
parts.push(`message: ${truncateMessage(message)}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedWsTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
const expect = t.expect;
|
||||
|
||||
try {
|
||||
const connectResult = await wsConnect(t.ws, ctx.signal);
|
||||
|
||||
if (!connectResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: false,
|
||||
connectTimeMs: null,
|
||||
error: connectResult.error,
|
||||
message: null,
|
||||
messageSize: null,
|
||||
};
|
||||
if (expect?.connected === false) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", connectResult.error),
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const ws = connectResult.ws;
|
||||
const connectTimeMs = Math.round(performance.now() - start);
|
||||
const handshakeHeaders = connectResult.headers;
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
closeWs(ws);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedConnected = expect?.connected ?? true;
|
||||
const connectedResult = checkConnected(true, expectedConnected);
|
||||
if (!connectedResult.matched) {
|
||||
closeWs(ws);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: connectedResult.failure,
|
||||
matched: false,
|
||||
observation: {
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
handshakeHeaders,
|
||||
message: null,
|
||||
messageSize: null,
|
||||
},
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (expect?.handshakeHeaders) {
|
||||
const headersResult = checkHandshakeHeaders(handshakeHeaders, expect.handshakeHeaders);
|
||||
if (!headersResult.matched) {
|
||||
closeWs(ws);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: headersResult.failure,
|
||||
matched: false,
|
||||
observation: {
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
handshakeHeaders,
|
||||
message: null,
|
||||
messageSize: null,
|
||||
},
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let messageText: null | string = null;
|
||||
let messageSize: null | number = null;
|
||||
|
||||
if (t.ws.send) {
|
||||
const messageResult: MessageReceiveResult = await wsSendAndReceive(
|
||||
ws,
|
||||
t.ws.send,
|
||||
t.ws.receiveTimeout,
|
||||
t.ws.maxMessageBytes,
|
||||
ctx.signal,
|
||||
);
|
||||
if (!messageResult.ok) {
|
||||
closeWs(ws);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: messageResult.error,
|
||||
handshakeHeaders,
|
||||
message: null,
|
||||
messageSize: null,
|
||||
};
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("message", "message", messageResult.error),
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
messageText = truncateMessageForObservation(messageResult.data);
|
||||
messageSize = messageResult.size;
|
||||
|
||||
if (expect?.message) {
|
||||
const msgCheck = checkMessage(messageResult.data, expect.message);
|
||||
if (!msgCheck.matched) {
|
||||
closeWs(ws);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: msgCheck.failure,
|
||||
matched: false,
|
||||
observation: {
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
handshakeHeaders,
|
||||
message: messageText,
|
||||
messageSize,
|
||||
},
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeWs(ws);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
handshakeHeaders,
|
||||
message: messageText,
|
||||
messageSize,
|
||||
};
|
||||
|
||||
if (expect?.connectTimeMs) {
|
||||
const ctResult = checkValueExpectation(connectTimeMs, expect.connectTimeMs, {
|
||||
message: "connectTimeMs mismatch",
|
||||
path: "connectTimeMs",
|
||||
phase: "connect",
|
||||
});
|
||||
if (!ctResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: ctResult.failure,
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"connect",
|
||||
"connect",
|
||||
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedWsTarget {
|
||||
const t = target as RawTargetConfig & { type: "ws"; ws: WsTargetConfig };
|
||||
|
||||
const maxMessageBytes = parseSize(t.ws.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES);
|
||||
const receiveTimeout = t.ws.receiveTimeout ?? DEFAULT_RECEIVE_TIMEOUT;
|
||||
|
||||
const expect = target.expect as ResolvedWsExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedWsExpectConfig = expect
|
||||
? { ...expect, connected: expect.connected ?? true }
|
||||
: { connected: true };
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "ws",
|
||||
ws: {
|
||||
headers: { ...(t.ws.headers ?? {}) },
|
||||
ignoreSSL: t.ws.ignoreSSL ?? false,
|
||||
maxMessageBytes,
|
||||
receiveTimeout,
|
||||
send: t.ws.send,
|
||||
subprotocols: t.ws.subprotocols ?? [],
|
||||
url: t.ws.url,
|
||||
},
|
||||
} satisfies ResolvedWsTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedWsTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
headers: t.ws.headers,
|
||||
ignoreSSL: t.ws.ignoreSSL,
|
||||
maxMessageBytes: t.ws.maxMessageBytes,
|
||||
receiveTimeout: t.ws.receiveTimeout,
|
||||
send: t.ws.send,
|
||||
subprotocols: t.ws.subprotocols,
|
||||
url: t.ws.url,
|
||||
}),
|
||||
target: t.ws.url,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateWsConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function closeWs(ws: WebSocket) {
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
} catch {
|
||||
/* best-effort close */
|
||||
}
|
||||
}
|
||||
|
||||
function simplifyConnectError(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
|
||||
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
|
||||
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
|
||||
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
|
||||
if (lower.includes("certificate") || lower.includes("cert") || lower.includes("ssl") || lower.includes("tls")) {
|
||||
return "tls error: certificate verification failed";
|
||||
}
|
||||
if (lower.includes("401") || lower.includes("unauthorized")) return "handshake failed: unauthorized (401)";
|
||||
if (lower.includes("403") || lower.includes("forbidden")) return "handshake failed: forbidden (403)";
|
||||
if (lower.includes("404") || lower.includes("not found")) return "handshake failed: not found (404)";
|
||||
if (lower.includes("handshake") || lower.includes("upgrade")) return `handshake failed: ${message}`;
|
||||
return message;
|
||||
}
|
||||
|
||||
function truncateMessage(message: string, maxLen = 80): string {
|
||||
if (message.length <= maxLen) return message;
|
||||
return `${message.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function truncateMessageForObservation(message: string, maxLen = 256): string {
|
||||
if (message.length <= maxLen) return message;
|
||||
return message.slice(0, maxLen);
|
||||
}
|
||||
|
||||
async function wsConnect(config: ResolvedWsTarget["ws"], signal: AbortSignal): Promise<WsConnectResult> {
|
||||
if (signal.aborted) {
|
||||
return { error: "连接已取消", ok: false };
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let resolveFn: ((result: WsConnectResult) => void) | undefined;
|
||||
const connectPromise = new Promise<WsConnectResult>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
|
||||
const settle = (result: WsConnectResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveFn!(result);
|
||||
};
|
||||
|
||||
try {
|
||||
const wsOptions: Bun.WebSocketOptions = {};
|
||||
if (Object.keys(config.headers).length > 0) {
|
||||
(wsOptions as Record<string, unknown>)["headers"] = config.headers;
|
||||
}
|
||||
if (config.ignoreSSL) {
|
||||
(wsOptions as Record<string, unknown>)["tls"] = { rejectUnauthorized: false };
|
||||
}
|
||||
if (config.subprotocols.length > 0) {
|
||||
(wsOptions as Record<string, unknown>)["protocols"] = config.subprotocols;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(config.url, wsOptions as never);
|
||||
|
||||
const onAbort = () => {
|
||||
settle({ error: "连接超时", ok: false });
|
||||
closeWs(ws);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
const headers: Record<string, string> = {};
|
||||
if (ws.protocol) {
|
||||
headers["sec-websocket-protocol"] = ws.protocol;
|
||||
}
|
||||
settle({ headers, ok: true, ws });
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
settle({ error: "连接失败", ok: false });
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
if (!settled) {
|
||||
const code = event.code;
|
||||
const reason = event.reason || "";
|
||||
if (code >= 1000 && code < 2000) {
|
||||
settle({
|
||||
error: `handshake failed: server closed with code ${code}${reason ? `: ${reason}` : ""}`,
|
||||
ok: false,
|
||||
});
|
||||
} else {
|
||||
settle({ error: "连接关闭", ok: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return await connectPromise;
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return { error: "连接超时", ok: false };
|
||||
}
|
||||
const message = isError(error) ? error.message : String(error);
|
||||
return { error: simplifyConnectError(message), ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function wsSendAndReceive(
|
||||
ws: WebSocket,
|
||||
sendText: string,
|
||||
receiveTimeout: number,
|
||||
maxMessageBytes: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<MessageReceiveResult> {
|
||||
let settled = false;
|
||||
let resolveFn: ((result: MessageReceiveResult) => void) | undefined;
|
||||
const messagePromise = new Promise<MessageReceiveResult>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
|
||||
const settle = (result: MessageReceiveResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveFn!(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle({ error: `等待响应超时 (${receiveTimeout}ms)`, ok: false });
|
||||
}, receiveTimeout);
|
||||
|
||||
const onAbort = () => {
|
||||
settle({ error: "探测已取消", ok: false });
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
ws.removeEventListener("message", onMessage);
|
||||
ws.removeEventListener("close", onClose);
|
||||
ws.removeEventListener("error", onError);
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (settled) return;
|
||||
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as Uint8Array);
|
||||
const size = new TextEncoder().encode(data).byteLength;
|
||||
if (size > maxMessageBytes) {
|
||||
settle({ error: `消息超过 ${maxMessageBytes} 字节限制 (${size} bytes)`, ok: false });
|
||||
return;
|
||||
}
|
||||
settle({ data, ok: true, size });
|
||||
};
|
||||
|
||||
const onClose = (event: CloseEvent) => {
|
||||
if (settled) return;
|
||||
const code = event.code;
|
||||
const reason = event.reason || "";
|
||||
settle({ error: `服务端关闭连接: code=${code}${reason ? ` reason=${reason}` : ""}`, ok: false });
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
if (settled) return;
|
||||
settle({ error: "连接错误", ok: false });
|
||||
};
|
||||
|
||||
ws.addEventListener("message", onMessage);
|
||||
ws.addEventListener("close", onClose);
|
||||
ws.addEventListener("error", onError);
|
||||
|
||||
try {
|
||||
ws.send(sendText);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
return { error: isError(error) ? error.message : "发送消息失败", ok: false };
|
||||
}
|
||||
|
||||
const result = await messagePromise;
|
||||
cleanup();
|
||||
return result;
|
||||
}
|
||||
30
src/server/checker/runner/ws/expect.ts
Normal file
30
src/server/checker/runner/ws/expect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ContentExpectations, ExpectationResult, KeyedExpectations } from "../../expect/types";
|
||||
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkHeaderExpectations } from "../../expect/headers";
|
||||
|
||||
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
|
||||
if (connected === expected) return { failure: null, matched: true };
|
||||
if (!connected && expected) {
|
||||
return {
|
||||
failure: mismatchFailure("connect", "connected", true, false, "期望 WebSocket 连接成功但连接失败"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
failure: mismatchFailure("connect", "connected", false, true, "期望 WebSocket 连接失败但连接成功"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkHandshakeHeaders(
|
||||
headers: Record<string, unknown>,
|
||||
expectations: KeyedExpectations | undefined,
|
||||
): ExpectationResult {
|
||||
return checkHeaderExpectations(headers, expectations);
|
||||
}
|
||||
|
||||
export function checkMessage(message: string, expectations: ContentExpectations): ExpectationResult {
|
||||
return checkContentExpectations(message, expectations, { path: "message", phase: "message" });
|
||||
}
|
||||
1
src/server/checker/runner/ws/index.ts
Normal file
1
src/server/checker/runner/ws/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WsChecker } from "./execute";
|
||||
66
src/server/checker/runner/ws/schema.ts
Normal file
66
src/server/checker/runner/ws/schema.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringKeyedExpectationsSchema,
|
||||
createAuthoringStringMapSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedKeyedExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
sizeSchema,
|
||||
stringMapSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const wsCheckerSchemas: CheckerSchemas = {
|
||||
authoring: {
|
||||
config: createWsConfigSchema("authoring"),
|
||||
expect: createWsExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createWsConfigSchema("normalized"),
|
||||
expect: createWsExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createWsConfigSchema(kind: "authoring" | "normalized") {
|
||||
const bool = Type.Boolean();
|
||||
const timeout = Type.Number({ minimum: 0 });
|
||||
return Type.Object(
|
||||
{
|
||||
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
|
||||
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||
maxMessageBytes: Type.Optional(sizeSchema),
|
||||
receiveTimeout: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(timeout) : timeout),
|
||||
send: Type.Optional(Type.String()),
|
||||
subprotocols: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createWsExpectSchema(kind: "authoring" | "normalized") {
|
||||
const connected = Type.Boolean();
|
||||
return Type.Object(
|
||||
{
|
||||
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
|
||||
connectTimeMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
handshakeHeaders: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||
),
|
||||
message: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
55
src/server/checker/runner/ws/types.ts
Normal file
55
src/server/checker/runner/ws/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
ContentExpectations,
|
||||
KeyedExpectations,
|
||||
RawContentExpectations,
|
||||
RawKeyedExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface RawWsExpectConfig {
|
||||
connected?: boolean;
|
||||
connectTimeMs?: RawValueExpectation;
|
||||
durationMs?: RawValueExpectation;
|
||||
handshakeHeaders?: RawKeyedExpectations;
|
||||
message?: RawContentExpectations;
|
||||
}
|
||||
|
||||
export interface ResolvedWsConfig {
|
||||
headers: Record<string, string>;
|
||||
ignoreSSL: boolean;
|
||||
maxMessageBytes: number;
|
||||
receiveTimeout: number;
|
||||
send?: string;
|
||||
subprotocols: string[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedWsExpectConfig {
|
||||
connected: boolean;
|
||||
connectTimeMs?: ValueExpectation;
|
||||
durationMs?: ValueExpectation;
|
||||
handshakeHeaders?: KeyedExpectations;
|
||||
message?: ContentExpectations;
|
||||
}
|
||||
|
||||
export interface ResolvedWsTarget extends ResolvedTargetBase {
|
||||
expect?: ResolvedWsExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "ws";
|
||||
ws: ResolvedWsConfig;
|
||||
}
|
||||
|
||||
export interface WsTargetConfig {
|
||||
headers?: Record<string, string>;
|
||||
ignoreSSL?: boolean;
|
||||
maxMessageBytes?: number | string;
|
||||
receiveTimeout?: number;
|
||||
send?: string;
|
||||
subprotocols?: string[];
|
||||
url: string;
|
||||
}
|
||||
215
src/server/checker/runner/ws/validate.ts
Normal file
215
src/server/checker/runner/ws/validate.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import {
|
||||
isPlainRecord,
|
||||
validateRawContentExpectations,
|
||||
validateRawKeyedExpectations,
|
||||
validateRawValueExpectation,
|
||||
} from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:"]);
|
||||
|
||||
export function validateWsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "ws") continue;
|
||||
issues.push(...validateWsTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function validateWsExpect(target: Record<string, unknown>, path: string, hasSend: boolean): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
const connectedFalse = expect["connected"] === false;
|
||||
|
||||
if (expect["handshakeHeaders"] !== undefined) {
|
||||
if (connectedFalse) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(expectPath, "handshakeHeaders"),
|
||||
"handshakeHeaders 断言需要 expect.connected 为 true",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
issues.push(
|
||||
...validateRawKeyedExpectations(
|
||||
expect["handshakeHeaders"],
|
||||
joinPath(expectPath, "handshakeHeaders"),
|
||||
targetName,
|
||||
{
|
||||
caseInsensitive: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (expect["message"] !== undefined) {
|
||||
if (!hasSend) {
|
||||
issues.push(issue("invalid-value", joinPath(expectPath, "message"), "message 断言需要配置 ws.send", targetName));
|
||||
} else if (connectedFalse) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(expectPath, "message"),
|
||||
"message 断言需要 expect.connected 为 true",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
issues.push(...validateRawContentExpectations(expect["message"], joinPath(expectPath, "message"), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (expect["connectTimeMs"] !== undefined) {
|
||||
if (connectedFalse) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(expectPath, "connectTimeMs"),
|
||||
"connectTimeMs 断言需要 expect.connected 为 true",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
issues.push(
|
||||
...validateRawValueExpectation(expect["connectTimeMs"], joinPath(expectPath, "connectTimeMs"), targetName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(["connected", "connectTimeMs", "durationMs", "handshakeHeaders", "message"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateWsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const ws = target["ws"];
|
||||
|
||||
if (!isPlainRecord(ws)) {
|
||||
issues.push(issue("required", joinPath(path, "ws"), "缺少 ws.url 字段", targetName));
|
||||
issues.push(...validateWsExpect(target, path, false));
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!isString(ws["url"]) || ws["url"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "ws"), "url"), "缺少 ws.url 字段", targetName));
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(ws["url"]);
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-url",
|
||||
joinPath(joinPath(path, "ws"), "url"),
|
||||
"格式不合法,必须以 ws:// 或 wss:// 开头",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
issues.push(issue("invalid-url", joinPath(joinPath(path, "ws"), "url"), "格式不合法", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (ws["subprotocols"] !== undefined) {
|
||||
if (!Array.isArray(ws["subprotocols"])) {
|
||||
issues.push(
|
||||
issue("invalid-type", joinPath(joinPath(path, "ws"), "subprotocols"), "必须为字符串数组", targetName),
|
||||
);
|
||||
} else {
|
||||
for (let i = 0; i < ws["subprotocols"].length; i++) {
|
||||
const sp = ws["subprotocols"][i] as unknown;
|
||||
if (!isString(sp) || sp.trim() === "") {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
`${joinPath(joinPath(path, "ws"), "subprotocols")}[${i}]`,
|
||||
"必须为非空字符串",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ws["ignoreSSL"] !== undefined && typeof ws["ignoreSSL"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(joinPath(path, "ws"), "ignoreSSL"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
if (
|
||||
ws["receiveTimeout"] !== undefined &&
|
||||
!(isNumber(ws["receiveTimeout"]) && Number.isFinite(ws["receiveTimeout"]) && ws["receiveTimeout"] >= 0)
|
||||
) {
|
||||
issues.push(
|
||||
issue("invalid-type", joinPath(joinPath(path, "ws"), "receiveTimeout"), "必须为非负有限数字", targetName),
|
||||
);
|
||||
}
|
||||
|
||||
if (ws["maxMessageBytes"] !== undefined) {
|
||||
if (
|
||||
!isString(ws["maxMessageBytes"]) &&
|
||||
!(isNumber(ws["maxMessageBytes"]) && Number.isFinite(ws["maxMessageBytes"]) && ws["maxMessageBytes"] >= 0)
|
||||
) {
|
||||
issues.push(
|
||||
issue("invalid-value", joinPath(joinPath(path, "ws"), "maxMessageBytes"), "必须为合法 size 值", targetName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedWsKeys = new Set([
|
||||
"headers",
|
||||
"ignoreSSL",
|
||||
"maxMessageBytes",
|
||||
"receiveTimeout",
|
||||
"send",
|
||||
"subprotocols",
|
||||
"url",
|
||||
]);
|
||||
for (const key of Object.keys(ws)) {
|
||||
if (!allowedWsKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "ws"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
const hasSend = isString(ws["send"]) && ws["send"].length > 0;
|
||||
issues.push(...validateWsExpect(target, path, hasSend));
|
||||
|
||||
return issues;
|
||||
}
|
||||
Reference in New Issue
Block a user