import type { z } from "zod";
import {
SearXNGApiError,
SearXNGNetworkError,
SearXNGResponseError,
SearXNGError,
} from "./errors.js";
/**
* Validate and get the SearXNG base URL
*/
function getSearXNGBaseUrl(): string {
const raw = process.env.SEARXNG_BASE_URL ?? "http://localhost:8080";
try {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error(`Invalid protocol: ${url.protocol}`);
}
// Ensure trailing slash for correct URL resolution with new URL(path, base)
return url.toString().replace(/\/+$/, "") + "/";
} catch {
throw new Error("Invalid SEARXNG_BASE_URL configuration");
}
}
const SEARXNG_BASE_URL = getSearXNGBaseUrl();
// Default timeout for search requests (30 seconds)
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Make a request to the SearXNG API with timeout, validation, and error handling
*/
export async function searxngRequest<T>(
endpoint: string,
params: Record<string, string> = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
responseSchema?: z.ZodSchema<T>
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const url = new URL(endpoint, SEARXNG_BASE_URL);
// Add query parameters
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
// Always request JSON format
url.searchParams.set("format", "json");
const response = await fetch(url.toString(), {
method: "GET",
signal: controller.signal,
headers: {
"Accept": "application/json",
},
});
if (!response.ok) {
const body = await response.text();
throw new SearXNGApiError(response.status, body);
}
const raw: unknown = await response.json();
// Validate response if schema provided
if (responseSchema) {
const parsed = responseSchema.safeParse(raw);
if (!parsed.success) {
throw new SearXNGResponseError(
`Invalid response shape: ${parsed.error.message}`,
parsed.error
);
}
return parsed.data;
}
return raw as T;
} catch (error) {
if (error instanceof SearXNGError) {
throw error;
}
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new SearXNGNetworkError("Request timed out", error);
}
if (
error.message.includes("fetch failed") ||
error.message.includes("ECONNREFUSED")
) {
throw new SearXNGNetworkError(
`Failed to connect to SearXNG: ${error.message}`,
error
);
}
}
throw error;
} finally {
clearTimeout(timeout);
}
}