Spaces:
Running
Running
Sync from felladrin/MiniSearch@f8cb3e1 (part 2)
Browse files- server/fallbackSearchService.test.ts +835 -0
- server/fallbackSearchService.ts +345 -0
- server/searchEndpointServerHook.test.ts +267 -0
- server/searchEndpointServerHook.ts +67 -6
- server/searchesSinceLastRestart.ts +25 -0
- server/statusEndpointServerHook.test.ts +24 -0
- server/statusEndpointServerHook.ts +4 -0
server/fallbackSearchService.test.ts
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import debug from "debug";
|
| 2 |
+
import {
|
| 3 |
+
afterEach,
|
| 4 |
+
beforeEach,
|
| 5 |
+
describe,
|
| 6 |
+
expect,
|
| 7 |
+
it,
|
| 8 |
+
type MockedFunction,
|
| 9 |
+
vi,
|
| 10 |
+
} from "vitest";
|
| 11 |
+
import {
|
| 12 |
+
fetchFallbackTextResults,
|
| 13 |
+
isSearchFallbackEnabled,
|
| 14 |
+
} from "./fallbackSearchService";
|
| 15 |
+
|
| 16 |
+
function createMockResponse(
|
| 17 |
+
body: string,
|
| 18 |
+
{
|
| 19 |
+
status,
|
| 20 |
+
ok = status === undefined ? true : status >= 200 && status < 300,
|
| 21 |
+
headers = {},
|
| 22 |
+
contentType = "application/json",
|
| 23 |
+
unreadBody = null,
|
| 24 |
+
}: {
|
| 25 |
+
ok?: boolean;
|
| 26 |
+
status?: number;
|
| 27 |
+
headers?: Record<string, string>;
|
| 28 |
+
contentType?: string;
|
| 29 |
+
unreadBody?: { cancel: () => Promise<void> } | null;
|
| 30 |
+
} = {},
|
| 31 |
+
): Response {
|
| 32 |
+
const resolvedStatus = status ?? (ok ? 200 : 503);
|
| 33 |
+
return {
|
| 34 |
+
ok,
|
| 35 |
+
status: resolvedStatus,
|
| 36 |
+
statusText: ok ? "OK" : "Error",
|
| 37 |
+
headers: new Headers({ "content-type": contentType, ...headers }),
|
| 38 |
+
redirected: false,
|
| 39 |
+
type: "basic" as ResponseType,
|
| 40 |
+
url: "http://test.com",
|
| 41 |
+
clone: function () {
|
| 42 |
+
return this;
|
| 43 |
+
},
|
| 44 |
+
body: unreadBody,
|
| 45 |
+
bodyUsed: false,
|
| 46 |
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
| 47 |
+
blob: () => Promise.resolve(new Blob()),
|
| 48 |
+
formData: () => Promise.resolve(new FormData()),
|
| 49 |
+
json: () => Promise.resolve(JSON.parse(body)),
|
| 50 |
+
text: () => Promise.resolve(body),
|
| 51 |
+
} as unknown as Response;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const SESSION_ID = "session-from-step-one";
|
| 55 |
+
const QUERY = "borogoves outgrabe mimsy-42";
|
| 56 |
+
// The module's own cap on the exchange; every test that is not about the
|
| 57 |
+
// caller's budget passes it as the budget.
|
| 58 |
+
const FULL_BUDGET_MS = 15_000;
|
| 59 |
+
|
| 60 |
+
const INITIALIZE_REPLY = JSON.stringify({
|
| 61 |
+
jsonrpc: "2.0",
|
| 62 |
+
id: 1,
|
| 63 |
+
result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} },
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const NOTIFICATION_ACCEPTED = "";
|
| 67 |
+
|
| 68 |
+
function toolReply(document: unknown): string {
|
| 69 |
+
return JSON.stringify({
|
| 70 |
+
jsonrpc: "2.0",
|
| 71 |
+
id: 2,
|
| 72 |
+
result: {
|
| 73 |
+
content: [{ type: "text", text: JSON.stringify(document) }],
|
| 74 |
+
},
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function sseReply(message: string): string {
|
| 79 |
+
return `event: message\ndata: ${message}\n\n`;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function documentWith(results: unknown[]): string {
|
| 83 |
+
return toolReply({ search_id: "search-1", results });
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const EXCERPT =
|
| 87 |
+
"The opening sentence of a very long excerpt that has to survive the filter. alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec";
|
| 88 |
+
|
| 89 |
+
const LONG_EXCERPT = `${EXCERPT} romeo sierra tango uniform victor whiskey xray yankee zulu alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu`;
|
| 90 |
+
|
| 91 |
+
let originalFetch: typeof fetch;
|
| 92 |
+
let fetchMock: MockedFunction<typeof fetch>;
|
| 93 |
+
|
| 94 |
+
const originalEnabled = process.env.SEARCH_FALLBACK_ENABLED;
|
| 95 |
+
const originalApiKey = process.env.SEARCH_FALLBACK_API_KEY;
|
| 96 |
+
|
| 97 |
+
beforeEach(() => {
|
| 98 |
+
delete process.env.SEARCH_FALLBACK_ENABLED;
|
| 99 |
+
delete process.env.SEARCH_FALLBACK_API_KEY;
|
| 100 |
+
originalFetch = global.fetch;
|
| 101 |
+
fetchMock = vi.fn() as unknown as MockedFunction<typeof fetch>;
|
| 102 |
+
global.fetch = fetchMock;
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
afterEach(() => {
|
| 106 |
+
global.fetch = originalFetch;
|
| 107 |
+
if (originalEnabled === undefined) delete process.env.SEARCH_FALLBACK_ENABLED;
|
| 108 |
+
else process.env.SEARCH_FALLBACK_ENABLED = originalEnabled;
|
| 109 |
+
if (originalApiKey === undefined) delete process.env.SEARCH_FALLBACK_API_KEY;
|
| 110 |
+
else process.env.SEARCH_FALLBACK_API_KEY = originalApiKey;
|
| 111 |
+
vi.restoreAllMocks();
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
/** Answers the three requests in order with the given tool-call reply. */
|
| 115 |
+
function mockExchange(
|
| 116 |
+
toolBody: string,
|
| 117 |
+
{
|
| 118 |
+
initializeBody = INITIALIZE_REPLY,
|
| 119 |
+
initializeContentType = "application/json",
|
| 120 |
+
toolContentType = "application/json",
|
| 121 |
+
}: {
|
| 122 |
+
initializeBody?: string;
|
| 123 |
+
initializeContentType?: string;
|
| 124 |
+
toolContentType?: string;
|
| 125 |
+
} = {},
|
| 126 |
+
) {
|
| 127 |
+
fetchMock
|
| 128 |
+
.mockResolvedValueOnce(
|
| 129 |
+
createMockResponse(initializeBody, {
|
| 130 |
+
contentType: initializeContentType,
|
| 131 |
+
headers: { "mcp-session-id": SESSION_ID },
|
| 132 |
+
}),
|
| 133 |
+
)
|
| 134 |
+
.mockResolvedValueOnce(
|
| 135 |
+
createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
|
| 136 |
+
)
|
| 137 |
+
.mockResolvedValueOnce(
|
| 138 |
+
createMockResponse(toolBody, { contentType: toolContentType }),
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function requestBody(index: number): {
|
| 143 |
+
jsonrpc?: string;
|
| 144 |
+
id?: number;
|
| 145 |
+
method?: string;
|
| 146 |
+
params?: {
|
| 147 |
+
name?: string;
|
| 148 |
+
arguments?: Record<string, unknown>;
|
| 149 |
+
protocolVersion?: string;
|
| 150 |
+
capabilities?: unknown;
|
| 151 |
+
clientInfo?: unknown;
|
| 152 |
+
};
|
| 153 |
+
} {
|
| 154 |
+
const [, init] = fetchMock.mock.calls[index];
|
| 155 |
+
return JSON.parse(String(init?.body));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function requestHeaders(index: number): Record<string, string> {
|
| 159 |
+
const [, init] = fetchMock.mock.calls[index];
|
| 160 |
+
return init?.headers as Record<string, string>;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
describe("isSearchFallbackEnabled", () => {
|
| 164 |
+
it("is off when the variable is unset", () => {
|
| 165 |
+
expect(isSearchFallbackEnabled()).toBe(false);
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
it("accepts 'true' and ' TRUE ' and '1'", () => {
|
| 169 |
+
process.env.SEARCH_FALLBACK_ENABLED = "true";
|
| 170 |
+
expect(isSearchFallbackEnabled()).toBe(true);
|
| 171 |
+
|
| 172 |
+
process.env.SEARCH_FALLBACK_ENABLED = " TRUE ";
|
| 173 |
+
expect(isSearchFallbackEnabled()).toBe(true);
|
| 174 |
+
|
| 175 |
+
process.env.SEARCH_FALLBACK_ENABLED = "1";
|
| 176 |
+
expect(isSearchFallbackEnabled()).toBe(true);
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
it("rejects a value that is not true or 1", () => {
|
| 180 |
+
process.env.SEARCH_FALLBACK_ENABLED = "yes";
|
| 181 |
+
expect(isSearchFallbackEnabled()).toBe(false);
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
it("reads the variable at call time", () => {
|
| 185 |
+
expect(isSearchFallbackEnabled()).toBe(false);
|
| 186 |
+
process.env.SEARCH_FALLBACK_ENABLED = "true";
|
| 187 |
+
expect(isSearchFallbackEnabled()).toBe(true);
|
| 188 |
+
});
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
describe("the three requests", () => {
|
| 192 |
+
it("runs initialize, the initialized notification and the tool call in order", async () => {
|
| 193 |
+
mockExchange(documentWith([]));
|
| 194 |
+
|
| 195 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 196 |
+
|
| 197 |
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
| 198 |
+
expect(requestBody(0)).toEqual({
|
| 199 |
+
jsonrpc: "2.0",
|
| 200 |
+
id: 1,
|
| 201 |
+
method: "initialize",
|
| 202 |
+
params: {
|
| 203 |
+
protocolVersion: "2025-06-18",
|
| 204 |
+
capabilities: {},
|
| 205 |
+
clientInfo: { name: "MiniSearch", version: "1" },
|
| 206 |
+
},
|
| 207 |
+
});
|
| 208 |
+
expect(requestBody(1)).toEqual({
|
| 209 |
+
jsonrpc: "2.0",
|
| 210 |
+
method: "notifications/initialized",
|
| 211 |
+
});
|
| 212 |
+
expect(requestBody(2).method).toBe("tools/call");
|
| 213 |
+
expect(requestBody(2).params?.name).toBe("web_search");
|
| 214 |
+
expect(requestBody(2).params?.arguments).toEqual({
|
| 215 |
+
objective: QUERY,
|
| 216 |
+
search_queries: [QUERY],
|
| 217 |
+
session_id: expect.any(String),
|
| 218 |
+
});
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
it("carries the session id from step 1 onto steps 2 and 3, with the protocol version", async () => {
|
| 222 |
+
mockExchange(documentWith([]));
|
| 223 |
+
|
| 224 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 225 |
+
|
| 226 |
+
expect(requestHeaders(0)["Mcp-Session-Id"]).toBeUndefined();
|
| 227 |
+
expect(requestHeaders(0)["MCP-Protocol-Version"]).toBeUndefined();
|
| 228 |
+
|
| 229 |
+
for (const index of [1, 2]) {
|
| 230 |
+
expect(requestHeaders(index)["Mcp-Session-Id"]).toBe(SESSION_ID);
|
| 231 |
+
expect(requestHeaders(index)["MCP-Protocol-Version"]).toBe("2025-06-18");
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
it("sends one session id for the process, not one per search", async () => {
|
| 236 |
+
mockExchange(documentWith([]));
|
| 237 |
+
mockExchange(documentWith([]));
|
| 238 |
+
|
| 239 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 240 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 241 |
+
|
| 242 |
+
const first = requestBody(2).params?.arguments?.session_id;
|
| 243 |
+
const second = requestBody(5).params?.arguments?.session_id;
|
| 244 |
+
expect(typeof first).toBe("string");
|
| 245 |
+
expect(second).toBe(first);
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
it("sends the content type and accept header on every request", async () => {
|
| 249 |
+
mockExchange(documentWith([]));
|
| 250 |
+
|
| 251 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 252 |
+
|
| 253 |
+
for (let index = 0; index < 3; index++) {
|
| 254 |
+
const headers = requestHeaders(index);
|
| 255 |
+
expect(headers["Content-Type"]).toBe("application/json");
|
| 256 |
+
expect(headers.Accept).toBe("application/json, text/event-stream");
|
| 257 |
+
}
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
it("never sends a model name", async () => {
|
| 261 |
+
mockExchange(documentWith([]));
|
| 262 |
+
|
| 263 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 264 |
+
|
| 265 |
+
const toolCall = JSON.stringify(fetchMock.mock.calls[2][1]?.body);
|
| 266 |
+
expect(toolCall).not.toContain("model_name");
|
| 267 |
+
expect(Object.keys(requestBody(2).params?.arguments ?? {})).toEqual([
|
| 268 |
+
"objective",
|
| 269 |
+
"search_queries",
|
| 270 |
+
"session_id",
|
| 271 |
+
]);
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
it("bounds all three requests with one abort signal", async () => {
|
| 275 |
+
mockExchange(documentWith([]));
|
| 276 |
+
|
| 277 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 278 |
+
|
| 279 |
+
const signals = fetchMock.mock.calls.map(([, init]) => init?.signal);
|
| 280 |
+
expect(signals[0]).toBeInstanceOf(AbortSignal);
|
| 281 |
+
expect(signals[1]).toBe(signals[0]);
|
| 282 |
+
expect(signals[2]).toBe(signals[0]);
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
it("hands the exchange the caller's budget, capped at its own", async () => {
|
| 286 |
+
mockExchange(documentWith([]));
|
| 287 |
+
mockExchange(documentWith([]));
|
| 288 |
+
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
|
| 289 |
+
|
| 290 |
+
await fetchFallbackTextResults(QUERY, 10, 3_000);
|
| 291 |
+
await fetchFallbackTextResults(QUERY, 10, 20_000);
|
| 292 |
+
|
| 293 |
+
expect(timeoutSpy).toHaveBeenNthCalledWith(1, 3_000);
|
| 294 |
+
// A budget past the module's own 15 s is not passed on: the exchange still
|
| 295 |
+
// ends at the cap, so the caller cannot lend it more time than it allows.
|
| 296 |
+
// Restored by the afterEach below, along with every other mock.
|
| 297 |
+
expect(timeoutSpy).toHaveBeenNthCalledWith(2, 15_000);
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
it("discards the unread bodies of the first two responses", async () => {
|
| 301 |
+
const cancelInitialize = vi.fn().mockResolvedValue(undefined);
|
| 302 |
+
const cancelInitialized = vi.fn().mockResolvedValue(undefined);
|
| 303 |
+
fetchMock
|
| 304 |
+
.mockResolvedValueOnce(
|
| 305 |
+
createMockResponse(INITIALIZE_REPLY, {
|
| 306 |
+
headers: { "mcp-session-id": SESSION_ID },
|
| 307 |
+
unreadBody: { cancel: cancelInitialize },
|
| 308 |
+
}),
|
| 309 |
+
)
|
| 310 |
+
.mockResolvedValueOnce(
|
| 311 |
+
createMockResponse(NOTIFICATION_ACCEPTED, {
|
| 312 |
+
status: 202,
|
| 313 |
+
unreadBody: { cancel: cancelInitialized },
|
| 314 |
+
}),
|
| 315 |
+
)
|
| 316 |
+
.mockResolvedValueOnce(createMockResponse(documentWith([])));
|
| 317 |
+
|
| 318 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 319 |
+
|
| 320 |
+
// An unread body keeps its socket out of the connection pool until it is
|
| 321 |
+
// collected, so the two replies nobody reads have to be dropped.
|
| 322 |
+
expect(cancelInitialize).toHaveBeenCalledTimes(1);
|
| 323 |
+
expect(cancelInitialized).toHaveBeenCalledTimes(1);
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
it("discards an unread body before throwing on a rejected initialize", async () => {
|
| 327 |
+
const cancelInitialize = vi.fn().mockResolvedValue(undefined);
|
| 328 |
+
fetchMock.mockResolvedValueOnce(
|
| 329 |
+
createMockResponse("", {
|
| 330 |
+
status: 500,
|
| 331 |
+
unreadBody: { cancel: cancelInitialize },
|
| 332 |
+
}),
|
| 333 |
+
);
|
| 334 |
+
|
| 335 |
+
await expect(
|
| 336 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 337 |
+
).rejects.toThrow("did not open a session (status 500)");
|
| 338 |
+
expect(cancelInitialize).toHaveBeenCalledTimes(1);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
it("omits the Bearer header when no key is configured", async () => {
|
| 342 |
+
mockExchange(documentWith([]));
|
| 343 |
+
|
| 344 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 345 |
+
|
| 346 |
+
for (let index = 0; index < 3; index++) {
|
| 347 |
+
expect(requestHeaders(index).Authorization).toBeUndefined();
|
| 348 |
+
}
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
it("sends the Bearer header on every request when a key is configured", async () => {
|
| 352 |
+
process.env.SEARCH_FALLBACK_API_KEY = "sk-fallback-key";
|
| 353 |
+
mockExchange(documentWith([]));
|
| 354 |
+
|
| 355 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 356 |
+
|
| 357 |
+
for (let index = 0; index < 3; index++) {
|
| 358 |
+
expect(requestHeaders(index).Authorization).toBe(
|
| 359 |
+
"Bearer sk-fallback-key",
|
| 360 |
+
);
|
| 361 |
+
}
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
it("treats a whitespace-only key as no key", async () => {
|
| 365 |
+
process.env.SEARCH_FALLBACK_API_KEY = " ";
|
| 366 |
+
mockExchange(documentWith([]));
|
| 367 |
+
|
| 368 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 369 |
+
|
| 370 |
+
expect(requestHeaders(0).Authorization).toBeUndefined();
|
| 371 |
+
});
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
describe("reading the reply", () => {
|
| 375 |
+
it("parses an application/json reply", async () => {
|
| 376 |
+
mockExchange(
|
| 377 |
+
toolReply({
|
| 378 |
+
search_id: "search-1",
|
| 379 |
+
results: [
|
| 380 |
+
{
|
| 381 |
+
url: "https://example.com/json",
|
| 382 |
+
title: "JSON title",
|
| 383 |
+
excerpts: [
|
| 384 |
+
`${"json ".repeat(12).trim()} an excerpt long enough to be kept by the length filter.`,
|
| 385 |
+
],
|
| 386 |
+
},
|
| 387 |
+
],
|
| 388 |
+
}),
|
| 389 |
+
);
|
| 390 |
+
|
| 391 |
+
const results = await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 392 |
+
|
| 393 |
+
expect(results).toHaveLength(1);
|
| 394 |
+
expect(results[0][0]).toBe("JSON title");
|
| 395 |
+
expect(results[0][2]).toBe("https://example.com/json");
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
it("parses a text/event-stream reply and skips blocks that are not JSON", async () => {
|
| 399 |
+
mockExchange(
|
| 400 |
+
`: keep-alive\n\n${sseReply(
|
| 401 |
+
JSON.stringify({
|
| 402 |
+
jsonrpc: "2.0",
|
| 403 |
+
id: 2,
|
| 404 |
+
result: {
|
| 405 |
+
content: [{ type: "text", text: JSON.stringify({ results: [] }) }],
|
| 406 |
+
},
|
| 407 |
+
}),
|
| 408 |
+
)}event: ping\ndata: not json\n\n`,
|
| 409 |
+
{ toolContentType: "text/event-stream" },
|
| 410 |
+
);
|
| 411 |
+
|
| 412 |
+
await expect(
|
| 413 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 414 |
+
).resolves.toEqual([]);
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
it("parses an SSE initialize reply", async () => {
|
| 418 |
+
mockExchange(documentWith([]), {
|
| 419 |
+
initializeBody: sseReply(INITIALIZE_REPLY),
|
| 420 |
+
initializeContentType: "text/event-stream",
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
await expect(
|
| 424 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 425 |
+
).resolves.toEqual([]);
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
it("returns an empty array when nothing usable comes back", async () => {
|
| 429 |
+
mockExchange(documentWith([]));
|
| 430 |
+
|
| 431 |
+
await expect(
|
| 432 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 433 |
+
).resolves.toEqual([]);
|
| 434 |
+
});
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
describe("mapping a result", () => {
|
| 438 |
+
const run = async (results: unknown[], limit = 10) => {
|
| 439 |
+
mockExchange(documentWith(results));
|
| 440 |
+
return fetchFallbackTextResults(QUERY, limit, FULL_BUDGET_MS);
|
| 441 |
+
};
|
| 442 |
+
|
| 443 |
+
it("drops navigation crumbs and keeps the paragraph", async () => {
|
| 444 |
+
const results = await run([
|
| 445 |
+
{
|
| 446 |
+
url: "https://example.com/guide",
|
| 447 |
+
title: "Guide",
|
| 448 |
+
excerpts: [
|
| 449 |
+
"Skip to main content",
|
| 450 |
+
"Home > Docs > Search > Guide",
|
| 451 |
+
EXCERPT,
|
| 452 |
+
],
|
| 453 |
+
},
|
| 454 |
+
]);
|
| 455 |
+
|
| 456 |
+
expect(results).toEqual([["Guide", EXCERPT, "https://example.com/guide"]]);
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
it("strips leading markdown markers", async () => {
|
| 460 |
+
const results = await run([
|
| 461 |
+
{
|
| 462 |
+
url: "https://example.com/markers",
|
| 463 |
+
title: "Markers",
|
| 464 |
+
excerpts: [`# ${EXCERPT}`],
|
| 465 |
+
},
|
| 466 |
+
]);
|
| 467 |
+
|
| 468 |
+
expect(results[0][1]).toBe(EXCERPT);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it("reduces a markdown link to its text", async () => {
|
| 472 |
+
const results = await run([
|
| 473 |
+
{
|
| 474 |
+
url: "https://example.com/link",
|
| 475 |
+
title: "Link",
|
| 476 |
+
excerpts: [
|
| 477 |
+
`See the [reference page](https://example.com/reference) and then ${EXCERPT}`,
|
| 478 |
+
],
|
| 479 |
+
},
|
| 480 |
+
]);
|
| 481 |
+
|
| 482 |
+
expect(results[0][1]).toBe(`See the reference page and then ${EXCERPT}`);
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
it("joins excerpts and collapses the whitespace between them", async () => {
|
| 486 |
+
const first = `The first excerpt paragraph is long enough to be kept intact.`;
|
| 487 |
+
const second = `The second excerpt paragraph is long enough to be kept intact.`;
|
| 488 |
+
const results = await run([
|
| 489 |
+
{
|
| 490 |
+
url: "https://example.com/join",
|
| 491 |
+
title: "Join",
|
| 492 |
+
excerpts: [`${first}\n\n${second}`, "Short"],
|
| 493 |
+
},
|
| 494 |
+
]);
|
| 495 |
+
|
| 496 |
+
expect(results[0][1]).toBe(`${first} ${second}`);
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
it("cuts at 400 characters, at a word boundary, and marks the cut", async () => {
|
| 500 |
+
expect(LONG_EXCERPT.length).toBeGreaterThan(400);
|
| 501 |
+
|
| 502 |
+
const results = await run([
|
| 503 |
+
{
|
| 504 |
+
url: "https://example.com/long",
|
| 505 |
+
title: "Long",
|
| 506 |
+
excerpts: [LONG_EXCERPT],
|
| 507 |
+
},
|
| 508 |
+
]);
|
| 509 |
+
|
| 510 |
+
const snippet = results[0][1];
|
| 511 |
+
expect(snippet.endsWith("โฆ")).toBe(true);
|
| 512 |
+
expect(snippet.length).toBeLessThanOrEqual(400);
|
| 513 |
+
const kept = snippet.slice(0, -1);
|
| 514 |
+
// The kept text is a prefix of the source, and the source continues with a
|
| 515 |
+
// space, so the cut landed between two words rather than inside one.
|
| 516 |
+
expect(LONG_EXCERPT.startsWith(kept)).toBe(true);
|
| 517 |
+
expect(LONG_EXCERPT[kept.length]).toBe(" ");
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
it("leaves a snippet that fits untouched", async () => {
|
| 521 |
+
expect(EXCERPT.length).toBeLessThanOrEqual(400);
|
| 522 |
+
|
| 523 |
+
const results = await run([
|
| 524 |
+
{ url: "https://example.com/short", title: "Short", excerpts: [EXCERPT] },
|
| 525 |
+
]);
|
| 526 |
+
|
| 527 |
+
expect(results[0][1]).toBe(EXCERPT);
|
| 528 |
+
expect(results[0][1]).not.toContain("โฆ");
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
it("drops a result with no url, a null or empty title, or an empty snippet", async () => {
|
| 532 |
+
const results = await run([
|
| 533 |
+
{ title: "No url", excerpts: [EXCERPT] },
|
| 534 |
+
{
|
| 535 |
+
url: "https://example.com/null-title",
|
| 536 |
+
title: null,
|
| 537 |
+
excerpts: [EXCERPT],
|
| 538 |
+
},
|
| 539 |
+
{
|
| 540 |
+
url: "https://example.com/empty-title",
|
| 541 |
+
title: " ",
|
| 542 |
+
excerpts: [EXCERPT],
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
url: "https://example.com/no-snippet",
|
| 546 |
+
title: "No snippet",
|
| 547 |
+
excerpts: [],
|
| 548 |
+
},
|
| 549 |
+
{
|
| 550 |
+
url: "https://example.com/crumb-only",
|
| 551 |
+
title: "Crumb only",
|
| 552 |
+
excerpts: ["Skip to main content"],
|
| 553 |
+
},
|
| 554 |
+
{ url: "https://example.com/kept", title: "Kept", excerpts: [EXCERPT] },
|
| 555 |
+
]);
|
| 556 |
+
|
| 557 |
+
expect(results).toEqual([["Kept", EXCERPT, "https://example.com/kept"]]);
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
it("drops a result whose url or title is not a string", async () => {
|
| 561 |
+
const results = await run([
|
| 562 |
+
{ url: 42, title: "Number url", excerpts: [EXCERPT] },
|
| 563 |
+
{ url: null, title: "Null url", excerpts: [EXCERPT] },
|
| 564 |
+
{
|
| 565 |
+
url: "https://example.com/number-title",
|
| 566 |
+
title: 7,
|
| 567 |
+
excerpts: [EXCERPT],
|
| 568 |
+
},
|
| 569 |
+
null,
|
| 570 |
+
{ url: "https://example.com/kept", title: "Kept", excerpts: [EXCERPT] },
|
| 571 |
+
]);
|
| 572 |
+
|
| 573 |
+
// One malformed entry must not take the rest of the search down with it.
|
| 574 |
+
expect(results).toEqual([["Kept", EXCERPT, "https://example.com/kept"]]);
|
| 575 |
+
});
|
| 576 |
+
|
| 577 |
+
it("drops duplicate urls in the endpoint's order", async () => {
|
| 578 |
+
const results = await run([
|
| 579 |
+
{ url: "https://example.com/dup", title: "First", excerpts: [EXCERPT] },
|
| 580 |
+
{
|
| 581 |
+
url: "https://example.com/other",
|
| 582 |
+
title: "Second",
|
| 583 |
+
excerpts: [EXCERPT],
|
| 584 |
+
},
|
| 585 |
+
{ url: "https://example.com/dup", title: "Third", excerpts: [EXCERPT] },
|
| 586 |
+
]);
|
| 587 |
+
|
| 588 |
+
expect(results.map(([title]) => title)).toEqual(["First", "Second"]);
|
| 589 |
+
});
|
| 590 |
+
|
| 591 |
+
it("keeps the endpoint's relevance order", async () => {
|
| 592 |
+
const results = await run([
|
| 593 |
+
{ url: "https://example.com/1", title: "One", excerpts: [EXCERPT] },
|
| 594 |
+
{ url: "https://example.com/2", title: "Two", excerpts: [EXCERPT] },
|
| 595 |
+
{ url: "https://example.com/3", title: "Three", excerpts: [EXCERPT] },
|
| 596 |
+
]);
|
| 597 |
+
|
| 598 |
+
expect(results.map(([title]) => title)).toEqual(["One", "Two", "Three"]);
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
it("applies the limit", async () => {
|
| 602 |
+
const results = await run(
|
| 603 |
+
[
|
| 604 |
+
{ url: "https://example.com/1", title: "One", excerpts: [EXCERPT] },
|
| 605 |
+
{ url: "https://example.com/2", title: "Two", excerpts: [EXCERPT] },
|
| 606 |
+
{ url: "https://example.com/3", title: "Three", excerpts: [EXCERPT] },
|
| 607 |
+
],
|
| 608 |
+
2,
|
| 609 |
+
);
|
| 610 |
+
|
| 611 |
+
expect(results.map(([title]) => title)).toEqual(["One", "Two"]);
|
| 612 |
+
});
|
| 613 |
+
});
|
| 614 |
+
|
| 615 |
+
describe("failures", () => {
|
| 616 |
+
it("throws when the tool call is not 2xx", async () => {
|
| 617 |
+
const cancel = vi.fn(async () => undefined);
|
| 618 |
+
fetchMock.mockResolvedValueOnce(
|
| 619 |
+
createMockResponse(INITIALIZE_REPLY, {
|
| 620 |
+
headers: { "mcp-session-id": SESSION_ID },
|
| 621 |
+
}),
|
| 622 |
+
);
|
| 623 |
+
fetchMock.mockResolvedValueOnce(
|
| 624 |
+
createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
|
| 625 |
+
);
|
| 626 |
+
fetchMock.mockResolvedValueOnce(
|
| 627 |
+
createMockResponse("bad", { status: 502, unreadBody: { cancel } }),
|
| 628 |
+
);
|
| 629 |
+
|
| 630 |
+
await expect(
|
| 631 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 632 |
+
).rejects.toThrow("The fallback search tool call failed with status 502");
|
| 633 |
+
// An unread body keeps undici's socket out of the pool until it is
|
| 634 |
+
// garbage-collected, so the non-2xx path has to discard it like the two
|
| 635 |
+
// paths above it; dropping that line fails here.
|
| 636 |
+
expect(cancel).toHaveBeenCalledTimes(1);
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
it("throws when initialize does not open a session", async () => {
|
| 640 |
+
fetchMock.mockResolvedValueOnce(createMockResponse(INITIALIZE_REPLY));
|
| 641 |
+
|
| 642 |
+
await expect(
|
| 643 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 644 |
+
).rejects.toThrow("did not open a session (status 200)");
|
| 645 |
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
| 646 |
+
});
|
| 647 |
+
|
| 648 |
+
it("throws on a JSON-RPC error and keeps the endpoint's message out of it", async () => {
|
| 649 |
+
fetchMock.mockResolvedValueOnce(
|
| 650 |
+
createMockResponse(INITIALIZE_REPLY, {
|
| 651 |
+
headers: { "mcp-session-id": SESSION_ID },
|
| 652 |
+
}),
|
| 653 |
+
);
|
| 654 |
+
fetchMock.mockResolvedValueOnce(
|
| 655 |
+
createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
|
| 656 |
+
);
|
| 657 |
+
fetchMock.mockResolvedValueOnce(
|
| 658 |
+
createMockResponse(
|
| 659 |
+
JSON.stringify({
|
| 660 |
+
jsonrpc: "2.0",
|
| 661 |
+
id: 2,
|
| 662 |
+
error: { code: -32601, message: "SECRET UPSTREAM TEXT" },
|
| 663 |
+
}),
|
| 664 |
+
),
|
| 665 |
+
);
|
| 666 |
+
|
| 667 |
+
const rejection = await fetchFallbackTextResults(
|
| 668 |
+
QUERY,
|
| 669 |
+
10,
|
| 670 |
+
FULL_BUDGET_MS,
|
| 671 |
+
).catch((error: unknown) => error);
|
| 672 |
+
// The whole message is fixed text plus the code: an upstream message is
|
| 673 |
+
// free-form text from a third party and must not travel any further.
|
| 674 |
+
expect(rejection).toBeInstanceOf(Error);
|
| 675 |
+
expect((rejection as Error).message).toBe(
|
| 676 |
+
"The fallback search endpoint returned a JSON-RPC error (code -32601)",
|
| 677 |
+
);
|
| 678 |
+
expect(String(rejection)).not.toContain("SECRET UPSTREAM TEXT");
|
| 679 |
+
});
|
| 680 |
+
|
| 681 |
+
it("keeps a non-numeric JSON-RPC code out of the message", async () => {
|
| 682 |
+
fetchMock.mockResolvedValueOnce(
|
| 683 |
+
createMockResponse(INITIALIZE_REPLY, {
|
| 684 |
+
headers: { "mcp-session-id": SESSION_ID },
|
| 685 |
+
}),
|
| 686 |
+
);
|
| 687 |
+
fetchMock.mockResolvedValueOnce(
|
| 688 |
+
createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
|
| 689 |
+
);
|
| 690 |
+
fetchMock.mockResolvedValueOnce(
|
| 691 |
+
createMockResponse(
|
| 692 |
+
JSON.stringify({
|
| 693 |
+
jsonrpc: "2.0",
|
| 694 |
+
id: 2,
|
| 695 |
+
error: { code: "SECRET", message: "SECRET UPSTREAM TEXT" },
|
| 696 |
+
}),
|
| 697 |
+
),
|
| 698 |
+
);
|
| 699 |
+
|
| 700 |
+
const rejection = await fetchFallbackTextResults(
|
| 701 |
+
QUERY,
|
| 702 |
+
10,
|
| 703 |
+
FULL_BUDGET_MS,
|
| 704 |
+
).catch((error: unknown) => error);
|
| 705 |
+
// A code is upstream data like the message is, so only a number may be
|
| 706 |
+
// carried into the thrown error.
|
| 707 |
+
expect(rejection).toBeInstanceOf(Error);
|
| 708 |
+
expect((rejection as Error).message).toBe(
|
| 709 |
+
"The fallback search endpoint returned a JSON-RPC error",
|
| 710 |
+
);
|
| 711 |
+
expect(String(rejection)).not.toContain("SECRET");
|
| 712 |
+
});
|
| 713 |
+
|
| 714 |
+
it("throws when the result is marked as an error", async () => {
|
| 715 |
+
mockExchange(
|
| 716 |
+
JSON.stringify({
|
| 717 |
+
jsonrpc: "2.0",
|
| 718 |
+
id: 2,
|
| 719 |
+
result: {
|
| 720 |
+
isError: true,
|
| 721 |
+
content: [{ type: "text", text: "upstream refused" }],
|
| 722 |
+
},
|
| 723 |
+
}),
|
| 724 |
+
);
|
| 725 |
+
|
| 726 |
+
await expect(
|
| 727 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 728 |
+
).rejects.toThrow("reported the search as failed");
|
| 729 |
+
});
|
| 730 |
+
|
| 731 |
+
it("throws when the tool output is not JSON", async () => {
|
| 732 |
+
mockExchange(
|
| 733 |
+
JSON.stringify({
|
| 734 |
+
jsonrpc: "2.0",
|
| 735 |
+
id: 2,
|
| 736 |
+
result: { content: [{ type: "text", text: "<html>gateway</html>" }] },
|
| 737 |
+
}),
|
| 738 |
+
);
|
| 739 |
+
|
| 740 |
+
await expect(
|
| 741 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 742 |
+
).rejects.toThrow("tool output that is not JSON");
|
| 743 |
+
});
|
| 744 |
+
|
| 745 |
+
it("throws a fixed message when the reply itself is not JSON", async () => {
|
| 746 |
+
mockExchange("<html>gateway</html>");
|
| 747 |
+
|
| 748 |
+
const rejection = await fetchFallbackTextResults(
|
| 749 |
+
QUERY,
|
| 750 |
+
10,
|
| 751 |
+
FULL_BUDGET_MS,
|
| 752 |
+
).catch((error: unknown) => error);
|
| 753 |
+
// A raw SyntaxError quotes the offending body, which would put the
|
| 754 |
+
// upstream response into the thrown error and then into the log.
|
| 755 |
+
expect((rejection as Error).message).toBe(
|
| 756 |
+
"The fallback search endpoint sent a reply that is not JSON",
|
| 757 |
+
);
|
| 758 |
+
expect(String(rejection)).not.toContain("<html>");
|
| 759 |
+
});
|
| 760 |
+
|
| 761 |
+
it("throws when the document has no results array", async () => {
|
| 762 |
+
mockExchange(toolReply({ search_id: "search-1" }));
|
| 763 |
+
|
| 764 |
+
await expect(
|
| 765 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 766 |
+
).rejects.toThrow("without a results array");
|
| 767 |
+
});
|
| 768 |
+
|
| 769 |
+
it("throws when the endpoint is unreachable", async () => {
|
| 770 |
+
fetchMock.mockRejectedValue(new Error("Network failure"));
|
| 771 |
+
|
| 772 |
+
await expect(
|
| 773 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 774 |
+
).rejects.toThrow("Network failure");
|
| 775 |
+
});
|
| 776 |
+
});
|
| 777 |
+
|
| 778 |
+
describe("privacy", () => {
|
| 779 |
+
let logLines: string[];
|
| 780 |
+
let originalLog: typeof debug.log;
|
| 781 |
+
|
| 782 |
+
beforeEach(() => {
|
| 783 |
+
logLines = [];
|
| 784 |
+
originalLog = debug.log;
|
| 785 |
+
debug.log = (...args: unknown[]) => {
|
| 786 |
+
logLines.push(args.map(String).join(" "));
|
| 787 |
+
};
|
| 788 |
+
for (const method of ["log", "info", "warn", "error", "debug"] as const) {
|
| 789 |
+
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
| 790 |
+
logLines.push(args.map(String).join(" "));
|
| 791 |
+
});
|
| 792 |
+
}
|
| 793 |
+
});
|
| 794 |
+
|
| 795 |
+
afterEach(() => {
|
| 796 |
+
debug.log = originalLog;
|
| 797 |
+
});
|
| 798 |
+
|
| 799 |
+
it("never writes the query, the API key or the response text to the log", async () => {
|
| 800 |
+
process.env.SEARCH_FALLBACK_API_KEY = "sk-fallback-key";
|
| 801 |
+
mockExchange(
|
| 802 |
+
documentWith([
|
| 803 |
+
{
|
| 804 |
+
url: "https://example.com/private",
|
| 805 |
+
title: "PRIVATE RESPONSE TITLE",
|
| 806 |
+
excerpts: [
|
| 807 |
+
`PRIVATE RESPONSE EXCERPT that is long enough to be kept by the length filter and returned to the caller.`,
|
| 808 |
+
],
|
| 809 |
+
},
|
| 810 |
+
]),
|
| 811 |
+
);
|
| 812 |
+
|
| 813 |
+
await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
|
| 814 |
+
|
| 815 |
+
// Failure paths too: they carry a fixed line plus a status or an error
|
| 816 |
+
// code, never the payload.
|
| 817 |
+
fetchMock.mockResolvedValueOnce(createMockResponse("", { status: 500 }));
|
| 818 |
+
await expect(
|
| 819 |
+
fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
|
| 820 |
+
).rejects.toThrow();
|
| 821 |
+
|
| 822 |
+
const output = logLines.join("\n");
|
| 823 |
+
for (const form of [
|
| 824 |
+
QUERY,
|
| 825 |
+
encodeURIComponent(QUERY),
|
| 826 |
+
QUERY.replaceAll(" ", "+"),
|
| 827 |
+
"PRIVATE RESPONSE TITLE",
|
| 828 |
+
"PRIVATE RESPONSE EXCERPT",
|
| 829 |
+
"sk-fallback-key",
|
| 830 |
+
]) {
|
| 831 |
+
expect(output).not.toContain(form);
|
| 832 |
+
}
|
| 833 |
+
expect(logLines.length).toBeGreaterThan(0);
|
| 834 |
+
});
|
| 835 |
+
});
|
server/fallbackSearchService.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomUUID } from "node:crypto";
|
| 2 |
+
import { basename } from "node:path";
|
| 3 |
+
import debug from "debug";
|
| 4 |
+
|
| 5 |
+
const fileName = basename(import.meta.url);
|
| 6 |
+
const printMessage = debug(fileName);
|
| 7 |
+
printMessage.enabled = true;
|
| 8 |
+
|
| 9 |
+
const ENDPOINT_URL = "https://search.parallel.ai/mcp";
|
| 10 |
+
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
| 11 |
+
// Bounds the whole three-request exchange, not each request: a fallback that
|
| 12 |
+
// hangs longer than this keeps the 502 the operator already gets from SearXNG
|
| 13 |
+
// waiting for a second source that is only there to soften the outage.
|
| 14 |
+
const FALLBACK_TIMEOUT_MS = 15_000;
|
| 15 |
+
const MAX_SNIPPET_LENGTH = 400;
|
| 16 |
+
// Excerpts arrive with navigation crumbs ("Skip to main content", menu items)
|
| 17 |
+
// that are sentences of their own; a real excerpt paragraph never runs this
|
| 18 |
+
// short, so length is the cheapest way to drop them without a stoplist.
|
| 19 |
+
const MIN_SNIPPET_LINE_LENGTH = 40;
|
| 20 |
+
|
| 21 |
+
// One per server process: the endpoint uses it for free-tier rate limiting and
|
| 22 |
+
// one MiniSearch instance is one client, so a fresh one per request would shed
|
| 23 |
+
// the rate limit instead of sharing it.
|
| 24 |
+
const SESSION_ID = randomUUID();
|
| 25 |
+
|
| 26 |
+
interface JsonRpcMessage {
|
| 27 |
+
id?: number;
|
| 28 |
+
error?: { code?: unknown };
|
| 29 |
+
result?: {
|
| 30 |
+
isError?: boolean;
|
| 31 |
+
content?: { type?: string; text?: string }[];
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
interface FallbackSearchResult {
|
| 36 |
+
url?: string;
|
| 37 |
+
title?: string | null;
|
| 38 |
+
publish_date?: string | null;
|
| 39 |
+
excerpts?: string[];
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface FallbackSearchDocument {
|
| 43 |
+
results?: FallbackSearchResult[];
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
type TextualResult = [title: string, content: string, url: string];
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Whether the operator opted into the second text-search source. Off unless
|
| 50 |
+
* `SEARCH_FALLBACK_ENABLED` is `true` or `1`, read at call time so tests can
|
| 51 |
+
* toggle it.
|
| 52 |
+
*/
|
| 53 |
+
export function isSearchFallbackEnabled(): boolean {
|
| 54 |
+
const value = process.env.SEARCH_FALLBACK_ENABLED?.trim().toLowerCase();
|
| 55 |
+
return value === "true" || value === "1";
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Runs a text search against the fallback endpoint when SearXNG has already
|
| 60 |
+
* failed, and returns the same `[title, snippet, url]` tuples the SearXNG path
|
| 61 |
+
* produces, so the caller can rank and render them unchanged.
|
| 62 |
+
*
|
| 63 |
+
* `timeoutMs` is the time the caller can still afford, measured from its own
|
| 64 |
+
* start: the exchange is bounded by this and by the module's own cap, whichever
|
| 65 |
+
* is shorter, so a search that has already spent most of its budget on SearXNG
|
| 66 |
+
* cannot hang on here past the point where the client has given up.
|
| 67 |
+
*
|
| 68 |
+
* Throws on any failure rather than returning what it managed to collect: the
|
| 69 |
+
* caller has to decide between a degraded answer and the 502 it would have sent
|
| 70 |
+
* anyway, and it has to be able to count the decision. An empty array is the
|
| 71 |
+
* one non-throwing outcome that means the endpoint answered and nothing usable
|
| 72 |
+
* came back.
|
| 73 |
+
*/
|
| 74 |
+
export async function fetchFallbackTextResults(
|
| 75 |
+
query: string,
|
| 76 |
+
limit: number,
|
| 77 |
+
timeoutMs: number,
|
| 78 |
+
): Promise<TextualResult[]> {
|
| 79 |
+
const apiKey = process.env.SEARCH_FALLBACK_API_KEY?.trim();
|
| 80 |
+
const signal = AbortSignal.timeout(Math.min(FALLBACK_TIMEOUT_MS, timeoutMs));
|
| 81 |
+
|
| 82 |
+
const headers: Record<string, string> = {
|
| 83 |
+
"Content-Type": "application/json",
|
| 84 |
+
Accept: "application/json, text/event-stream",
|
| 85 |
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const initializeResponse = await fetch(ENDPOINT_URL, {
|
| 89 |
+
method: "POST",
|
| 90 |
+
headers,
|
| 91 |
+
body: JSON.stringify({
|
| 92 |
+
jsonrpc: "2.0",
|
| 93 |
+
id: 1,
|
| 94 |
+
method: "initialize",
|
| 95 |
+
params: {
|
| 96 |
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
| 97 |
+
capabilities: {},
|
| 98 |
+
clientInfo: { name: "MiniSearch", version: "1" },
|
| 99 |
+
},
|
| 100 |
+
}),
|
| 101 |
+
signal,
|
| 102 |
+
});
|
| 103 |
+
const sessionId = initializeResponse.headers.get("mcp-session-id");
|
| 104 |
+
// An unread body keeps undici's socket out of the pool until it is GC'd, so
|
| 105 |
+
// both outcomes have to discard it before they part ways with the response.
|
| 106 |
+
await initializeResponse.body?.cancel();
|
| 107 |
+
if (!initializeResponse.ok || !sessionId) {
|
| 108 |
+
throw new Error(
|
| 109 |
+
`The fallback search endpoint did not open a session (status ${initializeResponse.status})`,
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const sessionHeaders = {
|
| 114 |
+
"Mcp-Session-Id": sessionId,
|
| 115 |
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
// A notification, so there is no reply body to read: the session is only
|
| 119 |
+
// usable once the endpoint has acknowledged it, and `ok` is the whole
|
| 120 |
+
// acknowledgement.
|
| 121 |
+
const initializedResponse = await fetch(ENDPOINT_URL, {
|
| 122 |
+
method: "POST",
|
| 123 |
+
headers: { ...headers, ...sessionHeaders },
|
| 124 |
+
body: JSON.stringify({
|
| 125 |
+
jsonrpc: "2.0",
|
| 126 |
+
method: "notifications/initialized",
|
| 127 |
+
}),
|
| 128 |
+
signal,
|
| 129 |
+
});
|
| 130 |
+
await initializedResponse.body?.cancel();
|
| 131 |
+
if (!initializedResponse.ok) {
|
| 132 |
+
throw new Error(
|
| 133 |
+
`The fallback search endpoint rejected the initialized notification (status ${initializedResponse.status})`,
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const callResponse = await fetch(ENDPOINT_URL, {
|
| 138 |
+
method: "POST",
|
| 139 |
+
headers: { ...headers, ...sessionHeaders },
|
| 140 |
+
body: JSON.stringify({
|
| 141 |
+
jsonrpc: "2.0",
|
| 142 |
+
id: 2,
|
| 143 |
+
method: "tools/call",
|
| 144 |
+
params: {
|
| 145 |
+
name: "web_search",
|
| 146 |
+
// No `model_name`: this is a search, not a completion, and the tool
|
| 147 |
+
// does not need a client that claims to be a model.
|
| 148 |
+
arguments: {
|
| 149 |
+
objective: query,
|
| 150 |
+
search_queries: [query],
|
| 151 |
+
session_id: SESSION_ID,
|
| 152 |
+
},
|
| 153 |
+
},
|
| 154 |
+
}),
|
| 155 |
+
signal,
|
| 156 |
+
});
|
| 157 |
+
if (!callResponse.ok) {
|
| 158 |
+
await callResponse.body?.cancel();
|
| 159 |
+
throw new Error(
|
| 160 |
+
`The fallback search tool call failed with status ${callResponse.status}`,
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const document = await readSearchDocument(callResponse, 2);
|
| 165 |
+
const textualResults = mapResults(document, limit);
|
| 166 |
+
printMessage(
|
| 167 |
+
`Fallback search usable text results: ${textualResults.length} of ${document.results?.length ?? 0}.`,
|
| 168 |
+
);
|
| 169 |
+
return textualResults;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Reads the JSON-RPC reply to `requestId`. The endpoint answers either one
|
| 174 |
+
* JSON message or an SSE stream of them, and which of them belongs to this
|
| 175 |
+
* request is decided by the id, not by position: the stream can carry
|
| 176 |
+
* notifications the client never asked for.
|
| 177 |
+
*/
|
| 178 |
+
async function readSearchDocument(
|
| 179 |
+
response: Response,
|
| 180 |
+
requestId: number,
|
| 181 |
+
): Promise<FallbackSearchDocument> {
|
| 182 |
+
const contentType = response.headers.get("content-type") ?? "";
|
| 183 |
+
const body = await response.text();
|
| 184 |
+
|
| 185 |
+
const messages = contentType.includes("text/event-stream")
|
| 186 |
+
? parseEventStream(body)
|
| 187 |
+
: parseJsonBody(body);
|
| 188 |
+
|
| 189 |
+
const reply = messages.find((message) => message.id === requestId);
|
| 190 |
+
if (!reply) {
|
| 191 |
+
throw new Error(
|
| 192 |
+
`The fallback search endpoint sent no reply to request ${requestId}`,
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Only a numeric code: an upstream error message is free-form text from a
|
| 197 |
+
// third party, and a non-numeric code is upstream data just the same, so
|
| 198 |
+
// neither may reach the log or a thrown error.
|
| 199 |
+
if (reply.error) {
|
| 200 |
+
throw new Error(
|
| 201 |
+
typeof reply.error.code === "number"
|
| 202 |
+
? `The fallback search endpoint returned a JSON-RPC error (code ${reply.error.code})`
|
| 203 |
+
: "The fallback search endpoint returned a JSON-RPC error",
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (reply.result?.isError) {
|
| 208 |
+
throw new Error(
|
| 209 |
+
"The fallback search endpoint reported the search as failed",
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const payload = (reply.result?.content ?? [])
|
| 214 |
+
.flatMap((item) =>
|
| 215 |
+
item.type === "text" && typeof item.text === "string" ? [item.text] : [],
|
| 216 |
+
)
|
| 217 |
+
.join("\n");
|
| 218 |
+
|
| 219 |
+
try {
|
| 220 |
+
const parsed: unknown = JSON.parse(payload);
|
| 221 |
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
| 222 |
+
throw new Error();
|
| 223 |
+
}
|
| 224 |
+
return parsed as FallbackSearchDocument;
|
| 225 |
+
} catch {
|
| 226 |
+
throw new Error(
|
| 227 |
+
"The fallback search endpoint returned tool output that is not JSON",
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function parseJsonBody(body: string): JsonRpcMessage[] {
|
| 233 |
+
let parsed: unknown;
|
| 234 |
+
try {
|
| 235 |
+
parsed = JSON.parse(body);
|
| 236 |
+
} catch {
|
| 237 |
+
// A raw SyntaxError quotes the offending snippet, which would carry the
|
| 238 |
+
// upstream response into the thrown error and from there into the log.
|
| 239 |
+
throw new Error(
|
| 240 |
+
"The fallback search endpoint sent a reply that is not JSON",
|
| 241 |
+
);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if (Array.isArray(parsed)) return parsed as JsonRpcMessage[];
|
| 245 |
+
return [parsed as JsonRpcMessage];
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* SSE blocks are separated by a blank line and each `data:` line holds a
|
| 250 |
+
* fragment of one JSON message. A block that is not JSON is a keep-alive or an
|
| 251 |
+
* event this client did not ask for, so it is skipped rather than failing a
|
| 252 |
+
* reply that may be perfectly good.
|
| 253 |
+
*/
|
| 254 |
+
function parseEventStream(body: string): JsonRpcMessage[] {
|
| 255 |
+
return body.split(/\r?\n\r?\n/).flatMap((block) => {
|
| 256 |
+
const data = block
|
| 257 |
+
.split(/\r?\n/)
|
| 258 |
+
.filter((line) => line.startsWith("data:"))
|
| 259 |
+
.map((line) => line.slice("data:".length).trimStart())
|
| 260 |
+
.join("\n");
|
| 261 |
+
|
| 262 |
+
if (!data) return [];
|
| 263 |
+
|
| 264 |
+
try {
|
| 265 |
+
const parsed: unknown = JSON.parse(data);
|
| 266 |
+
if (Array.isArray(parsed)) return parsed as JsonRpcMessage[];
|
| 267 |
+
return [parsed as JsonRpcMessage];
|
| 268 |
+
} catch {
|
| 269 |
+
return [];
|
| 270 |
+
}
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function mapResults(
|
| 275 |
+
document: FallbackSearchDocument,
|
| 276 |
+
limit: number,
|
| 277 |
+
): TextualResult[] {
|
| 278 |
+
if (!Array.isArray(document.results)) {
|
| 279 |
+
throw new Error(
|
| 280 |
+
"The fallback search endpoint returned a document without a results array",
|
| 281 |
+
);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
const seenUrls = new Set<string>();
|
| 285 |
+
const mapped: TextualResult[] = [];
|
| 286 |
+
|
| 287 |
+
for (const result of document.results) {
|
| 288 |
+
if (mapped.length >= limit) break;
|
| 289 |
+
// Upstream data, so one malformed entry must not fail the whole search: a
|
| 290 |
+
// result that is not an object, or whose url or title is not a string, is
|
| 291 |
+
// skipped rather than left to throw on `.trim()`.
|
| 292 |
+
if (
|
| 293 |
+
!result ||
|
| 294 |
+
typeof result !== "object" ||
|
| 295 |
+
typeof result.url !== "string" ||
|
| 296 |
+
!result.url ||
|
| 297 |
+
seenUrls.has(result.url)
|
| 298 |
+
) {
|
| 299 |
+
continue;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const title = typeof result.title === "string" ? result.title.trim() : "";
|
| 303 |
+
if (!title) continue;
|
| 304 |
+
|
| 305 |
+
const snippet = buildSnippet(result.excerpts);
|
| 306 |
+
if (!snippet) continue;
|
| 307 |
+
|
| 308 |
+
seenUrls.add(result.url);
|
| 309 |
+
mapped.push([title, snippet, result.url]);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
return mapped;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/**
|
| 316 |
+
* Turns the endpoint's markdown excerpts into the plain, single-line snippet
|
| 317 |
+
* the SearXNG path hands to ranking.
|
| 318 |
+
*/
|
| 319 |
+
function buildSnippet(excerpts: string[] | undefined): string {
|
| 320 |
+
if (!Array.isArray(excerpts) || excerpts.length === 0) return "";
|
| 321 |
+
|
| 322 |
+
const snippet = excerpts
|
| 323 |
+
.join("\n")
|
| 324 |
+
.split("\n")
|
| 325 |
+
.map(stripMarkdown)
|
| 326 |
+
.filter((line) => line.length >= MIN_SNIPPET_LINE_LENGTH)
|
| 327 |
+
.join(" ")
|
| 328 |
+
.replace(/\s+/g, " ")
|
| 329 |
+
.trim();
|
| 330 |
+
|
| 331 |
+
if (snippet.length <= MAX_SNIPPET_LENGTH) return snippet;
|
| 332 |
+
|
| 333 |
+
// Cut at a word boundary: a mid-word stump reads as a rendering bug, and the
|
| 334 |
+
// ellipsis is what tells the reader the sentence was cut, not finished.
|
| 335 |
+
const cut = snippet.slice(0, MAX_SNIPPET_LENGTH);
|
| 336 |
+
const lastSpace = cut.lastIndexOf(" ");
|
| 337 |
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}โฆ`;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function stripMarkdown(line: string): string {
|
| 341 |
+
return line
|
| 342 |
+
.replace(/^(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s+)/, "")
|
| 343 |
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
| 344 |
+
.trim();
|
| 345 |
+
}
|
server/searchEndpointServerHook.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
vi.mock("./handleTokenVerification", () => ({
|
| 5 |
handleTokenVerification: vi.fn(),
|
| 6 |
}));
|
|
@@ -16,6 +21,8 @@ vi.mock("./rerankerService", () => ({
|
|
| 16 |
vi.mock("./searchesSinceLastRestart", () => ({
|
| 17 |
incrementTextualSearchesSinceLastRestart: vi.fn(),
|
| 18 |
incrementGraphicalSearchesSinceLastRestart: vi.fn(),
|
|
|
|
|
|
|
| 19 |
recordSearchDuration: vi.fn(),
|
| 20 |
}));
|
| 21 |
|
|
@@ -23,12 +30,18 @@ vi.mock("./webSearchService", () => ({
|
|
| 23 |
fetchSearXNG: vi.fn(),
|
| 24 |
}));
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
import { handleTokenVerification } from "./handleTokenVerification";
|
| 27 |
import { rankSearchResults } from "./rankSearchResults";
|
| 28 |
import { getRerankerStatus } from "./rerankerService";
|
| 29 |
import { searchEndpointServerHook } from "./searchEndpointServerHook";
|
| 30 |
import {
|
| 31 |
incrementGraphicalSearchesSinceLastRestart,
|
|
|
|
|
|
|
| 32 |
incrementTextualSearchesSinceLastRestart,
|
| 33 |
recordSearchDuration,
|
| 34 |
} from "./searchesSinceLastRestart";
|
|
@@ -75,6 +88,11 @@ describe("searchEndpointServerHook", () => {
|
|
| 75 |
async (_query, _searchType, results) =>
|
| 76 |
results.map(([title, content, url]) => [title, content, url, 0]),
|
| 77 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
});
|
| 79 |
|
| 80 |
it("passes through requests that aren't under /search/", async () => {
|
|
@@ -425,6 +443,255 @@ describe("searchEndpointServerHook", () => {
|
|
| 425 |
JSON.stringify({ error: "Search service unavailable" }),
|
| 426 |
);
|
| 427 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
});
|
| 429 |
|
| 430 |
it("responds 500 when an unexpected error is thrown", async () => {
|
|
|
|
| 1 |
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
|
| 4 |
+
vi.mock("./fallbackSearchService", () => ({
|
| 5 |
+
fetchFallbackTextResults: vi.fn(),
|
| 6 |
+
isSearchFallbackEnabled: vi.fn(),
|
| 7 |
+
}));
|
| 8 |
+
|
| 9 |
vi.mock("./handleTokenVerification", () => ({
|
| 10 |
handleTokenVerification: vi.fn(),
|
| 11 |
}));
|
|
|
|
| 21 |
vi.mock("./searchesSinceLastRestart", () => ({
|
| 22 |
incrementTextualSearchesSinceLastRestart: vi.fn(),
|
| 23 |
incrementGraphicalSearchesSinceLastRestart: vi.fn(),
|
| 24 |
+
incrementSearchesServedByFallback: vi.fn(),
|
| 25 |
+
incrementSearchesFailedOnFallback: vi.fn(),
|
| 26 |
recordSearchDuration: vi.fn(),
|
| 27 |
}));
|
| 28 |
|
|
|
|
| 30 |
fetchSearXNG: vi.fn(),
|
| 31 |
}));
|
| 32 |
|
| 33 |
+
import {
|
| 34 |
+
fetchFallbackTextResults,
|
| 35 |
+
isSearchFallbackEnabled,
|
| 36 |
+
} from "./fallbackSearchService";
|
| 37 |
import { handleTokenVerification } from "./handleTokenVerification";
|
| 38 |
import { rankSearchResults } from "./rankSearchResults";
|
| 39 |
import { getRerankerStatus } from "./rerankerService";
|
| 40 |
import { searchEndpointServerHook } from "./searchEndpointServerHook";
|
| 41 |
import {
|
| 42 |
incrementGraphicalSearchesSinceLastRestart,
|
| 43 |
+
incrementSearchesFailedOnFallback,
|
| 44 |
+
incrementSearchesServedByFallback,
|
| 45 |
incrementTextualSearchesSinceLastRestart,
|
| 46 |
recordSearchDuration,
|
| 47 |
} from "./searchesSinceLastRestart";
|
|
|
|
| 88 |
async (_query, _searchType, results) =>
|
| 89 |
results.map(([title, content, url]) => [title, content, url, 0]),
|
| 90 |
);
|
| 91 |
+
// Off unless a test switches it on, and answered with something rather
|
| 92 |
+
// than undefined so an unexpected call fails a visible assertion instead
|
| 93 |
+
// of poisoning the results below.
|
| 94 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(false);
|
| 95 |
+
vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
|
| 96 |
});
|
| 97 |
|
| 98 |
it("passes through requests that aren't under /search/", async () => {
|
|
|
|
| 443 |
JSON.stringify({ error: "Search service unavailable" }),
|
| 444 |
);
|
| 445 |
});
|
| 446 |
+
|
| 447 |
+
describe("text search fallback", () => {
|
| 448 |
+
const fallbackResults: [string, string, string][] = [
|
| 449 |
+
["Fallback title", "Fallback snippet", "https://example.com/fallback"],
|
| 450 |
+
];
|
| 451 |
+
const rankedFallbackResults: [string, string, string, number][] = [
|
| 452 |
+
[
|
| 453 |
+
"Fallback title",
|
| 454 |
+
"Fallback snippet",
|
| 455 |
+
"https://example.com/fallback",
|
| 456 |
+
0.7,
|
| 457 |
+
],
|
| 458 |
+
];
|
| 459 |
+
|
| 460 |
+
function failSearxng() {
|
| 461 |
+
vi.mocked(fetchSearXNG).mockRejectedValue(
|
| 462 |
+
new Error("SearXNG request failed with status 503"),
|
| 463 |
+
);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
it("serves the fallback results through the normal text path", async () => {
|
| 467 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 468 |
+
failSearxng();
|
| 469 |
+
vi.mocked(fetchFallbackTextResults).mockResolvedValue(fallbackResults);
|
| 470 |
+
vi.mocked(getRerankerStatus).mockResolvedValue(true);
|
| 471 |
+
vi.mocked(rankSearchResults).mockResolvedValue(rankedFallbackResults);
|
| 472 |
+
// A search that has just started hands the fallback the whole budget.
|
| 473 |
+
vi.spyOn(performance, "now").mockReturnValue(0);
|
| 474 |
+
|
| 475 |
+
const handler = getRegisteredHandler();
|
| 476 |
+
const response = createResponse();
|
| 477 |
+
|
| 478 |
+
await handler(
|
| 479 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 480 |
+
response,
|
| 481 |
+
vi.fn(),
|
| 482 |
+
);
|
| 483 |
+
|
| 484 |
+
expect(fetchFallbackTextResults).toHaveBeenCalledWith(
|
| 485 |
+
"cats",
|
| 486 |
+
30,
|
| 487 |
+
25_000,
|
| 488 |
+
);
|
| 489 |
+
// Reranked exactly like SearXNG results, with the top ones preserved,
|
| 490 |
+
// and then answered by the one respond block the text path has.
|
| 491 |
+
expect(rankSearchResults).toHaveBeenCalledWith(
|
| 492 |
+
"cats",
|
| 493 |
+
"text",
|
| 494 |
+
fallbackResults,
|
| 495 |
+
true,
|
| 496 |
+
);
|
| 497 |
+
expect(incrementTextualSearchesSinceLastRestart).toHaveBeenCalledTimes(
|
| 498 |
+
1,
|
| 499 |
+
);
|
| 500 |
+
expect(incrementSearchesServedByFallback).toHaveBeenCalledTimes(1);
|
| 501 |
+
expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
|
| 502 |
+
// The duration metric is SearXNG's; the fallback's own latency is a
|
| 503 |
+
// different thing and would be misleading under the same average.
|
| 504 |
+
expect(recordSearchDuration).not.toHaveBeenCalled();
|
| 505 |
+
expect(response.statusCode).toBe(200);
|
| 506 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 507 |
+
JSON.stringify(rankedFallbackResults),
|
| 508 |
+
);
|
| 509 |
+
});
|
| 510 |
+
|
| 511 |
+
it("answers 200 with an empty list when the fallback returns nothing usable", async () => {
|
| 512 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 513 |
+
failSearxng();
|
| 514 |
+
vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
|
| 515 |
+
|
| 516 |
+
const handler = getRegisteredHandler();
|
| 517 |
+
const response = createResponse();
|
| 518 |
+
|
| 519 |
+
await handler(
|
| 520 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 521 |
+
response,
|
| 522 |
+
vi.fn(),
|
| 523 |
+
);
|
| 524 |
+
|
| 525 |
+
// The endpoint answered, so this is a search with no results rather
|
| 526 |
+
// than an outage: the client shows the no-results alert, not the
|
| 527 |
+
// unavailable one, and the fallback still counts as served.
|
| 528 |
+
expect(response.statusCode).toBe(200);
|
| 529 |
+
expect(response.end).toHaveBeenCalledWith(JSON.stringify([]));
|
| 530 |
+
expect(incrementSearchesServedByFallback).toHaveBeenCalledTimes(1);
|
| 531 |
+
expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
|
| 532 |
+
expect(recordSearchDuration).not.toHaveBeenCalled();
|
| 533 |
+
});
|
| 534 |
+
|
| 535 |
+
it("skips the fallback when the search deadline has already passed", async () => {
|
| 536 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 537 |
+
failSearxng();
|
| 538 |
+
// 0 for the search's start, then past the 25 s deadline.
|
| 539 |
+
vi.spyOn(performance, "now")
|
| 540 |
+
.mockReturnValueOnce(0)
|
| 541 |
+
.mockReturnValue(25_001);
|
| 542 |
+
|
| 543 |
+
const handler = getRegisteredHandler();
|
| 544 |
+
const response = createResponse();
|
| 545 |
+
|
| 546 |
+
await handler(
|
| 547 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 548 |
+
response,
|
| 549 |
+
vi.fn(),
|
| 550 |
+
);
|
| 551 |
+
|
| 552 |
+
// A fallback started now would answer after the client's own 30 s
|
| 553 |
+
// timeout, so it is never asked and the outage is reported the way it
|
| 554 |
+
// would have been with the feature off.
|
| 555 |
+
expect(fetchFallbackTextResults).not.toHaveBeenCalled();
|
| 556 |
+
expect(response.statusCode).toBe(502);
|
| 557 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 558 |
+
JSON.stringify({ error: "Search service unavailable" }),
|
| 559 |
+
);
|
| 560 |
+
expect(incrementSearchesFailedOnFallback).toHaveBeenCalledTimes(1);
|
| 561 |
+
expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
|
| 562 |
+
expect(recordSearchDuration).not.toHaveBeenCalled();
|
| 563 |
+
|
| 564 |
+
const logged = vi.mocked(console.error).mock.calls.flat().join(" ");
|
| 565 |
+
expect(logged).toContain(
|
| 566 |
+
"the search deadline left the fallback too little time",
|
| 567 |
+
);
|
| 568 |
+
expect(logged).not.toContain("cats");
|
| 569 |
+
});
|
| 570 |
+
|
| 571 |
+
it("hands the fallback the time left, not its own full budget", async () => {
|
| 572 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 573 |
+
failSearxng();
|
| 574 |
+
vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
|
| 575 |
+
// 0 for the search's start, then 12 s in when SearXNG gives up.
|
| 576 |
+
vi.spyOn(performance, "now")
|
| 577 |
+
.mockReturnValueOnce(0)
|
| 578 |
+
.mockReturnValue(12_000);
|
| 579 |
+
|
| 580 |
+
const handler = getRegisteredHandler();
|
| 581 |
+
const response = createResponse();
|
| 582 |
+
|
| 583 |
+
await handler(
|
| 584 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 585 |
+
response,
|
| 586 |
+
vi.fn(),
|
| 587 |
+
);
|
| 588 |
+
|
| 589 |
+
// 13 s are left of the 25 s deadline, which is under the fallback's
|
| 590 |
+
// own 15 s cap, so the search cannot outlive the client's patience.
|
| 591 |
+
expect(fetchFallbackTextResults).toHaveBeenCalledWith(
|
| 592 |
+
"cats",
|
| 593 |
+
30,
|
| 594 |
+
13_000,
|
| 595 |
+
);
|
| 596 |
+
expect(response.statusCode).toBe(200);
|
| 597 |
+
});
|
| 598 |
+
|
| 599 |
+
it("answers 502 when the fallback fails too", async () => {
|
| 600 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 601 |
+
failSearxng();
|
| 602 |
+
vi.mocked(fetchFallbackTextResults).mockRejectedValue(
|
| 603 |
+
new Error("The fallback search tool call failed with status 502"),
|
| 604 |
+
);
|
| 605 |
+
|
| 606 |
+
const handler = getRegisteredHandler();
|
| 607 |
+
const response = createResponse();
|
| 608 |
+
|
| 609 |
+
await handler(
|
| 610 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 611 |
+
response,
|
| 612 |
+
vi.fn(),
|
| 613 |
+
);
|
| 614 |
+
|
| 615 |
+
expect(response.statusCode).toBe(502);
|
| 616 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 617 |
+
JSON.stringify({ error: "Search service unavailable" }),
|
| 618 |
+
);
|
| 619 |
+
expect(incrementSearchesFailedOnFallback).toHaveBeenCalledTimes(1);
|
| 620 |
+
expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
|
| 621 |
+
expect(recordSearchDuration).not.toHaveBeenCalled();
|
| 622 |
+
|
| 623 |
+
const logged = vi.mocked(console.error).mock.calls.flat().join(" ");
|
| 624 |
+
expect(logged).toContain(
|
| 625 |
+
"The fallback search tool call failed with status 502",
|
| 626 |
+
);
|
| 627 |
+
expect(logged).not.toContain("cats");
|
| 628 |
+
});
|
| 629 |
+
|
| 630 |
+
it("answers 502 without consulting the fallback when it is switched off", async () => {
|
| 631 |
+
failSearxng();
|
| 632 |
+
|
| 633 |
+
const handler = getRegisteredHandler();
|
| 634 |
+
const response = createResponse();
|
| 635 |
+
|
| 636 |
+
await handler(
|
| 637 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 638 |
+
response,
|
| 639 |
+
vi.fn(),
|
| 640 |
+
);
|
| 641 |
+
|
| 642 |
+
expect(response.statusCode).toBe(502);
|
| 643 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 644 |
+
JSON.stringify({ error: "Search service unavailable" }),
|
| 645 |
+
);
|
| 646 |
+
expect(fetchFallbackTextResults).not.toHaveBeenCalled();
|
| 647 |
+
expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
|
| 648 |
+
expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
it("never consults the fallback for an image search", async () => {
|
| 652 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 653 |
+
failSearxng();
|
| 654 |
+
|
| 655 |
+
const handler = getRegisteredHandler();
|
| 656 |
+
const response = createResponse();
|
| 657 |
+
|
| 658 |
+
await handler(
|
| 659 |
+
createRequest("/search/images?q=cats&token=abc"),
|
| 660 |
+
response,
|
| 661 |
+
vi.fn(),
|
| 662 |
+
);
|
| 663 |
+
|
| 664 |
+
expect(response.statusCode).toBe(502);
|
| 665 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 666 |
+
JSON.stringify({ error: "Search service unavailable" }),
|
| 667 |
+
);
|
| 668 |
+
expect(fetchFallbackTextResults).not.toHaveBeenCalled();
|
| 669 |
+
expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
|
| 670 |
+
expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
|
| 671 |
+
});
|
| 672 |
+
|
| 673 |
+
it("never consults the fallback when SearXNG answers", async () => {
|
| 674 |
+
vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
|
| 675 |
+
vi.mocked(fetchSearXNG).mockResolvedValue(searxngResults);
|
| 676 |
+
|
| 677 |
+
const handler = getRegisteredHandler();
|
| 678 |
+
const response = createResponse();
|
| 679 |
+
|
| 680 |
+
await handler(
|
| 681 |
+
createRequest("/search/text?q=cats&token=abc"),
|
| 682 |
+
response,
|
| 683 |
+
vi.fn(),
|
| 684 |
+
);
|
| 685 |
+
|
| 686 |
+
expect(response.statusCode).toBe(200);
|
| 687 |
+
expect(response.end).toHaveBeenCalledWith(
|
| 688 |
+
JSON.stringify(searxngResults),
|
| 689 |
+
);
|
| 690 |
+
expect(fetchFallbackTextResults).not.toHaveBeenCalled();
|
| 691 |
+
expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
|
| 692 |
+
expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
|
| 693 |
+
});
|
| 694 |
+
});
|
| 695 |
});
|
| 696 |
|
| 697 |
it("responds 500 when an unexpected error is thrown", async () => {
|
server/searchEndpointServerHook.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
import type { PreviewServer, ViteDevServer } from "vite";
|
| 2 |
import { z } from "zod";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { handleTokenVerification } from "./handleTokenVerification.ts";
|
| 4 |
import { rankSearchResults } from "./rankSearchResults.ts";
|
| 5 |
import { getRerankerStatus } from "./rerankerService.ts";
|
|
@@ -9,6 +13,8 @@ import {
|
|
| 9 |
} from "./rerankingSinceLastRestart.ts";
|
| 10 |
import {
|
| 11 |
incrementGraphicalSearchesSinceLastRestart,
|
|
|
|
|
|
|
| 12 |
incrementTextualSearchesSinceLastRestart,
|
| 13 |
recordSearchDuration,
|
| 14 |
type SearchType,
|
|
@@ -19,6 +25,14 @@ import { fetchSearXNG } from "./webSearchService.ts";
|
|
| 19 |
const DEFAULT_SEARCH_LIMIT = 30;
|
| 20 |
const MAX_SEARCH_LIMIT = 30;
|
| 21 |
const MAX_QUERY_LENGTH = 2000;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const searchParamsSchema = z.object({
|
| 24 |
query: z
|
|
@@ -119,14 +133,61 @@ export function searchEndpointServerHook<
|
|
| 119 |
const isTextSearch = request.url?.startsWith("/search/text");
|
| 120 |
const searchType = isTextSearch ? "text" : "images";
|
| 121 |
|
| 122 |
-
let
|
| 123 |
const searchStartedAt = performance.now();
|
| 124 |
try {
|
| 125 |
-
|
| 126 |
recordSearchDuration(searchType, performance.now() - searchStartedAt);
|
| 127 |
} catch {
|
| 128 |
-
// SearXNG is unreachable
|
| 129 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
response.statusCode = 502;
|
| 131 |
response.setHeader("Content-Type", "application/json");
|
| 132 |
response.end(JSON.stringify({ error: "Search service unavailable" }));
|
|
@@ -134,7 +195,7 @@ export function searchEndpointServerHook<
|
|
| 134 |
}
|
| 135 |
|
| 136 |
if (isTextSearch) {
|
| 137 |
-
const results =
|
| 138 |
const rankedResults = await handleRanking(
|
| 139 |
query,
|
| 140 |
searchType,
|
|
@@ -147,7 +208,7 @@ export function searchEndpointServerHook<
|
|
| 147 |
response.setHeader("Content-Type", "application/json");
|
| 148 |
response.end(JSON.stringify(rankedResults));
|
| 149 |
} else {
|
| 150 |
-
const results =
|
| 151 |
const resultsText = results.map(
|
| 152 |
([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
|
| 153 |
);
|
|
|
|
| 1 |
import type { PreviewServer, ViteDevServer } from "vite";
|
| 2 |
import { z } from "zod";
|
| 3 |
+
import {
|
| 4 |
+
fetchFallbackTextResults,
|
| 5 |
+
isSearchFallbackEnabled,
|
| 6 |
+
} from "./fallbackSearchService.ts";
|
| 7 |
import { handleTokenVerification } from "./handleTokenVerification.ts";
|
| 8 |
import { rankSearchResults } from "./rankSearchResults.ts";
|
| 9 |
import { getRerankerStatus } from "./rerankerService.ts";
|
|
|
|
| 13 |
} from "./rerankingSinceLastRestart.ts";
|
| 14 |
import {
|
| 15 |
incrementGraphicalSearchesSinceLastRestart,
|
| 16 |
+
incrementSearchesFailedOnFallback,
|
| 17 |
+
incrementSearchesServedByFallback,
|
| 18 |
incrementTextualSearchesSinceLastRestart,
|
| 19 |
recordSearchDuration,
|
| 20 |
type SearchType,
|
|
|
|
| 25 |
const DEFAULT_SEARCH_LIMIT = 30;
|
| 26 |
const MAX_SEARCH_LIMIT = 30;
|
| 27 |
const MAX_QUERY_LENGTH = 2000;
|
| 28 |
+
// The client aborts a search after REQUEST_TIMEOUT in client/modules/search.ts
|
| 29 |
+
// (30 s) and shows its timeout alert, so a response still being assembled when
|
| 30 |
+
// that lands is a response the user never sees. The fallback may not push a
|
| 31 |
+
// search past this point; the 5 s margin is for ranking and the transfer.
|
| 32 |
+
const SEARCH_DEADLINE_MS = 25_000;
|
| 33 |
+
// A fallback cannot finish in this window, so starting one would only move the
|
| 34 |
+
// same 502 further out.
|
| 35 |
+
const MIN_FALLBACK_BUDGET_MS = 2_000;
|
| 36 |
|
| 37 |
const searchParamsSchema = z.object({
|
| 38 |
query: z
|
|
|
|
| 133 |
const isTextSearch = request.url?.startsWith("/search/text");
|
| 134 |
const searchType = isTextSearch ? "text" : "images";
|
| 135 |
|
| 136 |
+
let searchResults: TextResult[] | ImageResult[] | null;
|
| 137 |
const searchStartedAt = performance.now();
|
| 138 |
try {
|
| 139 |
+
searchResults = await fetchSearXNG(query, searchType, limit);
|
| 140 |
recordSearchDuration(searchType, performance.now() - searchStartedAt);
|
| 141 |
} catch {
|
| 142 |
+
// SearXNG is unreachable. The operator may have switched on a second
|
| 143 |
+
// text source, which is consulted only here, after SearXNG has failed,
|
| 144 |
+
// and never for images: Parallel has no image search.
|
| 145 |
+
searchResults = null;
|
| 146 |
+
|
| 147 |
+
if (isTextSearch && isSearchFallbackEnabled()) {
|
| 148 |
+
const remainingMs =
|
| 149 |
+
SEARCH_DEADLINE_MS - (performance.now() - searchStartedAt);
|
| 150 |
+
|
| 151 |
+
if (remainingMs < MIN_FALLBACK_BUDGET_MS) {
|
| 152 |
+
// The deadline leaves too little time for a second source, so it is
|
| 153 |
+
// not asked and the 502 goes out now. Counted as a fallback that
|
| 154 |
+
// failed rather than one that served, since none of its results
|
| 155 |
+
// could have reached the client in time.
|
| 156 |
+
incrementSearchesFailedOnFallback();
|
| 157 |
+
console.error(
|
| 158 |
+
"SearXNG failed, and the search deadline left the fallback too little time",
|
| 159 |
+
);
|
| 160 |
+
} else {
|
| 161 |
+
try {
|
| 162 |
+
searchResults = await fetchFallbackTextResults(
|
| 163 |
+
query,
|
| 164 |
+
limit,
|
| 165 |
+
remainingMs,
|
| 166 |
+
);
|
| 167 |
+
incrementSearchesServedByFallback();
|
| 168 |
+
console.log(
|
| 169 |
+
"SearXNG failed, served text results from the fallback search",
|
| 170 |
+
);
|
| 171 |
+
} catch (fallbackError) {
|
| 172 |
+
incrementSearchesFailedOnFallback();
|
| 173 |
+
// Fixed text plus the error's message, which by construction
|
| 174 |
+
// carries an HTTP status or a JSON-RPC code, never the query.
|
| 175 |
+
console.error(
|
| 176 |
+
`SearXNG failed, and the fallback search failed: ${
|
| 177 |
+
fallbackError instanceof Error
|
| 178 |
+
? fallbackError.message
|
| 179 |
+
: String(fallbackError)
|
| 180 |
+
}`,
|
| 181 |
+
);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
if (searchResults === null) {
|
| 188 |
+
// The fallback is off, is not offered for this search type, was skipped
|
| 189 |
+
// for lack of deadline, or failed itself: answer non-200 so the client
|
| 190 |
+
// can tell an outage apart from a search that genuinely has no results.
|
| 191 |
response.statusCode = 502;
|
| 192 |
response.setHeader("Content-Type", "application/json");
|
| 193 |
response.end(JSON.stringify({ error: "Search service unavailable" }));
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
if (isTextSearch) {
|
| 198 |
+
const results = searchResults as TextResult[];
|
| 199 |
const rankedResults = await handleRanking(
|
| 200 |
query,
|
| 201 |
searchType,
|
|
|
|
| 208 |
response.setHeader("Content-Type", "application/json");
|
| 209 |
response.end(JSON.stringify(rankedResults));
|
| 210 |
} else {
|
| 211 |
+
const results = searchResults as ImageResult[];
|
| 212 |
const resultsText = results.map(
|
| 213 |
([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
|
| 214 |
);
|
server/searchesSinceLastRestart.ts
CHANGED
|
@@ -53,6 +53,31 @@ export function incrementSearchesWithAllResultsDiscardedSinceLastRestart() {
|
|
| 53 |
searchesWithAllResultsDiscardedSinceLastRestart++;
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
/** One engine SearXNG reported as unresponsive, with the reason it gave. */
|
| 57 |
export interface UnresponsiveEngine {
|
| 58 |
engine: string;
|
|
|
|
| 53 |
searchesWithAllResultsDiscardedSinceLastRestart++;
|
| 54 |
}
|
| 55 |
|
| 56 |
+
// The fallback is only consulted after SearXNG has already failed, so these two
|
| 57 |
+
// say whether the second source pulls its weight: served is a search the caller
|
| 58 |
+
// got anyway, failed is one that ended as the 502 there was before it existed,
|
| 59 |
+
// including a search whose SearXNG retries left the deadline too little time to
|
| 60 |
+
// ask the fallback at all. A search with the fallback off, or an image search,
|
| 61 |
+
// lands in neither.
|
| 62 |
+
let searchesServedByFallback = 0;
|
| 63 |
+
let searchesFailedOnFallback = 0;
|
| 64 |
+
|
| 65 |
+
export function getSearchesServedByFallback() {
|
| 66 |
+
return searchesServedByFallback;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export function incrementSearchesServedByFallback() {
|
| 70 |
+
searchesServedByFallback++;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function getSearchesFailedOnFallback() {
|
| 74 |
+
return searchesFailedOnFallback;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function incrementSearchesFailedOnFallback() {
|
| 78 |
+
searchesFailedOnFallback++;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
/** One engine SearXNG reported as unresponsive, with the reason it gave. */
|
| 82 |
export interface UnresponsiveEngine {
|
| 83 |
engine: string;
|
server/statusEndpointServerHook.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
import { describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { statusEndpointServerHook } from "./statusEndpointServerHook.ts";
|
| 4 |
|
| 5 |
// The health getters probe live services; this test is about the payload,
|
|
@@ -67,6 +71,26 @@ describe("statusEndpointServerHook", () => {
|
|
| 67 |
});
|
| 68 |
});
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
it("publishes the page-read circuit count beside the outcome counters", async () => {
|
| 71 |
const status = await callStatus();
|
| 72 |
|
|
|
|
| 1 |
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
import { describe, expect, it, vi } from "vitest";
|
| 3 |
+
import {
|
| 4 |
+
incrementSearchesFailedOnFallback,
|
| 5 |
+
incrementSearchesServedByFallback,
|
| 6 |
+
} from "./searchesSinceLastRestart.ts";
|
| 7 |
import { statusEndpointServerHook } from "./statusEndpointServerHook.ts";
|
| 8 |
|
| 9 |
// The health getters probe live services; this test is about the payload,
|
|
|
|
| 71 |
});
|
| 72 |
});
|
| 73 |
|
| 74 |
+
it("publishes the two fallback counters as plain counts", async () => {
|
| 75 |
+
const before = await callStatus();
|
| 76 |
+
|
| 77 |
+
incrementSearchesServedByFallback();
|
| 78 |
+
incrementSearchesFailedOnFallback();
|
| 79 |
+
incrementSearchesFailedOnFallback();
|
| 80 |
+
|
| 81 |
+
const status = await callStatus();
|
| 82 |
+
|
| 83 |
+
// Exact values rather than expect.any(Number), which a pair of swapped
|
| 84 |
+
// getters would satisfy too. The counters are module state that no test
|
| 85 |
+
// here resets, so the deltas are what each getter moved.
|
| 86 |
+
expect(status.searchesServedByFallback).toBe(
|
| 87 |
+
(before.searchesServedByFallback as number) + 1,
|
| 88 |
+
);
|
| 89 |
+
expect(status.searchesFailedOnFallback).toBe(
|
| 90 |
+
(before.searchesFailedOnFallback as number) + 2,
|
| 91 |
+
);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
it("publishes the page-read circuit count beside the outcome counters", async () => {
|
| 95 |
const status = await callStatus();
|
| 96 |
|
server/statusEndpointServerHook.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { getRerankingStats } from "./rerankingSinceLastRestart.ts";
|
|
| 10 |
import {
|
| 11 |
getDegradedSearchTypes,
|
| 12 |
getGraphicalSearchesSinceLastRestart,
|
|
|
|
|
|
|
| 13 |
getSearchesWithAllResultsDiscardedSinceLastRestart,
|
| 14 |
getSearchesWithoutResultsSinceLastRestart,
|
| 15 |
getSearchesWithUnresponsiveEnginesSinceLastRestart,
|
|
@@ -83,6 +85,8 @@ export function statusEndpointServerHook<
|
|
| 83 |
getSearchesWithUnresponsiveEnginesSinceLastRestart(),
|
| 84 |
searchesWithAllResultsDiscarded:
|
| 85 |
getSearchesWithAllResultsDiscardedSinceLastRestart(),
|
|
|
|
|
|
|
| 86 |
biEncoderServiceStatus,
|
| 87 |
rerankerServiceStatus,
|
| 88 |
webSearchServiceStatus,
|
|
|
|
| 10 |
import {
|
| 11 |
getDegradedSearchTypes,
|
| 12 |
getGraphicalSearchesSinceLastRestart,
|
| 13 |
+
getSearchesFailedOnFallback,
|
| 14 |
+
getSearchesServedByFallback,
|
| 15 |
getSearchesWithAllResultsDiscardedSinceLastRestart,
|
| 16 |
getSearchesWithoutResultsSinceLastRestart,
|
| 17 |
getSearchesWithUnresponsiveEnginesSinceLastRestart,
|
|
|
|
| 85 |
getSearchesWithUnresponsiveEnginesSinceLastRestart(),
|
| 86 |
searchesWithAllResultsDiscarded:
|
| 87 |
getSearchesWithAllResultsDiscardedSinceLastRestart(),
|
| 88 |
+
searchesServedByFallback: getSearchesServedByFallback(),
|
| 89 |
+
searchesFailedOnFallback: getSearchesFailedOnFallback(),
|
| 90 |
biEncoderServiceStatus,
|
| 91 |
rerankerServiceStatus,
|
| 92 |
webSearchServiceStatus,
|