ubersearch 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/providers/searxng/.env.example +1 -1
- package/src/bootstrap/container.ts +3 -2
- package/src/cli.ts +6 -14
- package/src/config/load.ts +10 -13
- package/src/config/validation.ts +22 -1
- package/src/core/credits/FileCreditStateProvider.ts +1 -1
- package/src/core/docker/dockerComposeHelper.ts +15 -6
- package/src/core/docker/dockerLifecycleManager.ts +21 -9
- package/src/core/errorUtils.ts +6 -0
- package/src/core/orchestrator.ts +2 -28
- package/src/core/paths.ts +7 -8
- package/src/core/provider.ts +1 -14
- package/src/core/retry.ts +102 -0
- package/src/core/strategy/AllProvidersStrategy.ts +43 -27
- package/src/core/strategy/FirstSuccessStrategy.ts +3 -4
- package/src/core/strategy/ISearchStrategy.ts +2 -2
- package/src/core/types.ts +1 -0
- package/src/mcp-server.ts +110 -11
- package/src/plugin/PluginRegistry.ts +17 -6
- package/src/plugin/builtin.ts +8 -10
- package/src/plugin/types.ts +3 -4
- package/src/providers/brave.ts +2 -7
- package/src/providers/helpers/resultMappers.ts +2 -2
- package/src/providers/linkup.ts +15 -47
- package/src/providers/retry.ts +1 -102
- package/src/providers/searchxng.ts +24 -45
- package/src/providers/tavily.ts +10 -16
- package/src/tool/interface.ts +3 -1
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Copy this file to .env and update values
|
|
3
3
|
|
|
4
4
|
# Secret key for SearXNG (generate with: openssl rand -hex 32)
|
|
5
|
-
|
|
5
|
+
SEARXNG_SECRET=your-secret-key-here
|
|
6
6
|
|
|
7
7
|
# API key for ubersearch integration
|
|
8
8
|
# Generate with: openssl rand -hex 16
|
|
@@ -9,6 +9,7 @@ import type { EngineConfig, UberSearchConfig } from "../config/types";
|
|
|
9
9
|
import { type Container, container } from "../core/container";
|
|
10
10
|
import { CreditManager } from "../core/credits";
|
|
11
11
|
import { FileCreditStateProvider } from "../core/credits/FileCreditStateProvider";
|
|
12
|
+
import { getErrorMessage } from "../core/errorUtils";
|
|
12
13
|
import { createLogger } from "../core/logger";
|
|
13
14
|
import { UberSearchOrchestrator } from "../core/orchestrator";
|
|
14
15
|
import type { SearchProvider } from "../core/provider";
|
|
@@ -103,8 +104,8 @@ export async function bootstrapContainer(
|
|
|
103
104
|
registry.register(provider);
|
|
104
105
|
log.debug(`Registered provider: ${engineConfig.id}`);
|
|
105
106
|
} catch (error) {
|
|
106
|
-
const errorMsg =
|
|
107
|
-
log.
|
|
107
|
+
const errorMsg = getErrorMessage(error);
|
|
108
|
+
log.warn(`Failed to register provider ${engineConfig.id}: ${errorMsg}`);
|
|
108
109
|
failedProviders.push(engineConfig.id);
|
|
109
110
|
}
|
|
110
111
|
}
|
package/src/cli.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { bootstrapContainer } from "./bootstrap/container";
|
|
9
|
+
import { getErrorMessage } from "./core/errorUtils";
|
|
9
10
|
import type { ProviderRegistry } from "./core/provider";
|
|
10
11
|
import { ServiceKeys } from "./core/serviceKeys";
|
|
11
12
|
import { serve as serveMcp } from "./mcp-server";
|
|
12
|
-
import { isLifecycleProvider } from "./plugin/types
|
|
13
|
+
import { isLifecycleProvider } from "./plugin/types";
|
|
13
14
|
import type { UberSearchOutput } from "./tool/interface";
|
|
14
15
|
import { getCreditStatus, uberSearch } from "./tool/uberSearchTool";
|
|
15
16
|
|
|
@@ -102,12 +103,6 @@ if (args[0] === "health") {
|
|
|
102
103
|
process.exit(0);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
// MCP server command
|
|
106
|
-
if (args[0] === "mcp") {
|
|
107
|
-
await serveMcp();
|
|
108
|
-
process.exit(0);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
106
|
// Parse options
|
|
112
107
|
const options = {
|
|
113
108
|
json: args.includes("--json"),
|
|
@@ -214,7 +209,7 @@ async function main() {
|
|
|
214
209
|
printHumanReadable(result);
|
|
215
210
|
}
|
|
216
211
|
} catch (error) {
|
|
217
|
-
console.error("Search failed:",
|
|
212
|
+
console.error("Search failed:", getErrorMessage(error));
|
|
218
213
|
process.exit(1);
|
|
219
214
|
}
|
|
220
215
|
}
|
|
@@ -333,7 +328,7 @@ async function runHealthChecks(configPath?: string) {
|
|
|
333
328
|
});
|
|
334
329
|
console.log(`✓ ${engineId.padEnd(15)} Healthy`);
|
|
335
330
|
} catch (error) {
|
|
336
|
-
const message =
|
|
331
|
+
const message = getErrorMessage(error);
|
|
337
332
|
results.push({
|
|
338
333
|
engineId,
|
|
339
334
|
status: "unhealthy",
|
|
@@ -367,7 +362,7 @@ async function runHealthChecks(configPath?: string) {
|
|
|
367
362
|
|
|
368
363
|
console.log();
|
|
369
364
|
} catch (error) {
|
|
370
|
-
console.error("Health check failed:",
|
|
365
|
+
console.error("Health check failed:", getErrorMessage(error));
|
|
371
366
|
process.exit(1);
|
|
372
367
|
}
|
|
373
368
|
}
|
|
@@ -403,10 +398,7 @@ async function showCredits(configPath?: string) {
|
|
|
403
398
|
|
|
404
399
|
console.log();
|
|
405
400
|
} catch (error) {
|
|
406
|
-
console.error(
|
|
407
|
-
"Failed to load credits:",
|
|
408
|
-
error instanceof Error ? error.message : String(error),
|
|
409
|
-
);
|
|
401
|
+
console.error("Failed to load credits:", getErrorMessage(error));
|
|
410
402
|
process.exit(1);
|
|
411
403
|
}
|
|
412
404
|
}
|
package/src/config/load.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
+
import { getErrorMessage } from "../core/errorUtils";
|
|
17
18
|
import { getBundledSearxngComposePath } from "../core/paths";
|
|
18
19
|
import { PluginRegistry, registerBuiltInPlugins } from "../plugin";
|
|
19
20
|
import type { ConfigFactory, ExtendedSearchConfig } from "./defineConfig";
|
|
@@ -89,7 +90,10 @@ async function loadTypeScriptConfig(path: string): Promise<ExtendedSearchConfig>
|
|
|
89
90
|
const configOrFactory = module.default ?? module.config;
|
|
90
91
|
|
|
91
92
|
if (!configOrFactory) {
|
|
92
|
-
throw new Error(
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Config file at ${path} must export a config object as 'default' or 'config'. ` +
|
|
95
|
+
`Found exports: ${Object.keys(module).join(", ") || "none"}`,
|
|
96
|
+
);
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
// Handle factory functions (async config)
|
|
@@ -99,9 +103,7 @@ async function loadTypeScriptConfig(path: string): Promise<ExtendedSearchConfig>
|
|
|
99
103
|
|
|
100
104
|
return configOrFactory as ExtendedSearchConfig;
|
|
101
105
|
} catch (error) {
|
|
102
|
-
throw new Error(
|
|
103
|
-
`Failed to load TypeScript config from ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
104
|
-
);
|
|
106
|
+
throw new Error(`Failed to load TypeScript config from ${path}: ${getErrorMessage(error)}`);
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -201,12 +203,11 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
201
203
|
type: "searchxng",
|
|
202
204
|
enabled: true,
|
|
203
205
|
displayName: "SearXNG (Local)",
|
|
204
|
-
apiKeyEnv: "SEARXNG_API_KEY",
|
|
205
206
|
endpoint: "http://localhost:8888/search",
|
|
206
207
|
composeFile,
|
|
207
208
|
containerName: "searxng",
|
|
208
209
|
healthEndpoint: "http://localhost:8888/healthz",
|
|
209
|
-
defaultLimit:
|
|
210
|
+
defaultLimit: 10,
|
|
210
211
|
monthlyQuota: 10000,
|
|
211
212
|
creditCostPerSearch: 0,
|
|
212
213
|
lowCreditThresholdPercent: 80,
|
|
@@ -225,7 +226,7 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
225
226
|
displayName: "Brave Search",
|
|
226
227
|
apiKeyEnv: "BRAVE_API_KEY",
|
|
227
228
|
endpoint: "https://api.search.brave.com/res/v1/web/search",
|
|
228
|
-
defaultLimit:
|
|
229
|
+
defaultLimit: 10,
|
|
229
230
|
monthlyQuota: 2000,
|
|
230
231
|
creditCostPerSearch: 1,
|
|
231
232
|
lowCreditThresholdPercent: 80,
|
|
@@ -284,9 +285,7 @@ export async function loadConfig(
|
|
|
284
285
|
rawConfig = loadJsonConfig(path);
|
|
285
286
|
}
|
|
286
287
|
} catch (error) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`Failed to load config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
289
|
-
);
|
|
288
|
+
throw new Error(`Failed to load config file at ${path}: ${getErrorMessage(error)}`);
|
|
290
289
|
}
|
|
291
290
|
|
|
292
291
|
// Resolve relative paths (like composeFile) to absolute paths
|
|
@@ -359,9 +358,7 @@ export function loadConfigSync(
|
|
|
359
358
|
try {
|
|
360
359
|
rawConfig = loadJsonConfig(path);
|
|
361
360
|
} catch (error) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`Failed to parse config file at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
364
|
-
);
|
|
361
|
+
throw new Error(`Failed to parse config file at ${path}: ${getErrorMessage(error)}`);
|
|
365
362
|
}
|
|
366
363
|
|
|
367
364
|
// Resolve relative paths (like composeFile) to absolute paths
|
package/src/config/validation.ts
CHANGED
|
@@ -73,7 +73,28 @@ export const UberSearchConfigSchema = z
|
|
|
73
73
|
})
|
|
74
74
|
.optional(),
|
|
75
75
|
})
|
|
76
|
-
.passthrough()
|
|
76
|
+
.passthrough()
|
|
77
|
+
.refine(
|
|
78
|
+
(config) => {
|
|
79
|
+
const ids = config.engines.map((e: { id: string }) => e.id);
|
|
80
|
+
return new Set(ids).size === ids.length;
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
message: "Duplicate engine IDs found in engines array",
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
.refine(
|
|
87
|
+
(config) => {
|
|
88
|
+
if (!config.defaultEngineOrder) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const engineIds = new Set(config.engines.map((e: { id: string }) => e.id));
|
|
92
|
+
return config.defaultEngineOrder.every((id: string) => engineIds.has(id));
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
message: "defaultEngineOrder contains engine IDs not defined in engines array",
|
|
96
|
+
},
|
|
97
|
+
);
|
|
77
98
|
|
|
78
99
|
// CLI input schema
|
|
79
100
|
export const CliInputSchema = z.object({
|
|
@@ -112,7 +112,7 @@ export class FileCreditStateProvider implements CreditStateProvider {
|
|
|
112
112
|
// Write file using Bun's async API
|
|
113
113
|
await Bun.write(this.statePath, JSON.stringify(state, null, 2));
|
|
114
114
|
} catch (error) {
|
|
115
|
-
log.
|
|
115
|
+
log.error(`Failed to save credit state to ${this.statePath}:`, error);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { getSearxngPaths } from "../paths";
|
|
12
12
|
|
|
@@ -30,7 +30,8 @@ function getSearxngSecret(configDir: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Generate a new secret using crypto
|
|
33
|
-
const
|
|
33
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
34
|
+
const secret = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
34
35
|
try {
|
|
35
36
|
writeFileSync(secretFile, secret, { mode: 0o600 });
|
|
36
37
|
} catch {
|
|
@@ -69,7 +70,9 @@ export class DockerComposeHelper {
|
|
|
69
70
|
cwd,
|
|
70
71
|
timeout,
|
|
71
72
|
env: {
|
|
72
|
-
|
|
73
|
+
PATH: process.env.PATH,
|
|
74
|
+
HOME: process.env.HOME,
|
|
75
|
+
DOCKER_HOST: process.env.DOCKER_HOST,
|
|
73
76
|
SEARXNG_CONFIG: configDir,
|
|
74
77
|
SEARXNG_DATA: dataDir,
|
|
75
78
|
SEARXNG_SECRET: getSearxngSecret(configDir),
|
|
@@ -105,7 +108,7 @@ export class DockerComposeHelper {
|
|
|
105
108
|
* Get directory containing compose file
|
|
106
109
|
*/
|
|
107
110
|
private getComposeDir(): string {
|
|
108
|
-
return
|
|
111
|
+
return dirname(this.composeFile);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/**
|
|
@@ -173,13 +176,19 @@ export class DockerComposeHelper {
|
|
|
173
176
|
async isRunning(service?: string, options?: DockerComposeOptions): Promise<boolean> {
|
|
174
177
|
try {
|
|
175
178
|
const output = await this.ps(options);
|
|
179
|
+
const lines = output.split("\n").filter((l) => l.trim());
|
|
176
180
|
|
|
177
181
|
if (service) {
|
|
178
|
-
return
|
|
182
|
+
return lines.some((line) => {
|
|
183
|
+
const lower = line.toLowerCase();
|
|
184
|
+
return (
|
|
185
|
+
lower.includes(service.toLowerCase()) && /\bup\b/i.test(line) && !/exit/i.test(line)
|
|
186
|
+
);
|
|
187
|
+
});
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
// Check if any services are running
|
|
182
|
-
return
|
|
191
|
+
return lines.some((line) => /\bup\b/i.test(line));
|
|
183
192
|
} catch (_error) {
|
|
184
193
|
return false;
|
|
185
194
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { getErrorMessage } from "../errorUtils";
|
|
23
24
|
import { createLogger } from "../logger";
|
|
24
25
|
import { bootstrapSearxngConfig } from "../paths";
|
|
25
26
|
import { DockerComposeHelper } from "./dockerComposeHelper";
|
|
@@ -71,7 +72,7 @@ export class DockerLifecycleManager {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
this.initPromise = this.performInit().catch((error) => {
|
|
74
|
-
const message =
|
|
75
|
+
const message = getErrorMessage(error);
|
|
75
76
|
log.debug("Initialization failed:", message);
|
|
76
77
|
this.initialized = false;
|
|
77
78
|
throw error;
|
|
@@ -88,11 +89,19 @@ export class DockerLifecycleManager {
|
|
|
88
89
|
timeoutMs: number,
|
|
89
90
|
operation: string,
|
|
90
91
|
): Promise<T> {
|
|
92
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
91
93
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
92
|
-
|
|
94
|
+
timeoutId = setTimeout(
|
|
95
|
+
() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)),
|
|
96
|
+
timeoutMs,
|
|
97
|
+
);
|
|
93
98
|
});
|
|
94
99
|
|
|
95
|
-
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
}
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
private async performInit(): Promise<void> {
|
|
@@ -141,7 +150,7 @@ export class DockerLifecycleManager {
|
|
|
141
150
|
|
|
142
151
|
this.initialized = true;
|
|
143
152
|
} catch (error) {
|
|
144
|
-
const message =
|
|
153
|
+
const message = getErrorMessage(error);
|
|
145
154
|
log.debug("Failed to start container:", message);
|
|
146
155
|
throw error;
|
|
147
156
|
} finally {
|
|
@@ -171,8 +180,11 @@ export class DockerLifecycleManager {
|
|
|
171
180
|
if (response.ok) {
|
|
172
181
|
return true;
|
|
173
182
|
}
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
log.debug(`Health endpoint returned non-ok status: ${response.status}`);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const msg = getErrorMessage(error);
|
|
186
|
+
log.debug(`Health endpoint check failed: ${msg}`);
|
|
187
|
+
// Fall through to Docker status check
|
|
176
188
|
} finally {
|
|
177
189
|
clearTimeout(timeoutId);
|
|
178
190
|
}
|
|
@@ -243,7 +255,7 @@ export class DockerLifecycleManager {
|
|
|
243
255
|
);
|
|
244
256
|
log.debug("Container stopped.");
|
|
245
257
|
} catch (error) {
|
|
246
|
-
const message =
|
|
258
|
+
const message = getErrorMessage(error);
|
|
247
259
|
log.debug("Failed to stop container:", message);
|
|
248
260
|
// Don't throw on shutdown errors
|
|
249
261
|
}
|
|
@@ -306,7 +318,7 @@ export class DockerLifecycleManager {
|
|
|
306
318
|
const projectRoot = this.config.projectRoot || process.cwd();
|
|
307
319
|
await this.dockerHelper.ps({ cwd: projectRoot });
|
|
308
320
|
} catch (error: unknown) {
|
|
309
|
-
const message =
|
|
321
|
+
const message = getErrorMessage(error);
|
|
310
322
|
if (message.includes("config")) {
|
|
311
323
|
errors.push(`Invalid compose file: ${message}`);
|
|
312
324
|
}
|
|
@@ -357,7 +369,7 @@ export class DockerLifecycleManager {
|
|
|
357
369
|
cwd: projectRoot,
|
|
358
370
|
});
|
|
359
371
|
} catch (error) {
|
|
360
|
-
const message =
|
|
372
|
+
const message = getErrorMessage(error);
|
|
361
373
|
log.debug("Error checking if container is running:", message);
|
|
362
374
|
return false;
|
|
363
375
|
}
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -7,32 +7,11 @@
|
|
|
7
7
|
import type { UberSearchConfig } from "../config/types";
|
|
8
8
|
import type { CreditManager, CreditSnapshot } from "./credits";
|
|
9
9
|
import type { ProviderRegistry } from "./provider";
|
|
10
|
-
import type { StrategyContext } from "./strategy/ISearchStrategy";
|
|
10
|
+
import type { EngineAttempt, StrategyContext, UberSearchOptions } from "./strategy/ISearchStrategy";
|
|
11
11
|
import { StrategyFactory } from "./strategy/StrategyFactory";
|
|
12
12
|
import type { EngineId, SearchResultItem } from "./types";
|
|
13
13
|
|
|
14
|
-
export
|
|
15
|
-
/** Override the default engine order */
|
|
16
|
-
engineOrderOverride?: EngineId[];
|
|
17
|
-
|
|
18
|
-
/** Maximum results per provider */
|
|
19
|
-
limit?: number;
|
|
20
|
-
|
|
21
|
-
/** Include raw provider responses */
|
|
22
|
-
includeRaw?: boolean;
|
|
23
|
-
|
|
24
|
-
/** Search strategy */
|
|
25
|
-
strategy?: "all" | "first-success";
|
|
26
|
-
|
|
27
|
-
/** SearXNG categories to search (e.g., ["general", "it", "science"]) */
|
|
28
|
-
categories?: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface EngineAttempt {
|
|
32
|
-
engineId: EngineId;
|
|
33
|
-
success: boolean;
|
|
34
|
-
reason?: string;
|
|
35
|
-
}
|
|
14
|
+
export type { UberSearchOptions, EngineAttempt };
|
|
36
15
|
|
|
37
16
|
export interface OrchestratorResult {
|
|
38
17
|
/** Original query */
|
|
@@ -89,11 +68,6 @@ export class UberSearchOrchestrator {
|
|
|
89
68
|
// Execute strategy
|
|
90
69
|
const { results, attempts } = await strategy.execute(query, order, options, context);
|
|
91
70
|
|
|
92
|
-
// For 'all' strategy, sort results by score (descending)
|
|
93
|
-
if (strategyName === "all") {
|
|
94
|
-
results.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
71
|
// Get credit snapshots
|
|
98
72
|
const credits = this.credits.listSnapshots();
|
|
99
73
|
|
package/src/core/paths.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -170,11 +170,6 @@ export function bootstrapSearxngConfig(): boolean {
|
|
|
170
170
|
const { configDir } = getSearxngPaths();
|
|
171
171
|
const targetSettings = join(configDir, "settings.yml");
|
|
172
172
|
|
|
173
|
-
// If settings already exist, don't overwrite
|
|
174
|
-
if (existsSync(targetSettings)) {
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
173
|
const defaultSettings = getDefaultSettingsPath();
|
|
179
174
|
|
|
180
175
|
// If default settings don't exist, we can't bootstrap
|
|
@@ -186,12 +181,16 @@ export function bootstrapSearxngConfig(): boolean {
|
|
|
186
181
|
return false;
|
|
187
182
|
}
|
|
188
183
|
|
|
189
|
-
//
|
|
184
|
+
// Use exclusive flag to prevent TOCTOU race and avoid overwriting
|
|
190
185
|
try {
|
|
191
|
-
|
|
186
|
+
const content = readFileSync(defaultSettings);
|
|
187
|
+
writeFileSync(targetSettings, content, { flag: "wx" });
|
|
192
188
|
console.log(`[SearXNG] Bootstrapped default config to ${targetSettings}`);
|
|
193
189
|
return true;
|
|
194
190
|
} catch (error) {
|
|
191
|
+
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") {
|
|
192
|
+
return false; // File already exists, no action needed
|
|
193
|
+
}
|
|
195
194
|
console.error(`[SearXNG] Failed to bootstrap config:`, error);
|
|
196
195
|
return false;
|
|
197
196
|
}
|
package/src/core/provider.ts
CHANGED
|
@@ -23,20 +23,7 @@ export interface SearchProvider {
|
|
|
23
23
|
getMissingConfigMessage(): string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
* Interface for providers that manage lifecycle (init, healthcheck, shutdown)
|
|
28
|
-
*/
|
|
29
|
-
export interface ILifecycleProvider {
|
|
30
|
-
init(): Promise<void>;
|
|
31
|
-
healthcheck(): Promise<boolean>;
|
|
32
|
-
shutdown(): Promise<void>;
|
|
33
|
-
validateConfig(): Promise<{
|
|
34
|
-
valid: boolean;
|
|
35
|
-
errors: string[];
|
|
36
|
-
warnings: string[];
|
|
37
|
-
}>;
|
|
38
|
-
isLifecycleManaged(): boolean;
|
|
39
|
-
}
|
|
26
|
+
export type { ILifecycleProvider } from "./provider/ILifecycleProvider";
|
|
40
27
|
|
|
41
28
|
/**
|
|
42
29
|
* Registry to manage all available providers
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry Logic for Provider Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides exponential backoff retry functionality for resilient
|
|
5
|
+
* provider operations
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EngineId } from "./types";
|
|
9
|
+
import { SearchError } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Retry configuration
|
|
13
|
+
*/
|
|
14
|
+
export interface RetryConfig {
|
|
15
|
+
/** Maximum number of retry attempts */
|
|
16
|
+
maxAttempts?: number;
|
|
17
|
+
|
|
18
|
+
/** Initial delay between retries in milliseconds */
|
|
19
|
+
initialDelayMs?: number;
|
|
20
|
+
|
|
21
|
+
/** Multiplier for exponential backoff */
|
|
22
|
+
backoffMultiplier?: number;
|
|
23
|
+
|
|
24
|
+
/** Maximum delay between retries in milliseconds */
|
|
25
|
+
maxDelayMs?: number;
|
|
26
|
+
|
|
27
|
+
/** Whether to retry on specific error types */
|
|
28
|
+
retryableErrors?: Array<"network_error" | "api_error" | "rate_limit" | "no_results" | "timeout">;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default retry configuration
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
|
|
35
|
+
maxAttempts: 3,
|
|
36
|
+
initialDelayMs: 1000,
|
|
37
|
+
backoffMultiplier: 2,
|
|
38
|
+
maxDelayMs: 10000,
|
|
39
|
+
retryableErrors: ["network_error", "api_error", "rate_limit", "no_results"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute a function with retry logic and exponential backoff
|
|
44
|
+
*
|
|
45
|
+
* @param engineId - Engine ID for error messages
|
|
46
|
+
* @param fn - Function to execute with retry logic
|
|
47
|
+
* @param config - Retry configuration
|
|
48
|
+
* @returns Result of the function
|
|
49
|
+
* @throws SearchError if all retry attempts fail
|
|
50
|
+
*/
|
|
51
|
+
export async function withRetry<T>(
|
|
52
|
+
engineId: EngineId,
|
|
53
|
+
fn: () => Promise<T>,
|
|
54
|
+
config: RetryConfig = {},
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
// Skip retries in test environment to avoid timeouts
|
|
57
|
+
if (process.env.DISABLE_RETRY === "true") {
|
|
58
|
+
return fn();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
maxAttempts = DEFAULT_RETRY_CONFIG.maxAttempts,
|
|
63
|
+
initialDelayMs = DEFAULT_RETRY_CONFIG.initialDelayMs,
|
|
64
|
+
backoffMultiplier = DEFAULT_RETRY_CONFIG.backoffMultiplier,
|
|
65
|
+
maxDelayMs = DEFAULT_RETRY_CONFIG.maxDelayMs,
|
|
66
|
+
retryableErrors = DEFAULT_RETRY_CONFIG.retryableErrors,
|
|
67
|
+
} = config;
|
|
68
|
+
|
|
69
|
+
let lastError: unknown;
|
|
70
|
+
let delay = initialDelayMs;
|
|
71
|
+
|
|
72
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await fn();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lastError = error;
|
|
77
|
+
|
|
78
|
+
if (!(error instanceof SearchError)) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const shouldRetry = retryableErrors.includes(
|
|
83
|
+
error.reason as "network_error" | "api_error" | "rate_limit" | "no_results",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!shouldRetry || attempt >= maxAttempts) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nextDelay = Math.min(delay * backoffMultiplier, maxDelayMs);
|
|
91
|
+
|
|
92
|
+
console.warn(
|
|
93
|
+
`[${engineId}] Attempt ${attempt}/${maxAttempts} failed: ${error.message}. Retrying in ${nextDelay}ms...`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, nextDelay));
|
|
97
|
+
delay = nextDelay;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw lastError;
|
|
102
|
+
}
|