@yunfie/search-js 1.0.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.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +1 -0
  3. package/dist/cache/memory.d.ts +18 -0
  4. package/dist/cache/memory.d.ts.map +1 -0
  5. package/dist/cache/memory.js +79 -0
  6. package/dist/cache/memory.js.map +1 -0
  7. package/dist/cache/persistent.d.ts +5 -0
  8. package/dist/cache/persistent.d.ts.map +1 -0
  9. package/dist/cache/persistent.js +105 -0
  10. package/dist/cache/persistent.js.map +1 -0
  11. package/dist/config.d.ts +24 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +29 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/events.d.ts +18 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +32 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/history.d.ts +16 -0
  20. package/dist/history.d.ts.map +1 -0
  21. package/dist/history.js +61 -0
  22. package/dist/history.js.map +1 -0
  23. package/dist/index.d.ts +61 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +231 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/memory.d.ts +6 -0
  28. package/dist/memory.d.ts.map +1 -0
  29. package/dist/memory.js +79 -0
  30. package/dist/memory.js.map +1 -0
  31. package/dist/offline.d.ts +7 -0
  32. package/dist/offline.d.ts.map +1 -0
  33. package/dist/offline.js +39 -0
  34. package/dist/offline.js.map +1 -0
  35. package/dist/parser.d.ts +36 -0
  36. package/dist/parser.d.ts.map +1 -0
  37. package/dist/parser.js +118 -0
  38. package/dist/parser.js.map +1 -0
  39. package/dist/request/queue.d.ts +11 -0
  40. package/dist/request/queue.d.ts.map +1 -0
  41. package/dist/request/queue.js +31 -0
  42. package/dist/request/queue.js.map +1 -0
  43. package/dist/request/retry.d.ts +14 -0
  44. package/dist/request/retry.d.ts.map +1 -0
  45. package/dist/request/retry.js +155 -0
  46. package/dist/request/retry.js.map +1 -0
  47. package/dist/suggest.d.ts +14 -0
  48. package/dist/suggest.d.ts.map +1 -0
  49. package/dist/suggest.js +159 -0
  50. package/dist/suggest.js.map +1 -0
  51. package/dist/utils.d.ts +17 -0
  52. package/dist/utils.d.ts.map +1 -0
  53. package/dist/utils.js +72 -0
  54. package/dist/utils.js.map +1 -0
  55. package/package.json +39 -0
  56. package/src/cache/memory.ts +79 -0
  57. package/src/cache/persistent.ts +106 -0
  58. package/src/config.ts +53 -0
  59. package/src/events.ts +57 -0
  60. package/src/history.ts +70 -0
  61. package/src/index.ts +307 -0
  62. package/src/memory.ts +101 -0
  63. package/src/offline.ts +44 -0
  64. package/src/parser.ts +138 -0
  65. package/src/request/queue.ts +38 -0
  66. package/src/request/retry.ts +162 -0
  67. package/src/suggest.ts +174 -0
  68. package/src/utils.ts +101 -0
