fix(rabbitmq): parse connection addresses without URL

Closes #5280
This commit is contained in:
t8y2 committed 2026-08-20 13:37:34 +08:00
1 parent f3cc8b7a01
commit fc67a323c4
11 files changed
+116 -29

No files matched your search

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeRabbitmqAddresses, parseRabbitmqAddress } from "@/lib/connection/rabbitmqAddresses";
describe("RabbitMQ addresses", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps comma-separated addresses", () => {
expect(normalizeRabbitmqAddresses("node1:5672, node2:5672")).toBe("node1:5672,node2:5672");
});
@@ -11,12 +15,36 @@ describe("RabbitMQ addresses", () => {
expect(normalizeRabbitmqAddresses("node1, node2:5673")).toBe("node1:5672,node2:5673");
});
it("keeps IPv4 addresses with custom ports", () => {
expect(normalizeRabbitmqAddresses("192.0.2.176:51016")).toBe("192.0.2.176:51016");
expect(parseRabbitmqAddress("192.0.2.176:51016")).toEqual({ host: "192.0.2.176", port: 51016 });
});
it("parses IPv4 addresses without the browser URL implementation", () => {
vi.stubGlobal(
"URL",
class {
constructor() {
throw new Error("custom schemes are unsupported");
}
},
);
expect(normalizeRabbitmqAddresses("192.0.2.176:51016")).toBe("192.0.2.176:51016");
expect(parseRabbitmqAddress("192.0.2.176:51016")).toEqual({ host: "192.0.2.176", port: 51016 });
});
it("normalizes common address separators to commas", () => {
expect(normalizeRabbitmqAddresses("node1:5672;node2:5672,node3:5672\nnode4:5672 node5:5672")).toBe("node1:5672,node2:5672,node3:5672,node4:5672,node5:5672");
});
it("keeps IPv6 addresses", () => {
expect(normalizeRabbitmqAddresses("[::1]:5672;[2001:db8::1]:5672")).toBe("[::1]:5672,[2001:db8::1]:5672");
expect(parseRabbitmqAddress("[::1]:5672")).toEqual({ host: "::1", port: 5672 });
});
it("appends the default port to IPv6 addresses without a port", () => {
expect(normalizeRabbitmqAddresses("[::1]")).toBe("[::1]:5672");
expect(parseRabbitmqAddress("[::1]")).toEqual({ host: "::1", port: 5672 });
});
it("rejects empty addresses", () => {
@@ -30,4 +58,10 @@ describe("RabbitMQ addresses", () => {
it("rejects invalid address values", () => {
expect(() => normalizeRabbitmqAddresses("node1:5672/path,node2:5672")).toThrow("RabbitMQ addresses are invalid");
});
it("rejects invalid ports", () => {
expect(() => normalizeRabbitmqAddresses("node1:")).toThrow("RabbitMQ addresses are invalid");
expect(() => normalizeRabbitmqAddresses("node1:0")).toThrow("RabbitMQ addresses are invalid");
expect(() => normalizeRabbitmqAddresses("node1:65536")).toThrow("RabbitMQ addresses are invalid");
});
});
@@ -1,5 +1,12 @@
const RABBITMQ_ADDRESS_SEPARATOR = /[\s,;,;]+/u;
const RABBITMQ_DEFAULT_PORT = "5672";
const RABBITMQ_PORT_PATTERN = /^\d+$/u;
const RABBITMQ_INVALID_HOST_CHARACTER = /[\s/?#@%\\\u200b-\u200d\ufeff]/u;
export interface RabbitmqAddress {
host: string;
port: number;
}
function requireRabbitmqAddresses(value: string): string {
const trimmed = value.trim();
@@ -7,20 +14,66 @@ function requireRabbitmqAddresses(value: string): string {
return trimmed;
}
function normalizeRabbitmqAddress(address: string): string {
function invalidRabbitmqAddresses(): never {
throw new Error("RabbitMQ addresses are invalid");
}
function validateRabbitmqPort(port: string): void {
if (!RABBITMQ_PORT_PATTERN.test(port)) invalidRabbitmqAddresses();
const parsed = Number(port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) invalidRabbitmqAddresses();
}
function validateRabbitmqHostname(host: string): void {
if (!host || RABBITMQ_INVALID_HOST_CHARACTER.test(host)) invalidRabbitmqAddresses();
}
function validateRabbitmqIpv6(host: string): void {
try {
const parsed = new URL(`http://[${host}]`);
if (!parsed.hostname || parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash || parsed.pathname !== "/") {
invalidRabbitmqAddresses();
}
} catch {
invalidRabbitmqAddresses();
}
}
export function parseRabbitmqAddress(address: string): RabbitmqAddress {
if (address.includes("://")) {
throw new Error("RabbitMQ addresses must be host:port values without a URL scheme");
}
let parsed: URL;
try {
parsed = new URL(`amqp://${address}`);
} catch {
throw new Error("RabbitMQ addresses are invalid");
if (address.startsWith("[")) {
const match = address.match(/^\[([^\]]+)\](?::([^:]+))?$/u);
if (!match) invalidRabbitmqAddresses();
const [, host, port] = match;
validateRabbitmqIpv6(host);
if (port) {
validateRabbitmqPort(port);
return { host, port: Number(port) };
}
return { host, port: Number(RABBITMQ_DEFAULT_PORT) };
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("RabbitMQ addresses are invalid");
if (address.includes("[") || address.includes("]")) invalidRabbitmqAddresses();
const separator = address.indexOf(":");
if (separator !== address.lastIndexOf(":")) invalidRabbitmqAddresses();
const host = separator >= 0 ? address.slice(0, separator) : address;
const port = separator >= 0 ? address.slice(separator + 1) : "";
if (!host || (separator >= 0 && !port)) invalidRabbitmqAddresses();
validateRabbitmqHostname(host);
if (port) {
validateRabbitmqPort(port);
return { host, port: Number(port) };
}
return parsed.port ? address : `${address}:${RABBITMQ_DEFAULT_PORT}`;
return { host, port: Number(RABBITMQ_DEFAULT_PORT) };
}
function normalizeRabbitmqAddress(address: string): string {
const parsed = parseRabbitmqAddress(address);
const hasExplicitPort = address.startsWith("[") ? address.includes("]:") : address.includes(":");
return hasExplicitPort ? address : `${address}:${parsed.port}`;
}
export function normalizeRabbitmqAddresses(value: string): string {