1
0

feat: 新增 UDP checker,支持自定义 payload 请求-响应探测与断言

基于 Bun connected UDP socket 实现通用 UDP 拨测能力:
- 支持 text/hex/base64 payload 编码与独立 responseEncoding 响应视图
- 支持 responded、response、responseSize、sourceHost、sourcePort、maxDurationMs 专属 expect
- 单 datagram 发送,仅断言首个 UDP 响应 datagram
- 通过 maxResponseBytes 和 flags.truncated 进行响应大小限制与截断保护
- payload 可选,省略时发送空 datagram
- 自包含模块结构(types/schema/validate/expect/encoding/execute)
- 新增 741 tests(含 unit、execute 集成、expect 和编码 roundtrip),全部通过
This commit is contained in:
2026-05-18 17:23:17 +08:00
parent 550c427814
commit 52262a31f6
19 changed files with 2328 additions and 8 deletions

View File

@@ -4,8 +4,16 @@ import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker(), new TcpChecker(), new IcmpChecker()];
const checkers = [
new HttpChecker(),
new CommandChecker(),
new DbChecker(),
new TcpChecker(),
new IcmpChecker(),
new UdpChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();

View File

@@ -0,0 +1,49 @@
import type { UdpEncoding } from "./types";
export function decodePayload(payload: string, encoding: UdpEncoding): Uint8Array {
if (encoding === "hex") return hexToBytes(payload);
if (encoding === "base64") return base64ToBytes(payload);
return new TextEncoder().encode(payload);
}
export function encodeResponse(data: Uint8Array, encoding: UdpEncoding): string {
if (encoding === "hex") return bytesToHex(data);
if (encoding === "base64") return bytesToBase64(data);
return new TextDecoder().decode(data);
}
function base64ToBytes(base64: string): Uint8Array {
if (base64.length === 0) return new Uint8Array(0);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToBase64(data: Uint8Array): string {
let binary = "";
for (let i = 0; i < data.byteLength; i++) {
binary += String.fromCharCode(data[i]!);
}
return btoa(binary);
}
function bytesToHex(data: Uint8Array): string {
let hex = "";
for (let i = 0; i < data.byteLength; i++) {
hex += data[i]!.toString(16).padStart(2, "0");
}
return hex;
}
function hexToBytes(hex: string): Uint8Array {
if (hex.length === 0) return new Uint8Array(0);
if (hex.length % 2 !== 0) throw new Error("hex string must have even length");
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}

View File

@@ -0,0 +1,388 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
import { udpCheckerSchemas } from "./schema";
import { validateUdpConfig } from "./validate";
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
const RESPONSE_PREVIEW_MAX = 80;
type UdpExchangeResult =
| {
data: Uint8Array;
flags: { truncated: boolean };
ok: true;
responded: true;
sourceAddress: string;
sourcePort: number;
}
| { error: string; ok: false }
| {
ok: true;
responded: false;
};
export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
readonly configKey = "udp";
readonly schemas = udpCheckerSchemas;
readonly type = "udp";
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
const expect = t.expect;
try {
const payloadBytes = decodePayload(t.udp.payload, t.udp.encoding);
const exchangeResult = await udpExchange(t.udp.host, t.udp.port, payloadBytes, ctx.signal);
if (!exchangeResult.ok) {
const durationMs = Math.round(performance.now() - start);
if (expect?.responded === false) {
return {
durationMs,
failure: null,
matched: true,
statusDetail: exchangeResult.error,
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: errorFailure("response", "response", exchangeResult.error),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const expectedResponded = expect?.responded ?? true;
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
if (!respondedResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: respondedResult.failure,
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
if (!exchangeResult.responded) {
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildNoResponseDetail(durationMs),
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: buildNoResponseDetail(durationMs),
targetId: t.id,
timestamp,
};
}
if (exchangeResult.flags.truncated) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure(
"response",
"response",
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
if (expect?.responseSize) {
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
if (!sizeResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: sizeResult.failure,
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
if (expect?.response) {
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
const textResult = checkResponseText(responseText, expect.response);
if (!textResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: textResult.failure,
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
if (expect?.sourceHost) {
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
if (!sourceResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: sourceResult.failure,
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
if (expect?.sourcePort) {
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
if (!sourceResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: sourceResult.failure,
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildRespondedDetail(
exchangeResult.data.byteLength,
durationMs,
t.udp.responseEncoding,
exchangeResult.data,
),
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: buildRespondedDetail(
exchangeResult.data.byteLength,
durationMs,
t.udp.responseEncoding,
exchangeResult.data,
),
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
const udpDefaults = context.defaults["udp"] as UdpDefaultsConfig | undefined;
const encoding = t.udp.encoding ?? udpDefaults?.encoding ?? "text";
const responseEncoding = t.udp.responseEncoding ?? udpDefaults?.responseEncoding ?? "text";
const maxResponseBytes = parseSize(
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
);
return {
description: null,
expect: target.expect as UdpExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "udp",
udp: {
encoding,
host: t.udp.host,
maxResponseBytes,
payload: t.udp.payload ?? "",
port: t.udp.port,
responseEncoding,
},
} satisfies ResolvedUdpTarget;
}
serialize(t: ResolvedUdpTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.udp),
target: `udp ${t.udp.host}:${t.udp.port}`,
};
}
validate(input: CheckerValidationInput) {
return validateUdpConfig(input);
}
}
function buildNoResponseDetail(durationMs: number): string {
return `no response in ${durationMs}ms`;
}
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
let detail = `responded in ${durationMs}ms, ${size} bytes`;
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}` : preview;
detail = `${detail}, response: ${truncated}`;
}
return detail;
}
function simplifyUdpError(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 "timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
async function udpExchange(
hostname: string,
port: number,
payload: Uint8Array,
signal: AbortSignal,
): Promise<UdpExchangeResult> {
let settled = false;
let exchangeResolve: ((value: UdpExchangeResult) => void) | undefined;
const exchangePromise = new Promise<UdpExchangeResult>((resolve) => {
exchangeResolve = resolve;
});
const settle = (result: UdpExchangeResult) => {
if (settled) return;
settled = true;
exchangeResolve!(result);
};
try {
const socket = await Bun.udpSocket({
connect: { hostname, port },
socket: {
data(socket, data, _port, _address, flags) {
settle({
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
flags: { truncated: flags.truncated },
ok: true,
responded: true,
sourceAddress: _address,
sourcePort: _port,
});
try {
socket.close();
} catch {
/* best-effort */
}
},
drain() {
// Bun UDP socket handler 必填项UDP checker 不关注 drain 事件
},
error(socket, error) {
settle({ error: error.message, ok: false });
try {
socket.close();
} catch {
/* best-effort */
}
},
},
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
return { error: "探测已取消", ok: false };
}
socket.send(payload);
const onAbort = () => {
settle({ ok: true, responded: false });
try {
socket.close();
} catch {
/* best-effort */
}
};
signal.addEventListener("abort", onAbort, { once: true });
const result = await exchangePromise;
signal.removeEventListener("abort", onAbort);
return result;
} catch (error) {
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = isError(error) ? error.message : String(error);
return { error: simplifyUdpError(message), ok: false };
}
}

View File

@@ -0,0 +1,66 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator } from "../../types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
if (responded === expected) return { failure: null, matched: true };
if (!responded && expected) {
return {
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
matched: false,
};
}
return {
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
matched: false,
};
}
export function checkResponseSize(size: number, op: ExpectOperator): ExpectResult {
const matched = applyOperator(size, op);
if (!matched) {
return {
failure: mismatchFailure("responseSize", "responseSize", op, size, "响应大小不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkResponseText(text: string, rules: ExpectOperator[]): ExpectResult {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
const path = `response[${i}]`;
if (!applyOperator(text, rule)) {
return {
failure: mismatchFailure("response", path, rule, text, `response rule at index ${i} mismatch`),
matched: false,
};
}
}
return { failure: null, matched: true };
}
export function checkSourceHost(actual: string, op: ExpectOperator): ExpectResult {
const matched = applyOperator(actual, op);
if (!matched) {
return {
failure: mismatchFailure("sourceHost", "sourceHost", op, actual, "响应来源地址不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkSourcePort(actual: number, op: ExpectOperator): ExpectResult {
const matched = applyOperator(actual, op);
if (!matched) {
return {
failure: mismatchFailure("sourcePort", "sourcePort", op, actual, "响应来源端口不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
}

View File

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

View File

@@ -0,0 +1,38 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, createTextRulesSchema, sizeSchema } from "../../schema/fragments";
export const udpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
host: Type.String({ minLength: 1 }),
maxResponseBytes: Type.Optional(sizeSchema),
payload: Type.Optional(Type.String()),
port: Type.Integer({ maximum: 65535, minimum: 1 }),
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
maxResponseBytes: Type.Optional(sizeSchema),
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
responded: Type.Optional(Type.Boolean()),
response: Type.Optional(createTextRulesSchema()),
responseSize: Type.Optional(createPureOperatorSchema()),
sourceHost: Type.Optional(createPureOperatorSchema()),
sourcePort: Type.Optional(createPureOperatorSchema()),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,46 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface ResolvedUdpConfig {
encoding: UdpEncoding;
host: string;
maxResponseBytes: number;
payload: string;
port: number;
responseEncoding: UdpEncoding;
}
export interface ResolvedUdpTarget extends ResolvedTargetBase {
expect?: UdpExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "udp";
udp: ResolvedUdpConfig;
}
export interface UdpDefaultsConfig {
encoding?: UdpEncoding;
maxResponseBytes?: number | string;
responseEncoding?: UdpEncoding;
}
export type UdpEncoding = "base64" | "hex" | "text";
export interface UdpExpectConfig {
maxDurationMs?: number;
responded?: boolean;
response?: ExpectOperator[];
responseSize?: ExpectOperator;
sourceHost?: ExpectOperator;
sourcePort?: ExpectOperator;
}
export interface UdpTargetConfig {
encoding?: UdpEncoding;
host: string;
maxResponseBytes?: number | string;
payload?: string;
port: number;
responseEncoding?: UdpEncoding;
}

View File

@@ -0,0 +1,227 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
export function validateUdpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
issues.push(...validateUdpDefaults(input));
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (target["type"] !== "udp") continue;
issues.push(...validateUdpTarget(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 isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateEncoding(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) || !VALID_ENCODINGS.has(value)) {
return [issue("invalid-value", path, "必须为 text、hex 或 base64", targetName)];
}
return [];
}
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) && !(isNumber(value) && Number.isFinite(value) && value >= 0)) {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
return [];
}
function validateTextRulesArray(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (!Array.isArray(value)) {
return [issue("invalid-type", path, "必须为数组", targetName)];
}
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < value.length; i++) {
const rule: unknown = value[i];
if (!isPlainObject(rule)) {
issues.push(issue("invalid-type", joinPath(path, `[${i}]`), "必须为 operator 对象", targetName));
continue;
}
issues.push(...validateOperatorObject(rule, joinPath(path, `[${i}]`), targetName));
}
return issues;
}
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["udp"];
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
const targetName = "defaults.udp";
issues.push(...validateEncoding(defaults["encoding"], joinPath("defaults.udp", "encoding"), targetName));
issues.push(
...validateEncoding(defaults["responseEncoding"], joinPath("defaults.udp", "responseEncoding"), targetName),
);
issues.push(...validateSize(defaults["maxResponseBytes"], joinPath("defaults.udp", "maxResponseBytes"), targetName));
const allowedKeys = new Set(["encoding", "maxResponseBytes", "responseEncoding"]);
for (const key of Object.keys(defaults)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath("defaults.udp", key), "是未知字段", targetName));
}
}
return issues;
}
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
const responded: unknown = expect["responded"];
if (responded !== undefined && typeof responded !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
if (expect["response"] !== undefined) {
issues.push(...validateTextRulesArray(expect["response"], joinPath(expectPath, "response"), targetName));
}
if (expect["responseSize"] !== undefined) {
issues.push(...validateOperatorObject(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
}
if (expect["sourceHost"] !== undefined) {
issues.push(...validateOperatorObject(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
}
if (expect["sourcePort"] !== undefined) {
issues.push(...validateOperatorObject(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
}
const respondedFalse = responded === false;
if (respondedFalse) {
if (expect["response"] !== undefined || expect["responseSize"] !== undefined) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "responded"),
"响应内容或大小断言需要 expect.responded 为 true",
targetName,
),
);
}
if (expect["sourceHost"] !== undefined || expect["sourcePort"] !== undefined) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "responded"),
"响应来源断言需要 expect.responded 为 true",
targetName,
),
);
}
}
const allowedKeys = new Set(["maxDurationMs", "responded", "response", "responseSize", "sourceHost", "sourcePort"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateUdpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const udp = target["udp"];
if (!isPlainObject(udp)) {
issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName));
issues.push(...validateUdpExpect(target, path));
return issues;
}
if (!isString(udp["host"]) || udp["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "udp"), "host"), "缺少 udp.host 字段", targetName));
}
if (udp["port"] === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "udp"), "port"), "缺少 udp.port 字段", targetName));
} else if (!isNumber(udp["port"]) || !Number.isInteger(udp["port"]) || udp["port"] < 1 || udp["port"] > 65535) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "udp"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
const encoding: unknown = udp["encoding"];
issues.push(...validateEncoding(encoding, joinPath(joinPath(path, "udp"), "encoding"), targetName));
if (encoding === "hex" && isString(udp["payload"])) {
const hexPattern = /^[0-9a-fA-F]*$/;
if (!hexPattern.test(udp["payload"])) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "udp"), "payload"),
"udp.payload 与 udp.encoding 不匹配",
targetName,
),
);
}
}
if (encoding === "base64" && isString(udp["payload"])) {
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Pattern.test(udp["payload"])) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "udp"), "payload"),
"udp.payload 与 udp.encoding 不匹配",
targetName,
),
);
}
}
const responseEncoding: unknown = udp["responseEncoding"];
issues.push(...validateEncoding(responseEncoding, joinPath(joinPath(path, "udp"), "responseEncoding"), targetName));
issues.push(
...validateSize(udp["maxResponseBytes"], joinPath(joinPath(path, "udp"), "maxResponseBytes"), targetName),
);
const allowedUdpKeys = new Set(["encoding", "host", "maxResponseBytes", "payload", "port", "responseEncoding"]);
for (const key of Object.keys(udp)) {
if (!allowedUdpKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "udp"), key), "是未知字段", targetName));
}
}
issues.push(...validateUdpExpect(target, path));
return issues;
}