@x12i/api-hub-search 0.0.0-stage → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,11 @@
1
- # Temporary Holding Version
1
+ # @x12i/api-hub-search
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Web search client for the x12i API hub
4
+
5
+ ESM package for Node.js 24 or newer. Import the public name:
6
+
7
+ ```js
8
+ import * as search from '@x12i/api-hub-search';
9
+ ```
10
+
11
+ The whole set is also available from [`@x12i/api-hub`](https://www.npmjs.com/package/@x12i/api-hub).
@@ -0,0 +1,32 @@
1
+ import { type Logger, type WebSearch, type WebSearchResult } from '@x12i/api-hub-core';
2
+ import { type SearchProvider } from '@x12i/search';
3
+ import { type SearchAdapter } from '@x12i/search-adapter';
4
+ export declare const MAX_RESULTS = 20;
5
+ export declare const SERPER_NEWS_URL = "https://google.serper.dev/news";
6
+ /** A news result; `source` is the publisher name when the provider reports one. */
7
+ export interface NewsSearchResult extends WebSearchResult {
8
+ source?: string;
9
+ }
10
+ /** One POST to Serper's news endpoint. Injectable so tests never reach the network. */
11
+ export type SerperNewsTransport = (body: Record<string, unknown>, signal: AbortSignal) => Promise<{
12
+ status: number;
13
+ data: unknown;
14
+ }>;
15
+ export declare function mapSerperNews(data: unknown, limit: number): NewsSearchResult[];
16
+ /** Serper (Google results): web through `@x12i/search`, news through Serper's news endpoint (which `@x12i/search` does not cover). */
17
+ export declare function serperSearchClient(options: {
18
+ apiKey?: string;
19
+ provider?: SearchProvider;
20
+ newsTransport?: SerperNewsTransport;
21
+ log?: Logger;
22
+ timeoutMs?: number;
23
+ }): WebSearch;
24
+ /** Tavily through `@x12i/search-adapter`. Its page-fetch stage stays off: caller URLs only go through `@x12i/api-hub-safe-fetch`. */
25
+ export declare function tavilySearchClient(options: {
26
+ apiKey?: string;
27
+ adapter?: SearchAdapter;
28
+ log?: Logger;
29
+ timeoutMs?: number;
30
+ }): WebSearch;
31
+ /** Serper when `SERPER_API_KEY` is set, else Tavily when `TAVILY_API_KEY` is set, else the stub (`service_not_connected`). */
32
+ export declare function createSearchFromEnv(env?: NodeJS.ProcessEnv, log?: Logger): WebSearch;
package/dist/index.js ADDED
@@ -0,0 +1,231 @@
1
+ import { HubError, stubSearch, } from '@x12i/api-hub-core';
2
+ import { createSerperProvider, search as serperSearch, SearchError } from '@x12i/search';
3
+ import { createSearchAdapter } from '@x12i/search-adapter';
4
+ import { request } from 'undici';
5
+ export const MAX_RESULTS = 20;
6
+ const DAYS = { day: 1, week: 7, month: 31, year: 365 };
7
+ export const SERPER_NEWS_URL = 'https://google.serper.dev/news';
8
+ const SERPER_NEWS_MAX_BYTES = 2 * 1024 * 1024;
9
+ function serperNewsTransport(apiKey, timeoutMs) {
10
+ return async (body, signal) => {
11
+ const res = await request(SERPER_NEWS_URL, {
12
+ method: 'POST',
13
+ headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
14
+ body: JSON.stringify(body),
15
+ signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),
16
+ });
17
+ const length = Number(res.headers['content-length'] ?? 0);
18
+ if (length > SERPER_NEWS_MAX_BYTES) {
19
+ await res.body.dump();
20
+ return { status: 502, data: null };
21
+ }
22
+ const text = await res.body.text();
23
+ try {
24
+ return { status: res.statusCode, data: text.length <= SERPER_NEWS_MAX_BYTES ? JSON.parse(text) : null };
25
+ }
26
+ catch {
27
+ return { status: res.statusCode, data: null };
28
+ }
29
+ };
30
+ }
31
+ const nonEmpty = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
32
+ export function mapSerperNews(data, limit) {
33
+ const items = data?.news;
34
+ if (!Array.isArray(items))
35
+ throw new HubError('upstream_unavailable', 'The search provider returned an unusable response', { provider: 'serper' });
36
+ const results = [];
37
+ for (const raw of items) {
38
+ const title = nonEmpty(raw?.title);
39
+ const link = nonEmpty(raw?.link);
40
+ if (!title || !link || !/^https?:\/\//i.test(link))
41
+ continue;
42
+ const snippet = nonEmpty(raw.snippet);
43
+ const date = nonEmpty(raw.date);
44
+ const source = nonEmpty(raw.source);
45
+ results.push({
46
+ position: typeof raw.position === 'number' && raw.position > 0 ? raw.position : results.length + 1,
47
+ title,
48
+ url: link,
49
+ domain: domainOf(link),
50
+ ...(snippet ? { snippet } : {}),
51
+ ...(date ? { date } : {}),
52
+ ...(source ? { source } : {}),
53
+ });
54
+ if (results.length === limit)
55
+ break;
56
+ }
57
+ return results;
58
+ }
59
+ async function serperNews(req, transport, log) {
60
+ const limit = clampLimit(req.limit);
61
+ const body = { q: req.query, num: limit };
62
+ if (req.country)
63
+ body.gl = req.country;
64
+ if (req.language)
65
+ body.hl = req.language;
66
+ if (req.timeRange)
67
+ body.tbs = `qdr:${req.timeRange[0]}`;
68
+ let res;
69
+ try {
70
+ res = await transport(body, req.signal ?? new AbortController().signal);
71
+ }
72
+ catch (err) {
73
+ if (req.signal?.aborted)
74
+ throw new HubError('timeout', 'The search was cancelled');
75
+ const name = err?.name;
76
+ if (name === 'TimeoutError' || name === 'AbortError')
77
+ throw new HubError('timeout', 'The search provider did not answer in time');
78
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper' });
79
+ }
80
+ if (res.status === 401 || res.status === 403) {
81
+ log?.warn('search provider rejected the configured key', { provider: 'serper' });
82
+ throw new HubError('upstream_unavailable', 'The search provider rejected the configured key');
83
+ }
84
+ if (res.status >= 400)
85
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper', status: res.status });
86
+ return { provider: 'serper', query: req.query, results: mapSerperNews(res.data, limit) };
87
+ }
88
+ function domainOf(url) {
89
+ try {
90
+ return new URL(url).hostname.replace(/^www\./, '');
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
96
+ function clampLimit(limit) {
97
+ return Math.min(Math.max(Math.trunc(limit ?? 10), 1), MAX_RESULTS);
98
+ }
99
+ function serperError(err, log) {
100
+ if (err instanceof HubError)
101
+ throw err;
102
+ if (err instanceof SearchError) {
103
+ switch (err.code) {
104
+ case 'INVALID_SEARCH_REQUEST':
105
+ throw new HubError('invalid_input', err.message);
106
+ case 'SEARCH_PROVIDER_TIMEOUT':
107
+ throw new HubError('timeout', 'The search provider did not answer in time');
108
+ case 'SEARCH_PROVIDER_AUTH_ERROR':
109
+ log?.warn('search provider rejected the configured key', { provider: 'serper' });
110
+ throw new HubError('upstream_unavailable', 'The search provider rejected the configured key');
111
+ case 'INTERNAL_ERROR':
112
+ throw new HubError('internal', 'Search failed');
113
+ default:
114
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper', code: err.code });
115
+ }
116
+ }
117
+ if (err?.name === 'AbortError')
118
+ throw new HubError('timeout', 'The search was cancelled');
119
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper' });
120
+ }
121
+ /** Serper (Google results): web through `@x12i/search`, news through Serper's news endpoint (which `@x12i/search` does not cover). */
122
+ export function serperSearchClient(options) {
123
+ const newsTransport = options.newsTransport ?? serperNewsTransport(options.apiKey ?? '', options.timeoutMs ?? 15_000);
124
+ const provider = options.provider ??
125
+ createSerperProvider({
126
+ apiKey: options.apiKey ?? '',
127
+ baseUrl: 'https://google.serper.dev',
128
+ timeoutMs: options.timeoutMs ?? 15_000,
129
+ maxRetries: 1,
130
+ defaultNum: 10,
131
+ maxNum: MAX_RESULTS,
132
+ includeRawByDefault: false,
133
+ enforceSafeSearch: true,
134
+ });
135
+ return {
136
+ async search(req) {
137
+ if (req.kind === 'news')
138
+ return serperNews(req, newsTransport, options.log);
139
+ let res;
140
+ try {
141
+ res = await serperSearch({
142
+ query: req.query,
143
+ limit: clampLimit(req.limit),
144
+ country: req.country,
145
+ language: req.language,
146
+ timeRange: req.timeRange,
147
+ safeSearch: 'active',
148
+ includeRawProviderResponse: false,
149
+ }, { provider: provider ?? null, signal: req.signal });
150
+ }
151
+ catch (err) {
152
+ serperError(err, options.log);
153
+ }
154
+ return {
155
+ provider: 'serper',
156
+ query: req.query,
157
+ results: res.organic.map((r) => ({
158
+ position: r.position,
159
+ title: r.title,
160
+ url: r.url,
161
+ domain: r.domain || domainOf(r.url),
162
+ ...(r.snippet ? { snippet: r.snippet } : {}),
163
+ ...(r.dateText ? { date: r.dateText } : {}),
164
+ })),
165
+ ...(res.answerBox?.answer ? { answer: res.answerBox.answer } : {}),
166
+ };
167
+ },
168
+ };
169
+ }
170
+ function tavilyError(result, log) {
171
+ const code = result.error?.code;
172
+ if (code === 'INVALID_REQUEST')
173
+ throw new HubError('invalid_input', result.error?.message ?? 'Invalid search request');
174
+ if (code === 'PROVIDER_TIMEOUT')
175
+ throw new HubError('timeout', 'The search provider did not answer in time');
176
+ if (code === 'MISSING_API_KEY') {
177
+ log?.warn('tavily key missing at call time', { provider: 'tavily' });
178
+ throw new HubError('service_not_connected', 'Web search provider is not connected on this server', { service: 'search', docs: 'docs/ENV.md#search' });
179
+ }
180
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'tavily', code });
181
+ }
182
+ /** Tavily through `@x12i/search-adapter`. Its page-fetch stage stays off: caller URLs only go through `@x12i/api-hub-safe-fetch`. */
183
+ export function tavilySearchClient(options) {
184
+ const adapter = options.adapter ??
185
+ createSearchAdapter({
186
+ includeSourceSnippets: true,
187
+ tavily: { apiKey: options.apiKey, timeoutMs: options.timeoutMs ?? 15_000, maxRetries: 1, includeAnswer: false },
188
+ fetch: { enabled: false },
189
+ });
190
+ return {
191
+ async search(req) {
192
+ let result;
193
+ try {
194
+ result = await adapter.search({
195
+ query: req.query,
196
+ topic: req.kind === 'news' ? 'news' : 'general',
197
+ maxResults: clampLimit(req.limit),
198
+ includeAnswer: false,
199
+ fetchPages: false,
200
+ ...(req.timeRange ? { days: DAYS[req.timeRange] } : {}),
201
+ });
202
+ }
203
+ catch {
204
+ throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'tavily' });
205
+ }
206
+ if (!result.ok)
207
+ tavilyError(result, options.log);
208
+ return {
209
+ provider: 'tavily',
210
+ query: req.query,
211
+ results: result.discoveredSources.map((s, i) => ({
212
+ position: s.rank ?? i + 1,
213
+ title: s.title ?? s.url,
214
+ url: s.url,
215
+ domain: s.domain ?? domainOf(s.url),
216
+ ...(s.snippet ? { snippet: s.snippet } : {}),
217
+ ...(s.publishedAt ? { date: s.publishedAt } : {}),
218
+ })),
219
+ };
220
+ },
221
+ };
222
+ }
223
+ /** Serper when `SERPER_API_KEY` is set, else Tavily when `TAVILY_API_KEY` is set, else the stub (`service_not_connected`). */
224
+ export function createSearchFromEnv(env = process.env, log) {
225
+ if (env.SERPER_API_KEY?.trim())
226
+ return serperSearchClient({ apiKey: env.SERPER_API_KEY.trim(), log });
227
+ if (env.TAVILY_API_KEY?.trim())
228
+ return tavilySearchClient({ apiKey: env.TAVILY_API_KEY.trim(), log });
229
+ return stubSearch;
230
+ }
231
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/search/src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n HubError,\n stubSearch,\n type Logger,\n type WebSearch,\n type WebSearchRequest,\n type WebSearchResponse,\n type WebSearchResult,\n} from '@x12i/api-hub-core';\nimport { createSerperProvider, search as serperSearch, SearchError, type SearchProvider } from '@x12i/search';\nimport { createSearchAdapter, type SearchAdapter, type SearchResult as TavilyResult } from '@x12i/search-adapter';\nimport { request } from 'undici';\n\nexport const MAX_RESULTS = 20;\n\nconst DAYS = { day: 1, week: 7, month: 31, year: 365 } as const;\n\nexport const SERPER_NEWS_URL = 'https://google.serper.dev/news';\nconst SERPER_NEWS_MAX_BYTES = 2 * 1024 * 1024;\n\n/** A news result; `source` is the publisher name when the provider reports one. */\nexport interface NewsSearchResult extends WebSearchResult {\n source?: string;\n}\n\n/** One POST to Serper's news endpoint. Injectable so tests never reach the network. */\nexport type SerperNewsTransport = (body: Record<string, unknown>, signal: AbortSignal) => Promise<{ status: number; data: unknown }>;\n\nfunction serperNewsTransport(apiKey: string, timeoutMs: number): SerperNewsTransport {\n return async (body, signal) => {\n const res = await request(SERPER_NEWS_URL, {\n method: 'POST',\n headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },\n body: JSON.stringify(body),\n signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),\n });\n const length = Number(res.headers['content-length'] ?? 0);\n if (length > SERPER_NEWS_MAX_BYTES) {\n await res.body.dump();\n return { status: 502, data: null };\n }\n const text = await res.body.text();\n try {\n return { status: res.statusCode, data: text.length <= SERPER_NEWS_MAX_BYTES ? (JSON.parse(text) as unknown) : null };\n } catch {\n return { status: res.statusCode, data: null };\n }\n };\n}\n\ninterface SerperNewsItem {\n title?: unknown;\n link?: unknown;\n snippet?: unknown;\n date?: unknown;\n source?: unknown;\n position?: unknown;\n}\n\nconst nonEmpty = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);\n\nexport function mapSerperNews(data: unknown, limit: number): NewsSearchResult[] {\n const items = (data as { news?: unknown } | null)?.news;\n if (!Array.isArray(items)) throw new HubError('upstream_unavailable', 'The search provider returned an unusable response', { provider: 'serper' });\n const results: NewsSearchResult[] = [];\n for (const raw of items as SerperNewsItem[]) {\n const title = nonEmpty(raw?.title);\n const link = nonEmpty(raw?.link);\n if (!title || !link || !/^https?:\\/\\//i.test(link)) continue;\n const snippet = nonEmpty(raw.snippet);\n const date = nonEmpty(raw.date);\n const source = nonEmpty(raw.source);\n results.push({\n position: typeof raw.position === 'number' && raw.position > 0 ? raw.position : results.length + 1,\n title,\n url: link,\n domain: domainOf(link),\n ...(snippet ? { snippet } : {}),\n ...(date ? { date } : {}),\n ...(source ? { source } : {}),\n });\n if (results.length === limit) break;\n }\n return results;\n}\n\nasync function serperNews(req: WebSearchRequest, transport: SerperNewsTransport, log?: Logger): Promise<WebSearchResponse> {\n const limit = clampLimit(req.limit);\n const body: Record<string, unknown> = { q: req.query, num: limit };\n if (req.country) body.gl = req.country;\n if (req.language) body.hl = req.language;\n if (req.timeRange) body.tbs = `qdr:${req.timeRange[0]}`;\n let res;\n try {\n res = await transport(body, req.signal ?? new AbortController().signal);\n } catch (err) {\n if (req.signal?.aborted) throw new HubError('timeout', 'The search was cancelled');\n const name = (err as Error)?.name;\n if (name === 'TimeoutError' || name === 'AbortError') throw new HubError('timeout', 'The search provider did not answer in time');\n throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper' });\n }\n if (res.status === 401 || res.status === 403) {\n log?.warn('search provider rejected the configured key', { provider: 'serper' });\n throw new HubError('upstream_unavailable', 'The search provider rejected the configured key');\n }\n if (res.status >= 400) throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper', status: res.status });\n return { provider: 'serper', query: req.query, results: mapSerperNews(res.data, limit) };\n}\n\nfunction domainOf(url: string): string {\n try {\n return new URL(url).hostname.replace(/^www\\./, '');\n } catch {\n return '';\n }\n}\n\nfunction clampLimit(limit: number | undefined): number {\n return Math.min(Math.max(Math.trunc(limit ?? 10), 1), MAX_RESULTS);\n}\n\nfunction serperError(err: unknown, log?: Logger): never {\n if (err instanceof HubError) throw err;\n if (err instanceof SearchError) {\n switch (err.code) {\n case 'INVALID_SEARCH_REQUEST':\n throw new HubError('invalid_input', err.message);\n case 'SEARCH_PROVIDER_TIMEOUT':\n throw new HubError('timeout', 'The search provider did not answer in time');\n case 'SEARCH_PROVIDER_AUTH_ERROR':\n log?.warn('search provider rejected the configured key', { provider: 'serper' });\n throw new HubError('upstream_unavailable', 'The search provider rejected the configured key');\n case 'INTERNAL_ERROR':\n throw new HubError('internal', 'Search failed');\n default:\n throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper', code: err.code });\n }\n }\n if ((err as Error)?.name === 'AbortError') throw new HubError('timeout', 'The search was cancelled');\n throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'serper' });\n}\n\n/** Serper (Google results): web through `@x12i/search`, news through Serper's news endpoint (which `@x12i/search` does not cover). */\nexport function serperSearchClient(options: {\n apiKey?: string;\n provider?: SearchProvider;\n newsTransport?: SerperNewsTransport;\n log?: Logger;\n timeoutMs?: number;\n}): WebSearch {\n const newsTransport = options.newsTransport ?? serperNewsTransport(options.apiKey ?? '', options.timeoutMs ?? 15_000);\n const provider =\n options.provider ??\n createSerperProvider({\n apiKey: options.apiKey ?? '',\n baseUrl: 'https://google.serper.dev',\n timeoutMs: options.timeoutMs ?? 15_000,\n maxRetries: 1,\n defaultNum: 10,\n maxNum: MAX_RESULTS,\n includeRawByDefault: false,\n enforceSafeSearch: true,\n });\n return {\n async search(req: WebSearchRequest): Promise<WebSearchResponse> {\n if (req.kind === 'news') return serperNews(req, newsTransport, options.log);\n let res;\n try {\n res = await serperSearch(\n {\n query: req.query,\n limit: clampLimit(req.limit),\n country: req.country,\n language: req.language,\n timeRange: req.timeRange,\n safeSearch: 'active',\n includeRawProviderResponse: false,\n },\n { provider: provider ?? null, signal: req.signal },\n );\n } catch (err) {\n serperError(err, options.log);\n }\n return {\n provider: 'serper',\n query: req.query,\n results: res.organic.map((r) => ({\n position: r.position,\n title: r.title,\n url: r.url,\n domain: r.domain || domainOf(r.url),\n ...(r.snippet ? { snippet: r.snippet } : {}),\n ...(r.dateText ? { date: r.dateText } : {}),\n })),\n ...(res.answerBox?.answer ? { answer: res.answerBox.answer } : {}),\n };\n },\n };\n}\n\nfunction tavilyError(result: TavilyResult, log?: Logger): never {\n const code = result.error?.code;\n if (code === 'INVALID_REQUEST') throw new HubError('invalid_input', result.error?.message ?? 'Invalid search request');\n if (code === 'PROVIDER_TIMEOUT') throw new HubError('timeout', 'The search provider did not answer in time');\n if (code === 'MISSING_API_KEY') {\n log?.warn('tavily key missing at call time', { provider: 'tavily' });\n throw new HubError('service_not_connected', 'Web search provider is not connected on this server', { service: 'search', docs: 'docs/ENV.md#search' });\n }\n throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'tavily', code });\n}\n\n/** Tavily through `@x12i/search-adapter`. Its page-fetch stage stays off: caller URLs only go through `@x12i/api-hub-safe-fetch`. */\nexport function tavilySearchClient(options: { apiKey?: string; adapter?: SearchAdapter; log?: Logger; timeoutMs?: number }): WebSearch {\n const adapter =\n options.adapter ??\n createSearchAdapter({\n includeSourceSnippets: true,\n tavily: { apiKey: options.apiKey, timeoutMs: options.timeoutMs ?? 15_000, maxRetries: 1, includeAnswer: false },\n fetch: { enabled: false },\n });\n return {\n async search(req: WebSearchRequest): Promise<WebSearchResponse> {\n let result: TavilyResult;\n try {\n result = await adapter.search({\n query: req.query,\n topic: req.kind === 'news' ? 'news' : 'general',\n maxResults: clampLimit(req.limit),\n includeAnswer: false,\n fetchPages: false,\n ...(req.timeRange ? { days: DAYS[req.timeRange] } : {}),\n });\n } catch {\n throw new HubError('upstream_unavailable', 'The search provider is unavailable', { provider: 'tavily' });\n }\n if (!result.ok) tavilyError(result, options.log);\n return {\n provider: 'tavily',\n query: req.query,\n results: result.discoveredSources.map((s, i) => ({\n position: s.rank ?? i + 1,\n title: s.title ?? s.url,\n url: s.url,\n domain: s.domain ?? domainOf(s.url),\n ...(s.snippet ? { snippet: s.snippet } : {}),\n ...(s.publishedAt ? { date: s.publishedAt } : {}),\n })),\n };\n },\n };\n}\n\n/** Serper when `SERPER_API_KEY` is set, else Tavily when `TAVILY_API_KEY` is set, else the stub (`service_not_connected`). */\nexport function createSearchFromEnv(env: NodeJS.ProcessEnv = process.env, log?: Logger): WebSearch {\n if (env.SERPER_API_KEY?.trim()) return serperSearchClient({ apiKey: env.SERPER_API_KEY.trim(), log });\n if (env.TAVILY_API_KEY?.trim()) return tavilySearchClient({ apiKey: env.TAVILY_API_KEY.trim(), log });\n return stubSearch;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@x12i/api-hub-search",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Web search client for the x12i API hub",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/x12i/apis-hub.git",
24
+ "directory": "packages/search"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/x12i/apis-hub/issues"
28
+ },
29
+ "homepage": "https://github.com/x12i/apis-hub",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@x12i/api-hub-core": "0.1.0",
35
+ "@x12i/search": "0.4.0",
36
+ "@x12i/search-adapter": "1.5.1",
37
+ "undici": "8.12.3"
38
+ }
39
+ }