ubersearch 1.6.2 → 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.
@@ -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 interface UberSearchOptions {
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,9 +5,9 @@
5
5
  * https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
6
6
  */
7
7
 
8
- import { copyFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { dirname, join } from "node:path";
10
+ import { dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  const APP_NAME = "ubersearch";
@@ -108,17 +108,58 @@ export function getPackageRoot(): string {
108
108
  return dirname(dirname(currentDir));
109
109
  }
110
110
 
111
+ /**
112
+ * Get candidate runtime roots where bundled SearXNG assets may live.
113
+ *
114
+ * This covers:
115
+ * - source checkouts (`<root>/providers/searxng`)
116
+ * - built JS output (`<root>/dist/providers/searxng`)
117
+ * - compiled binaries with copied assets next to the executable
118
+ */
119
+ function getRuntimeRoots(): string[] {
120
+ const roots = new Set<string>();
121
+
122
+ roots.add(resolve(getPackageRoot()));
123
+ roots.add(resolve(dirname(process.execPath)));
124
+ roots.add(resolve(process.cwd()));
125
+
126
+ return [...roots];
127
+ }
128
+
129
+ function getBundledSearxngAssetCandidates(relativePath: string): string[] {
130
+ const candidates = new Set<string>();
131
+
132
+ for (const root of getRuntimeRoots()) {
133
+ candidates.add(join(root, "providers", "searxng", relativePath));
134
+ candidates.add(join(root, "dist", "providers", "searxng", relativePath));
135
+ }
136
+
137
+ return [...candidates];
138
+ }
139
+
140
+ function resolveBundledSearxngAsset(relativePath: string): string {
141
+ const candidates = getBundledSearxngAssetCandidates(relativePath);
142
+ const existing = candidates.find((candidate) => existsSync(candidate));
143
+
144
+ if (existing) {
145
+ return existing;
146
+ }
147
+
148
+ return candidates[0] ?? join(getPackageRoot(), "providers", "searxng", relativePath);
149
+ }
150
+
151
+ /**
152
+ * Get the bundled SearXNG docker-compose file path.
153
+ */
154
+ export function getBundledSearxngComposePath(): string {
155
+ return resolveBundledSearxngAsset("docker-compose.yml");
156
+ }
157
+
111
158
  /**
112
159
  * Get the bundled default settings.yml path
113
160
  */
114
161
  export function getDefaultSettingsPath(): string {
115
- const packageRoot = getPackageRoot();
116
- // In bundled mode, look in dist/providers/searxng/
117
- // In dev mode, look in providers/searxng/
118
- if (packageRoot.endsWith("/dist")) {
119
- return join(packageRoot, "providers", "searxng", "config", "settings.yml");
120
- }
121
- return join(packageRoot, "providers", "searxng", "config", "settings.yml");
162
+ return resolveBundledSearxngAsset(join("config", "settings.yml"));
122
163
  }
123
164
 
124
165
  /**
@@ -129,11 +170,6 @@ export function bootstrapSearxngConfig(): boolean {
129
170
  const { configDir } = getSearxngPaths();
130
171
  const targetSettings = join(configDir, "settings.yml");
131
172
 
132
- // If settings already exist, don't overwrite
133
- if (existsSync(targetSettings)) {
134
- return false;
135
- }
136
-
137
173
  const defaultSettings = getDefaultSettingsPath();
138
174
 
139
175
  // If default settings don't exist, we can't bootstrap
@@ -145,12 +181,16 @@ export function bootstrapSearxngConfig(): boolean {
145
181
  return false;
146
182
  }
147
183
 
148
- // Copy default settings to XDG config dir
184
+ // Use exclusive flag to prevent TOCTOU race and avoid overwriting
149
185
  try {
150
- copyFileSync(defaultSettings, targetSettings);
186
+ const content = readFileSync(defaultSettings);
187
+ writeFileSync(targetSettings, content, { flag: "wx" });
151
188
  console.log(`[SearXNG] Bootstrapped default config to ${targetSettings}`);
152
189
  return true;
153
190
  } catch (error) {
191
+ if ((error as NodeJS.ErrnoException)?.code === "EEXIST") {
192
+ return false; // File already exists, no action needed
193
+ }
154
194
  console.error(`[SearXNG] Failed to bootstrap config:`, error);
155
195
  return false;
156
196
  }
@@ -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
+ }
@@ -7,9 +7,10 @@
7
7
  * the overall search.
8
8
  */
9
9
 
10
- import { withRetry } from "../../providers/retry";
10
+ import { getErrorMessage } from "../errorUtils";
11
11
  import { createLogger } from "../logger";
12
12
  import type { SearchProvider } from "../provider";
13
+ import { withRetry } from "../retry";
13
14
  import type { EngineId, SearchResponse, SearchResultItem } from "../types";
14
15
  import { SearchError } from "../types";
15
16
  import type {
@@ -28,7 +29,7 @@ const log = createLogger("AllProviders");
28
29
  interface EngineSearchResult {
29
30
  engineId: EngineId;
30
31
  response?: SearchResponse;
31
- error?: Error;
32
+ error?: unknown;
32
33
  }
33
34
 
34
35
  /**
@@ -81,8 +82,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
81
82
  continue;
82
83
  }
83
84
 
84
- // Check credit availability
85
- if (!context.creditManager.hasSufficientCredits(engineId)) {
85
+ // Charge credits before search (atomic check+deduct to avoid race conditions)
86
+ if (!context.creditManager.charge(engineId)) {
86
87
  attempts.push({ engineId, success: false, reason: "out_of_credit" });
87
88
  continue;
88
89
  }
@@ -98,23 +99,14 @@ export class AllProvidersStrategy implements ISearchStrategy {
98
99
  }),
99
100
  );
100
101
 
101
- // Deduct credits
102
- if (!context.creditManager.charge(engineId)) {
103
- // This shouldn't happen because we checked above, but handle gracefully
104
- attempts.push({ engineId, success: false, reason: "out_of_credit" });
105
- continue;
106
- }
107
-
108
102
  // Record success
109
103
  attempts.push({ engineId, success: true });
110
104
 
111
- // Add results, applying limit if specified
112
- if (options.limit !== undefined) {
113
- results.push(...response.items.slice(0, options.limit - results.length));
114
- } else {
115
- results.push(...response.items);
116
- }
105
+ results.push(...response.items);
117
106
  } catch (error) {
107
+ // Refund credits for failed search
108
+ context.creditManager.refund(engineId);
109
+
118
110
  // Record failure
119
111
  if (error instanceof SearchError) {
120
112
  attempts.push({ engineId, success: false, reason: error.reason });
@@ -123,18 +115,11 @@ export class AllProvidersStrategy implements ISearchStrategy {
123
115
  }
124
116
 
125
117
  // Log debug message but continue with other providers
126
- log.debug(
127
- `Search failed for ${engineId}: ${error instanceof Error ? error.message : String(error)}`,
128
- );
118
+ log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
129
119
  }
130
120
  }
131
121
 
132
- // Apply final limit if specified
133
- if (options.limit !== undefined && results.length > options.limit) {
134
- results.splice(options.limit);
135
- }
136
-
137
- return { results, attempts };
122
+ return { results: this.finalizeResults(results, options), attempts };
138
123
  }
139
124
 
140
125
  /**
@@ -163,7 +148,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
163
148
  continue;
164
149
  }
165
150
 
166
- if (!context.creditManager.hasSufficientCredits(engineId)) {
151
+ // Charge credits before search (atomic check+deduct to avoid race conditions)
152
+ if (!context.creditManager.charge(engineId)) {
167
153
  ineligibleAttempts.push({ engineId, success: false, reason: "out_of_credit" });
168
154
  continue;
169
155
  }
@@ -185,7 +171,7 @@ export class AllProvidersStrategy implements ISearchStrategy {
185
171
  );
186
172
  return { engineId, response };
187
173
  } catch (error) {
188
- return { engineId, error: error instanceof Error ? error : new Error(String(error)) };
174
+ return { engineId, error };
189
175
  }
190
176
  },
191
177
  );
@@ -207,31 +193,35 @@ export class AllProvidersStrategy implements ISearchStrategy {
207
193
 
208
194
  if (settledResult.status === "rejected") {
209
195
  // Promise itself was rejected (shouldn't happen with our try/catch, but handle it)
210
- attempts.push({ engineId, success: false, reason: "unknown" });
196
+ context.creditManager.refund(engineId);
197
+ const error = settledResult.reason;
198
+ if (error instanceof SearchError) {
199
+ attempts.push({ engineId, success: false, reason: error.reason });
200
+ } else {
201
+ attempts.push({ engineId, success: false, reason: "unknown" });
202
+ }
211
203
  log.debug(`Search failed for ${engineId}: Promise rejected`);
212
204
  continue;
213
205
  }
214
206
 
215
- const { response, error } = settledResult.value;
207
+ if ("error" in settledResult.value) {
208
+ // Refund credits for failed search
209
+ context.creditManager.refund(engineId);
216
210
 
217
- if (error) {
218
211
  // Search threw an error
212
+ const error = settledResult.value.error;
219
213
  if (error instanceof SearchError) {
220
214
  attempts.push({ engineId, success: false, reason: error.reason });
221
215
  } else {
222
216
  attempts.push({ engineId, success: false, reason: "unknown" });
223
217
  }
224
- log.debug(`Search failed for ${engineId}: ${error.message}`);
218
+ log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
225
219
  continue;
226
220
  }
227
221
 
228
- if (response) {
229
- // Deduct credits
230
- if (!context.creditManager.charge(engineId)) {
231
- attempts.push({ engineId, success: false, reason: "out_of_credit" });
232
- continue;
233
- }
222
+ const { response } = settledResult.value;
234
223
 
224
+ if (response) {
235
225
  // Record success
236
226
  attempts.push({ engineId, success: true });
237
227
 
@@ -249,11 +239,31 @@ export class AllProvidersStrategy implements ISearchStrategy {
249
239
  (a, b) => (engineOrder.get(a.engineId) ?? 0) - (engineOrder.get(b.engineId) ?? 0),
250
240
  );
251
241
 
252
- // Apply final limit if specified
253
- if (options.limit !== undefined && results.length > options.limit) {
254
- results.splice(options.limit);
242
+ return { results: this.finalizeResults(results, options), attempts };
243
+ }
244
+
245
+ /**
246
+ * Deduplicate results by URL, sort by score, and apply limit.
247
+ */
248
+ private finalizeResults(
249
+ results: SearchResultItem[],
250
+ options: UberSearchOptions,
251
+ ): SearchResultItem[] {
252
+ const seen = new Set<string>();
253
+ const deduped: SearchResultItem[] = [];
254
+ for (const item of results) {
255
+ if (!seen.has(item.url)) {
256
+ seen.add(item.url);
257
+ deduped.push(item);
258
+ }
259
+ }
260
+
261
+ deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
262
+
263
+ if (options.limit !== undefined && deduped.length > options.limit) {
264
+ deduped.splice(options.limit);
255
265
  }
256
266
 
257
- return { results, attempts };
267
+ return deduped;
258
268
  }
259
269
  }
@@ -6,8 +6,9 @@
6
6
  * with insufficient credits and stops execution after the first success.
7
7
  */
8
8
 
9
- import { withRetry } from "../../providers/retry";
9
+ import { getErrorMessage } from "../errorUtils";
10
10
  import { createLogger } from "../logger";
11
+ import { withRetry } from "../retry";
11
12
  import type { EngineId } from "../types";
12
13
  import { SearchError } from "../types";
13
14
  import type {
@@ -53,8 +54,8 @@ export class FirstSuccessStrategy implements ISearchStrategy {
53
54
  continue;
54
55
  }
55
56
 
56
- // Check credit availability
57
- if (!context.creditManager.hasSufficientCredits(engineId)) {
57
+ // Charge credits before search (atomic check+deduct to avoid race conditions)
58
+ if (!context.creditManager.charge(engineId)) {
58
59
  attempts.push({ engineId, success: false, reason: "out_of_credit" });
59
60
  continue;
60
61
  }
@@ -70,18 +71,15 @@ export class FirstSuccessStrategy implements ISearchStrategy {
70
71
  }),
71
72
  );
72
73
 
73
- // Deduct credits
74
- if (!context.creditManager.charge(engineId)) {
75
- attempts.push({ engineId, success: false, reason: "out_of_credit" });
76
- continue;
77
- }
78
-
79
74
  // Record success
80
75
  attempts.push({ engineId, success: true });
81
76
 
82
77
  // Return immediately with results from this provider
83
78
  return { results: response.items, attempts };
84
79
  } catch (error) {
80
+ // Refund credits for failed search
81
+ context.creditManager.refund(engineId);
82
+
85
83
  // Record failure and continue to next provider
86
84
  if (error instanceof SearchError) {
87
85
  attempts.push({ engineId, success: false, reason: error.reason });
@@ -90,9 +88,7 @@ export class FirstSuccessStrategy implements ISearchStrategy {
90
88
  }
91
89
 
92
90
  // Log debug message and continue
93
- log.debug(
94
- `Search failed for ${engineId}: ${error instanceof Error ? error.message : String(error)}`,
95
- );
91
+ log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
96
92
  }
97
93
  }
98
94
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { CreditManager } from "../credits";
9
9
  import type { ProviderRegistry } from "../provider";
10
- import type { EngineId, SearchResultItem } from "../types";
10
+ import type { EngineId, SearchFailureReason, SearchResultItem } from "../types";
11
11
 
12
12
  /**
13
13
  * Context object providing dependencies to search strategies
@@ -58,7 +58,7 @@ export interface EngineAttempt {
58
58
  success: boolean;
59
59
 
60
60
  /** Reason for failure (if success=false) */
61
- reason?: string;
61
+ reason?: SearchFailureReason;
62
62
  }
63
63
 
64
64
  /**
package/src/core/types.ts CHANGED
@@ -33,6 +33,7 @@ export type SearchFailureReason =
33
33
  | "rate_limit"
34
34
  | "no_results"
35
35
  | "low_credit"
36
+ | "out_of_credit"
36
37
  | "config_error"
37
38
  | "no_provider"
38
39
  | "provider_unavailable"