1
0

feat: WS checker,支持可达性检测和单次请求-响应交互验证

This commit is contained in:
2026-05-25 14:13:43 +08:00
parent 714b635aef
commit c1db793073
20 changed files with 2339 additions and 4 deletions

View File

@@ -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 };

View File

@@ -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 {

View 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;
}

View 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" });
}

View File

@@ -0,0 +1 @@
export { WsChecker } from "./execute";

View 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 },
);
}

View 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;
}

View 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;
}