@@ -0,0 +1,106 @@
1
+ // src/cache/persistent.ts
2
+ import { getConfig } from "../config.js";
3
+
4
+ let _dbPromise: Promise<IDBDatabase | null> | null = null;
5
+
6
+ function _open(): Promise<IDBDatabase | null> {
7
+ if (typeof globalThis.indexedDB === "undefined") return Promise.resolve(null);
8
+ if (_dbPromise) return _dbPromise;
9
+
10
+ _dbPromise = new Promise<IDBDatabase | null>((resolve) => {
11
+ const req = indexedDB.open("ApiCache", 2);
12
+ req.onerror = () => { console.warn("IndexedDB unavailable"); resolve(null); };
13
+ req.onsuccess = () => resolve(req.result);
14
+ req.onupgradeneeded = (e: IDBVersionChangeEvent) => {
15
+ const db = (e.target as IDBOpenDBRequest).result;
16
+ if (!db.objectStoreNames.contains("cache")) {
17
+ db.createObjectStore("cache").createIndex("time", "time");
18
+ }
19
+ };
20
+ });
21
+
22
+ return _dbPromise;
23
+ }
24
+
25
+ export async function getP(key: string): Promise<unknown> {
26
+ const db = await _open();
27
+ if (!db) return null;
28
+
29
+ return new Promise<unknown>((resolve) => {
30
+ const req = db
31
+ .transaction(["cache"], "readonly")
32
+ .objectStore("cache")
33
+ .get(key);
34
+ req.onsuccess = () => {
35
+ const item = req.result as { time: number; data: unknown } | undefined;
36
+ resolve(
37
+ item && Date.now() - item.time < getConfig().CACHE_TTL ? item.data : null
38
+ );
39
+ };
40
+ req.onerror = () => resolve(null);
41
+ });
42
+ }
43
+
44
+ export async function setP(key: string, data: unknown): Promise<void> {
45
+ const db = await _open();
46
+ if (!db) return;
47
+
48
+ const cfg = getConfig();
49
+ return new Promise<void>((resolve, reject) => {
50
+ const tx = db.transaction(["cache"], "readwrite");
51
+ tx.onerror = () => reject(tx.error);
52
+ tx.onabort = () => reject(tx.error);
53
+
54
+ const objectStore = tx.objectStore("cache");
55
+ const countReq = objectStore.count();
56
+
57
+ countReq.onsuccess = () => {
58
+ if (countReq.result >= cfg.PERSISTENT_CACHE_MAX) {
59
+ const maxDel = Math.ceil(cfg.PERSISTENT_CACHE_MAX * 0.2);
60
+ const cur = objectStore.index("time").openCursor(IDBKeyRange.lowerBound(0));
61
+ let deleted = 0;
62
+ cur.onsuccess = (e: Event) => {
63
+ const c = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
64
+ if (c && deleted < maxDel) {
65
+ c.delete(); deleted++; c.continue();
66
+ } else {
67
+ objectStore.put({ data, time: Date.now() }, key);
68
+ resolve();
69
+ }
70
+ };
71
+ cur.onerror = () => reject(cur.error);
72
+ } else {
73
+ objectStore.put({ data, time: Date.now() }, key);
74
+ tx.oncomplete = () => resolve();
75
+ }
76
+ };
77
+ countReq.onerror = () => reject(countReq.error);
78
+ });
79
+ }
80
+
81
+ export async function cleanup(): Promise<void> {
82
+ const db = await _open();
83
+ if (!db) return;
84
+ const cfg = getConfig();
85
+ const cutoff = Date.now() - cfg.CACHE_TTL;
86
+
87
+ return new Promise<void>((resolve) => {
88
+ const req = db
89
+ .transaction(["cache"], "readwrite")
90
+ .objectStore("cache")
91
+ .index("time")
92
+ .openCursor(IDBKeyRange.upperBound(cutoff));
93
+ req.onsuccess = (e: Event) => {
94
+ const c = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
95
+ if (c) { c.delete(); c.continue(); }
96
+ else resolve();
97
+ };
98
+ req.onerror = () => resolve();
99
+ });
100
+ }
101
+
102
+ export async function destroyDB(): Promise<void> {
103
+ const db = await _open();
104
+ db?.close();
105
+ _dbPromise = null;
106
+ }
package/src/config.ts ADDED
@@ -0,0 +1,53 @@
1
+ // src/config.ts
2
+
3
+ export interface Config {
4
+ API_BASE: string;
5
+ CACHE_TTL: number;
6
+ CACHE_MAX: number;
7
+ CACHE_LOW_MEMORY: number;
8
+ MEMORY_PRESSURE_NORMAL: number;
9
+ MEMORY_PRESSURE_CRITICAL: number;
10
+ STRINGIFY_SIZE_THRESHOLD: number;
11
+ TIMEOUT: number;
12
+ RETRIES: number;
13
+ RETRY_BACKOFF_BASE: number;
14
+ MAX_CONCURRENT_REQUESTS: number;
15
+ MAX_CONCURRENT_LOW_MEMORY: number;
16
+ STREAMING_BUFFER_SIZE: number;
17
+ PERSISTENT_CACHE_MAX: number;
18
+ PERSISTENT_CLEANUP_INTERVAL: number;
19
+ MEMORY_CHECK_INTERVAL: number;
20
+ SUGGEST_TTL: number;
21
+ SUGGEST_DEBOUNCE_MS: number;
22
+ }
23
+
24
+ export const defaults: Config = {
25
+ API_BASE: "https://api.wholphin.net",
26
+ CACHE_TTL: 1000 * 60 * 5,
27
+ CACHE_MAX: 30,
28
+ CACHE_LOW_MEMORY: 10,
29
+ MEMORY_PRESSURE_NORMAL: 0.65,
30
+ MEMORY_PRESSURE_CRITICAL: 0.80,
31
+ STRINGIFY_SIZE_THRESHOLD: 1024 * 10,
32
+ TIMEOUT: 8000,
33
+ RETRIES: 3,
34
+ RETRY_BACKOFF_BASE: 1000,
35
+ MAX_CONCURRENT_REQUESTS: 6,
36
+ MAX_CONCURRENT_LOW_MEMORY: 2,
37
+ STREAMING_BUFFER_SIZE: 1024 * 10,
38
+ PERSISTENT_CACHE_MAX: 500,
39
+ PERSISTENT_CLEANUP_INTERVAL: 1000 * 60 * 30,
40
+ MEMORY_CHECK_INTERVAL: 1000 * 60,
41
+ SUGGEST_TTL: 1000 * 30,
42
+ SUGGEST_DEBOUNCE_MS: 200,
43
+ };
44
+
45
+ let _config: Config = { ...defaults };
46
+
47
+ export function configure(overrides: Partial<Config> = {}): void {
48
+ _config = { ...defaults, ...overrides };
49
+ }
50
+
51
+ export function getConfig(): Config {
52
+ return _config;
53
+ }
package/src/events.ts ADDED
@@ -0,0 +1,57 @@
1
+ // src/events.ts
2
+
3
+ export type SearchEventMap = {
4
+ memoryStateChange: { isLow: boolean; isCritical: boolean };
5
+ online: undefined;
6
+ offline: undefined;
7
+ cacheRefreshed: { key: string };
8
+ };
9
+
10
+ type Handler<T> = T extends undefined ? () => void : (payload: T) => void;
11
+
12
+ type Listeners = {
13
+ [K in keyof SearchEventMap]: Set<Handler<SearchEventMap[K]>>;
14
+ };
15
+
16
+ const _listeners: Listeners = {
17
+ memoryStateChange: new Set(),
18
+ online: new Set(),
19
+ offline: new Set(),
20
+ cacheRefreshed: new Set(),
21
+ };
22
+
23
+ export function on<K extends keyof SearchEventMap>(
24
+ event: K,
25
+ handler: Handler<SearchEventMap[K]>
26
+ ): () => void {
27
+ (_listeners[event] as Set<Handler<SearchEventMap[K]>>).add(handler);
28
+ return () => off(event, handler);
29
+ }
30
+
31
+ export function off<K extends keyof SearchEventMap>(
32
+ event: K,
33
+ handler: Handler<SearchEventMap[K]>
34
+ ): void {
35
+ (_listeners[event] as Set<Handler<SearchEventMap[K]>>).delete(handler);
36
+ }
37
+
38
+ export function emit<K extends keyof SearchEventMap>(
39
+ event: K,
40
+ ...args: SearchEventMap[K] extends undefined ? [] : [SearchEventMap[K]]
41
+ ): void {
42
+ const set = _listeners[event] as Set<Handler<SearchEventMap[K]>>;
43
+ for (const fn of [...set]) {
44
+ try {
45
+ // @ts-expect-error payload is type-safe but TS can't follow the union
46
+ fn(...args);
47
+ } catch (e) {
48
+ console.error("[search-js] event handler error:", e);
49
+ }
50
+ }
51
+ }
52
+
53
+ export function clearAllListeners(): void {
54
+ for (const key of Object.keys(_listeners) as (keyof SearchEventMap)[]) {
55
+ (_listeners[key] as Set<unknown>).clear();
56
+ }
57
+ }
package/src/history.ts ADDED
@@ -0,0 +1,70 @@
1
+ // src/history.ts
2
+
3
+ const STORAGE_KEY = "__search_js_history__";
4
+ const MAX_HISTORY = 20;
5
+
6
+ export interface HistoryEntry {
7
+ q: string;
8
+ type: string;
9
+ time: number;
10
+ }
11
+
12
+ function _isAvailable(): boolean {
13
+ try { return typeof localStorage !== "undefined"; }
14
+ catch { return false; }
15
+ }
16
+
17
+ function _load(): HistoryEntry[] {
18
+ if (!_isAvailable()) return [];
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY);
21
+ return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
22
+ } catch { return []; }
23
+ }
24
+
25
+ function _save(entries: HistoryEntry[]): void {
26
+ if (!_isAvailable()) return;
27
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); }
28
+ catch { /* quota exceeded */ }
29
+ }
30
+
31
+ export function addHistory(q: string, type = "web"): void {
32
+ if (!q.trim()) return;
33
+ const entries = _load().filter(
34
+ (e) => !(e.q.toLowerCase() === q.trim().toLowerCase() && e.type === type)
35
+ );
36
+ entries.unshift({ q: q.trim(), type, time: Date.now() });
37
+ _save(entries.slice(0, MAX_HISTORY));
38
+ }
39
+
40
+ export function getHistory(prefix?: string): HistoryEntry[] {
41
+ const entries = _load();
42
+ if (!prefix) return entries;
43
+ const lower = prefix.trim().toLowerCase();
44
+ return entries.filter((e) => e.q.toLowerCase().startsWith(lower));
45
+ }
46
+
47
+ export function removeHistory(q: string, type = "web"): void {
48
+ _save(
49
+ _load().filter(
50
+ (e) => !(e.q.toLowerCase() === q.trim().toLowerCase() && e.type === type)
51
+ )
52
+ );
53
+ }
54
+
55
+ export function clearHistory(): void {
56
+ if (_isAvailable()) localStorage.removeItem(STORAGE_KEY);
57
+ }
58
+
59
+ export function mergeWithHistory(
60
+ q: string,
61
+ suggestItems: { title: string }[]
62
+ ): { title: string; fromHistory: boolean }[] {
63
+ const histEntries = getHistory(q);
64
+ const histSet = new Set(histEntries.map((e) => e.q.toLowerCase()));
65
+ const histResults = histEntries.map((e) => ({ title: e.q, fromHistory: true as const }));
66
+ const filtered = suggestItems
67
+ .filter((s) => !histSet.has(s.title.toLowerCase()))
68
+ .map((s) => ({ title: s.title, fromHistory: false as const }));
69
+ return [...histResults, ...filtered];
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,307 @@
1
+ // src/index.ts
2
+ import { configure, getConfig, type Config } from "./config.js";
3
+ import { store as memStore, clearStore, evictExpired, trimToHalf } from "./cache/memory.js";
4
+ import { getCacheKey, get as memGet, set as memSet } from "./cache/memory.js";
5
+ import { getP, setP, cleanup, destroyDB } from "./cache/persistent.js";
6
+ import { initMemoryMonitor, getIsLowMemory, getIsCriticalMemory, getCurrentCacheMax, destroyMemoryMonitor } from "./memory.js";
7
+ import { enqueue, Priority, clearQueues, type PriorityValue } from "./request/queue.js";
8
+ import { fetchWithRetry, cancel as cancelRequest, cancelAll, type FetchResult } from "./request/retry.js";
9
+ import { debounce } from "./utils.js";
10
+ import {
11
+ extractMeta, extractDetail,
12
+ type ResultMeta, type ResultDetail, type ParsedResponse,
13
+ } from "./parser.js";
14
+ import {
15
+ getSuggest as _getSuggest,
16
+ getSuggestDebounced,
17
+ clearSuggestCache,
18
+ type SuggestItem,
19
+ type SuggestResult,
20
+ } from "./suggest.js";
21
+ import { on, off, emit, clearAllListeners, type SearchEventMap } from "./events.js";
22
+ import { addHistory, getHistory, removeHistory, clearHistory, mergeWithHistory, type HistoryEntry } from "./history.js";
23
+ import { initOfflineMonitor, destroyOfflineMonitor, getIsOnline, addRetryTask } from "./offline.js";
24
+
25
+ export {
26
+ configure, debounce,
27
+ cancelRequest, cancelAll,
28
+ getSuggestDebounced,
29
+ on, off,
30
+ addHistory, getHistory, removeHistory, clearHistory, mergeWithHistory,
31
+ getIsOnline,
32
+ };
33
+ export type {
34
+ Config, FetchResult,
35
+ ResultMeta, ResultDetail, ParsedResponse,
36
+ SuggestItem, SuggestResult,
37
+ SearchEventMap, HistoryEntry,
38
+ };
39
+
40
+ export type SearchType = "web" | "image" | "video" | "news" | "panel";
41
+
42
+ const HEAVY_TYPES: ReadonlySet<SearchType> = new Set(["image", "video"]);
43
+
44
+ export interface SearchOptions {
45
+ q: string;
46
+ page?: number;
47
+ type?: SearchType;
48
+ safesearch?: 0 | 1 | 2;
49
+ lang?: string;
50
+ enableStreaming?: boolean;
51
+ onChunk?: (chunk: unknown) => void;
52
+ usePersistentCache?: boolean;
53
+ metaOnly?: boolean;
54
+ signal?: AbortSignal;
55
+ }
56
+
57
+ export interface RequestOptions {
58
+ useCache?: boolean;
59
+ priority?: PriorityValue;
60
+ onChunk?: ((chunk: unknown) => void) | null;
61
+ usePersistentCache?: boolean;
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ export interface SearchStats {
66
+ memoryCacheSize: number;
67
+ memoryCacheMax: number;
68
+ isLowMemory: boolean;
69
+ isCriticalMemory: boolean;
70
+ inFlightCount: number;
71
+ isOnline: boolean;
72
+ }
73
+
74
+ export function getSearchStats(): SearchStats {
75
+ return {
76
+ memoryCacheSize: memStore.size,
77
+ memoryCacheMax: getCurrentCacheMax(),
78
+ isLowMemory: getIsLowMemory(),
79
+ isCriticalMemory: getIsCriticalMemory(),
80
+ inFlightCount: inFlight.size,
81
+ isOnline: getIsOnline(),
82
+ };
83
+ }
84
+
85
+ let _cleanupTimer: ReturnType<typeof setInterval> | null = null;
86
+
87
+ export function init(options: Partial<Config> = {}): void {
88
+ configure(options);
89
+ initMemoryMonitor(memStore);
90
+ initOfflineMonitor();
91
+ if (_cleanupTimer !== null) clearInterval(_cleanupTimer);
92
+ _cleanupTimer = setInterval(cleanup, getConfig().PERSISTENT_CLEANUP_INTERVAL);
93
+ }
94
+
95
+ export async function destroy(): Promise<void> {
96
+ cancelAll();
97
+ clearQueues();
98
+ inFlight.clear();
99
+ clearStore();
100
+ clearSuggestCache();
101
+ clearAllListeners();
102
+ destroyOfflineMonitor();
103
+ await destroyDB();
104
+ destroyMemoryMonitor();
105
+ if (_cleanupTimer !== null) {
106
+ clearInterval(_cleanupTimer);
107
+ _cleanupTimer = null;
108
+ }
109
+ }
110
+
111
+ const inFlight = new Map<string, Promise<FetchResult>>();
112
+
113
+ function _requestKey(endpoint: string, params: Record<string, unknown>): string {
114
+ const p = params as { q?: string; page?: number; type?: string };
115
+ return endpoint + "\x00" + (p.q ?? "") + "\x00" + (p.page ?? 1) + "\x00" + (p.type ?? "web");
116
+ }
117
+
118
+ async function request(
119
+ endpoint: string,
120
+ params: Record<string, unknown> = {},
121
+ {
122
+ useCache = true,
123
+ priority = Priority.NORMAL,
124
+ onChunk,
125
+ usePersistentCache = false,
126
+ signal,
127
+ }: RequestOptions = {}
128
+ ): Promise<FetchResult> {
129
+ const cfg = getConfig();
130
+ const lowMem = getIsLowMemory();
131
+ const offline = !getIsOnline();
132
+
133
+ if (getIsCriticalMemory()) { evictExpired(); trimToHalf(); }
134
+ else if (lowMem) { evictExpired(); }
135
+
136
+ const url = new URL(cfg.API_BASE + endpoint);
137
+ for (const k in params) {
138
+ const v = params[k];
139
+ if (v != null) url.searchParams.append(k, String(v));
140
+ }
141
+
142
+ const cacheKey = getCacheKey(endpoint, params);
143
+ const reqKey = _requestKey(endpoint, params);
144
+
145
+ if (useCache) {
146
+ const hit = memGet(cacheKey);
147
+ if (hit) {
148
+ if (hit.expired && !lowMem && !offline) {
149
+ enqueue(async () => {
150
+ const r = await fetchWithRetry(url.toString(), _fetchOpts, reqKey);
151
+ if (r.ok) {
152
+ memSet(cacheKey, r.data);
153
+ if (usePersistentCache) await setP(cacheKey, r.data);
154
+ emit("cacheRefreshed", { key: cacheKey });
155
+ }
156
+ }, priority);
157
+ }
158
+ return { ok: true, data: hit.data, cached: true, stale: hit.expired };
159
+ }
160
+ if (usePersistentCache) {
161
+ const pData = await getP(cacheKey);
162
+ if (pData) {
163
+ if (!lowMem) memSet(cacheKey, pData);
164
+ return { ok: true, data: pData, cached: true, persistent: true };
165
+ }
166
+ }
167
+ }
168
+
169
+ if (offline) return { ok: false, error: "offline" };
170
+ if (signal?.aborted) return { ok: false, error: "cancelled" };
171
+
172
+ const existing = inFlight.get(reqKey);
173
+ if (existing) return existing;
174
+
175
+ const fetchOpts: RequestInit = signal ? { ..._fetchOpts, signal } : _fetchOpts;
176
+
177
+ const promise = new Promise<FetchResult>((resolve) => {
178
+ const onAbort = (): void => resolve({ ok: false, error: "cancelled" });
179
+ signal?.addEventListener("abort", onAbort, { once: true });
180
+
181
+ enqueue(async () => {
182
+ signal?.removeEventListener("abort", onAbort);
183
+ if (signal?.aborted) { resolve({ ok: false, error: "cancelled" }); return; }
184
+ try {
185
+ const result = await fetchWithRetry(url.toString(), fetchOpts, reqKey, onChunk ?? undefined);
186
+ if (result.ok && useCache && !result.streamed) {
187
+ if (!lowMem) memSet(cacheKey, result.data);
188
+ if (usePersistentCache) await setP(cacheKey, result.data);
189
+ }
190
+ if (!result.ok && result.error === "network_error") {
191
+ addRetryTask(async () => {
192
+ const r = await fetchWithRetry(url.toString(), _fetchOpts, reqKey);
193
+ if (r.ok) {
194
+ if (!lowMem) memSet(cacheKey, r.data);
195
+ if (usePersistentCache) await setP(cacheKey, r.data);
196
+ }
197
+ });
198
+ }
199
+ resolve(result);
200
+ } catch (e) {
201
+ resolve({ ok: false, error: e instanceof Error ? e.message : "unknown_error" });
202
+ } finally {
203
+ inFlight.delete(reqKey);
204
+ }
205
+ }, priority);
206
+ });
207
+
208
+ inFlight.set(reqKey, promise);
209
+ return promise;
210
+ }
211
+
212
+ const _fetchOpts: RequestInit = Object.freeze({
213
+ method: "GET",
214
+ headers: Object.freeze({ Accept: "application/json" }),
215
+ });
216
+
217
+ function _prefetch(endpoint: string, params: Record<string, unknown>): void {
218
+ if (getIsLowMemory() || !getIsOnline()) return;
219
+ const cacheKey = getCacheKey(endpoint, params);
220
+ const hit = memGet(cacheKey);
221
+ if (hit && !hit.expired) return;
222
+ if (inFlight.has(_requestKey(endpoint, params))) return;
223
+ request(endpoint, params, { priority: Priority.LOW }).catch(() => {});
224
+ }
225
+
226
+ export async function search({
227
+ q, page = 1, type = "web", safesearch = 0, lang = "ja",
228
+ enableStreaming = false, onChunk, usePersistentCache = false, metaOnly = false, signal,
229
+ }: SearchOptions): Promise<FetchResult> {
230
+ if (!q?.trim()) return { ok: false, error: "empty_query" };
231
+ const lowMem = getIsLowMemory();
232
+ const isHeavy = HEAVY_TYPES.has(type);
233
+ const forceStream = isHeavy && lowMem;
234
+ const params = { q: q.trim(), page, type, safesearch, lang };
235
+ if (page < 10 && !lowMem) _prefetch("/search", { ...params, page: page + 1 });
236
+ if (forceStream || (enableStreaming && onChunk)) {
237
+ return request("/search", params, { priority: Priority.NORMAL, onChunk: onChunk ?? undefined, usePersistentCache, signal });
238
+ }
239
+ const result = await request("/search", params, { priority: Priority.NORMAL, onChunk: null, usePersistentCache, signal });
240
+ if (!result.ok) return result;
241
+ if (isHeavy || metaOnly || lowMem) {
242
+ return { ...result, data: extractMeta(result.data, type, lowMem) };
243
+ }
244
+ return result;
245
+ }
246
+
247
+ export function searchMeta(opts: Omit<SearchOptions, "metaOnly" | "enableStreaming" | "onChunk">): Promise<FetchResult> {
248
+ return search({ ...opts, metaOnly: true });
249
+ }
250
+
251
+ export async function fetchDetail(
252
+ opts: Omit<SearchOptions, "metaOnly" | "enableStreaming" | "onChunk">,
253
+ idx: number
254
+ ): Promise<ResultDetail | null> {
255
+ const { q, page = 1, type = "web", safesearch = 0, lang = "ja", usePersistentCache = false } = opts;
256
+ if (!q?.trim()) return null;
257
+ const params = { q: q.trim(), page, type, safesearch, lang };
258
+ const cacheKey = getCacheKey("/search", params);
259
+ const hit = memGet(cacheKey);
260
+ if (hit) return extractDetail(hit.data, idx);
261
+ if (usePersistentCache) {
262
+ const pData = await getP(cacheKey);
263
+ if (pData) return extractDetail(pData, idx);
264
+ }
265
+ const result = await request("/search", params, { priority: Priority.NORMAL, usePersistentCache });
266
+ return result.ok ? extractDetail(result.data, idx) : null;
267
+ }
268
+
269
+ export function getSuggest(q: string): Promise<SuggestResult> { return _getSuggest(q); }
270
+
271
+ export interface Pager {
272
+ next(): Promise<FetchResult | null>;
273
+ prev(): Promise<FetchResult | null>;
274
+ readonly currentPage: number;
275
+ reset(): void;
276
+ }
277
+
278
+ export function createPager(opts: Omit<SearchOptions, "page">, maxPage = 10): Pager {
279
+ let _page = 0;
280
+ return {
281
+ get currentPage() { return _page; },
282
+ async next() { if (_page >= maxPage) return null; _page++; return search({ ...opts, page: _page }); },
283
+ async prev() { if (_page <= 1) return null; _page--; return search({ ...opts, page: _page }); },
284
+ reset() { _page = 0; },
285
+ };
286
+ }
287
+
288
+ export type SearchAllResult = Partial<Record<SearchType, FetchResult>>;
289
+
290
+ export async function searchAll(
291
+ opts: Omit<SearchOptions, "type">,
292
+ types: SearchType[] = ["web", "news"]
293
+ ): Promise<SearchAllResult> {
294
+ const entries = await Promise.allSettled(types.map((type) => search({ ...opts, type })));
295
+ const result: SearchAllResult = {};
296
+ for (let i = 0; i < types.length; i++) {
297
+ const s = entries[i];
298
+ if (s.status === "fulfilled" && s.value.ok) result[types[i]] = s.value;
299
+ }
300
+ return result;
301
+ }
302
+
303
+ export const searchWeb = (q: string, page = 1, signal?: AbortSignal): Promise<FetchResult> => search({ q, page, type: "web", signal });
304
+ export const searchImage = (q: string, page = 1, signal?: AbortSignal): Promise<FetchResult> => search({ q, page, type: "image", signal });
305
+ export const searchVideo = (q: string, page = 1, signal?: AbortSignal): Promise<FetchResult> => search({ q, page, type: "video", signal });
306
+ export const searchNews = (q: string, page = 1, signal?: AbortSignal): Promise<FetchResult> => search({ q, page, type: "news", signal });
307
+ export const searchPanel = (q: string, signal?: AbortSignal): Promise<FetchResult> => search({ q, type: "panel", signal });
package/src/memory.ts ADDED
@@ -0,0 +1,101 @@
1
+ // src/memory.ts
2
+ import { getConfig } from "./config.js";
3
+ import { emit } from "./events.js";
4
+
5
+ interface Capabilities {
6
+ performanceMemory: boolean;
7
+ deviceMemory: boolean;
8
+ }
9
+
10
+ const capabilities: Capabilities = {
11
+ performanceMemory:
12
+ typeof performance !== "undefined" &&
13
+ !!(performance as Performance & { memory?: unknown }).memory,
14
+ deviceMemory:
15
+ typeof navigator !== "undefined" &&
16
+ typeof (navigator as Navigator & { deviceMemory?: number }).deviceMemory === "number",
17
+ };
18
+
19
+ let isLowMemory = false;
20
+ let isCriticalMemory = false;
21
+ let currentCacheMax = 0;
22
+ let _monitorTimer: ReturnType<typeof setInterval> | null = null;
23
+
24
+ export const getIsLowMemory = (): boolean => isLowMemory;
25
+ export const getIsCriticalMemory = (): boolean => isCriticalMemory;
26
+ export const getCurrentCacheMax = (): number => currentCacheMax;
27
+
28
+ export function initMemoryMonitor(cacheRef: Map<unknown, unknown>): void {
29
+ if (_monitorTimer !== null) {
30
+ clearInterval(_monitorTimer);
31
+ _monitorTimer = null;
32
+ }
33
+
34
+ const cfg = getConfig();
35
+ currentCacheMax = cfg.CACHE_MAX;
36
+
37
+ const nav = navigator as Navigator & { deviceMemory?: number };
38
+ if (capabilities.deviceMemory && nav.deviceMemory !== undefined && nav.deviceMemory <= 2) {
39
+ currentCacheMax = cfg.CACHE_LOW_MEMORY;
40
+ }
41
+
42
+ _monitorTimer = setInterval(() => _check(cacheRef), cfg.MEMORY_CHECK_INTERVAL);
43
+ }
44
+
45
+ export function destroyMemoryMonitor(): void {
46
+ if (_monitorTimer !== null) {
47
+ clearInterval(_monitorTimer);
48
+ _monitorTimer = null;
49
+ }
50
+ isLowMemory = false;
51
+ isCriticalMemory = false;
52
+ }
53
+
54
+ function _check(cacheRef: Map<unknown, unknown>): void {
55
+ const cfg = getConfig();
56
+ let pressure = 0;
57
+
58
+ if (capabilities.performanceMemory) {
59
+ const mem = (performance as Performance & {
60
+ memory: { usedJSHeapSize: number; jsHeapSizeLimit: number };
61
+ }).memory;
62
+ pressure = Math.max(pressure, (mem.usedJSHeapSize / mem.jsHeapSizeLimit) * 100);
63
+ }
64
+
65
+ if (cacheRef.size > currentCacheMax * 0.85) {
66
+ pressure = Math.max(pressure, 65);
67
+ }
68
+
69
+ const prevLow = isLowMemory;
70
+ const prevCritical = isCriticalMemory;
71
+
72
+ const normalThreshold = cfg.MEMORY_PRESSURE_NORMAL * 100;
73
+ const criticalThreshold = cfg.MEMORY_PRESSURE_CRITICAL * 100;
74
+
75
+ isCriticalMemory = pressure > criticalThreshold;
76
+ isLowMemory = pressure > normalThreshold;
77
+
78
+ if (isLowMemory !== prevLow || isCriticalMemory !== prevCritical) {
79
+ emit("memoryStateChange", { isLow: isLowMemory, isCritical: isCriticalMemory });
80
+ }
81
+
82
+ if (isLowMemory) {
83
+ const target = isCriticalMemory
84
+ ? Math.ceil(cfg.CACHE_LOW_MEMORY * 0.5)
85
+ : cfg.CACHE_LOW_MEMORY;
86
+ if (currentCacheMax > target) {
87
+ _trimCache(cacheRef, target);
88
+ currentCacheMax = target;
89
+ }
90
+ } else if (!isLowMemory && prevLow) {
91
+ currentCacheMax = Math.min(cfg.CACHE_MAX, Math.ceil(currentCacheMax * 1.5));
92
+ }
93
+ }
94
+
95
+ function _trimCache(cacheRef: Map<unknown, unknown>, maxSize: number): void {
96
+ while (cacheRef.size > maxSize) {
97
+ const firstKey = cacheRef.keys().next().value;
98
+ if (firstKey !== undefined) cacheRef.delete(firstKey);
99
+ else break;
100
+ }
101
+ }