@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
package/src/offline.ts ADDED
@@ -0,0 +1,44 @@
1
+ // src/offline.ts
2
+ import { emit } from "./events.js";
3
+
4
+ type RetryTask = () => Promise<void>;
5
+
6
+ const _pendingRetries: RetryTask[] = [];
7
+ let _isOnline = true;
8
+ let _initialized = false;
9
+
10
+ export function getIsOnline(): boolean { return _isOnline; }
11
+
12
+ export function initOfflineMonitor(): void {
13
+ if (_initialized || typeof window === "undefined") return;
14
+ _initialized = true;
15
+ _isOnline = navigator.onLine;
16
+ window.addEventListener("online", _handleOnline);
17
+ window.addEventListener("offline", _handleOffline);
18
+ }
19
+
20
+ export function destroyOfflineMonitor(): void {
21
+ if (typeof window !== "undefined") {
22
+ window.removeEventListener("online", _handleOnline);
23
+ window.removeEventListener("offline", _handleOffline);
24
+ }
25
+ _initialized = false;
26
+ _isOnline = true;
27
+ _pendingRetries.length = 0;
28
+ }
29
+
30
+ export function addRetryTask(task: RetryTask): void {
31
+ if (_pendingRetries.length < 50) _pendingRetries.push(task);
32
+ }
33
+
34
+ function _handleOnline(): void {
35
+ _isOnline = true;
36
+ emit("online");
37
+ const tasks = _pendingRetries.splice(0);
38
+ for (const task of tasks) task().catch(() => {});
39
+ }
40
+
41
+ function _handleOffline(): void {
42
+ _isOnline = false;
43
+ emit("offline");
44
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,138 @@
1
+ // src/parser.ts
2
+ import type { SearchType } from "./index.js";
3
+
4
+ export interface BaseMeta {
5
+ _idx: number;
6
+ title?: string;
7
+ url?: string;
8
+ }
9
+ export interface WebMeta extends BaseMeta { summary?: string; favicon?: string; }
10
+ export interface ImageMeta extends BaseMeta { thumbnail?: string; domain?: string; }
11
+ export interface VideoMeta extends BaseMeta { thumbnail?: string; duration?: string; publishedDate?: string; }
12
+ export interface NewsMeta extends BaseMeta { summary?: string; favicon?: string; publishedDate?: string; }
13
+ export type ResultMeta = WebMeta | ImageMeta | VideoMeta | NewsMeta | BaseMeta;
14
+ export type ResultDetail = ResultMeta & { [key: string]: unknown };
15
+ export interface ParsedResponse { meta: ResultMeta[]; raw: unknown; }
16
+
17
+ const META_FIELDS_BY_TYPE: Record<string, readonly string[]> = {
18
+ web: ["title", "url", "summary", "favicon"],
19
+ news: ["title", "url", "summary", "favicon", "publishedDate"],
20
+ image: ["title", "url", "thumbnail", "domain"],
21
+ video: ["title", "url", "thumbnail", "duration", "publishedDate"],
22
+ };
23
+
24
+ const HEAVY_FIELDS = new Set<string>(["summary"]);
25
+
26
+ const FIELD_ALIASES: Record<string, readonly string[]> = {
27
+ url: ["url", "href", "link"],
28
+ thumbnail: ["thumbnail", "image", "img", "thumb"],
29
+ favicon: ["favicon", "icon"],
30
+ domain: ["domain", "source", "siteName"],
31
+ summary: ["summary", "description", "snippet", "desc", "content"],
32
+ publishedDate: ["publishedDate", "date", "published", "publishedAt"],
33
+ duration: ["duration", "length"],
34
+ };
35
+
36
+ export function extractMeta(
37
+ data: unknown,
38
+ type: SearchType = "web",
39
+ lowMemory = false
40
+ ): ResultMeta[] {
41
+ const fields = _fieldsForType(type, lowMemory);
42
+ const arr = _toArray(data);
43
+ const result: ResultMeta[] = new Array(arr.length);
44
+ for (let i = 0; i < arr.length; i++) {
45
+ const obj = _safeObj(arr[i]);
46
+ const meta: Record<string, unknown> = { _idx: i };
47
+ for (const f of fields) {
48
+ const v = _resolve(obj, f);
49
+ if (v !== undefined) meta[f] = v;
50
+ }
51
+ // _idx は必ず存在するので unknown 経由のキャストで型安全
52
+ result[i] = meta as unknown as ResultMeta;
53
+ }
54
+ return result;
55
+ }
56
+
57
+ export function extractDetail(data: unknown, idx: number): ResultDetail | null {
58
+ const arr = _toArray(data);
59
+ if (idx < 0 || idx >= arr.length) return null;
60
+ const obj = _safeObj(arr[idx]);
61
+ return {
62
+ _idx: idx,
63
+ title: _resolve(obj, "title"),
64
+ url: _resolve(obj, "url"),
65
+ thumbnail: _resolve(obj, "thumbnail"),
66
+ favicon: _resolve(obj, "favicon"),
67
+ domain: _resolve(obj, "domain"),
68
+ summary: _resolve(obj, "summary"),
69
+ duration: _resolve(obj, "duration"),
70
+ publishedDate: _resolve(obj, "publishedDate"),
71
+ ...obj,
72
+ };
73
+ }
74
+
75
+ export function chunkToMeta(
76
+ chunk: unknown,
77
+ type: SearchType = "web",
78
+ lowMemory = false
79
+ ): ResultMeta | null {
80
+ if (!chunk) return null;
81
+ const fields = _fieldsForType(type, lowMemory);
82
+ const tryObj = _safeObj(chunk);
83
+ if ("title" in tryObj || "url" in tryObj) return _buildMeta(tryObj, fields);
84
+ const arr = _toArray(chunk);
85
+ if (arr.length > 0) return chunkToMeta(arr[0], type, lowMemory);
86
+ return null;
87
+ }
88
+
89
+ function _fieldsForType(type: SearchType, lowMemory: boolean): readonly string[] {
90
+ const base = META_FIELDS_BY_TYPE[type] ?? ["title", "url"];
91
+ if (!lowMemory) return base;
92
+ return base.filter((f) => !HEAVY_FIELDS.has(f));
93
+ }
94
+
95
+ function _buildMeta(obj: Record<string, unknown>, fields: readonly string[]): ResultMeta {
96
+ const meta: Record<string, unknown> = {
97
+ _idx: typeof obj._idx === "number" ? obj._idx : 0,
98
+ };
99
+ for (const f of fields) {
100
+ const v = _resolve(obj, f);
101
+ if (v !== undefined) meta[f] = v;
102
+ }
103
+ return meta as unknown as ResultMeta;
104
+ }
105
+
106
+ function _resolve(obj: Record<string, unknown>, field: string): string | undefined {
107
+ const aliases = FIELD_ALIASES[field];
108
+ if (aliases) {
109
+ for (const key of aliases) {
110
+ const v = _str(obj[key]);
111
+ if (v !== undefined) return v;
112
+ }
113
+ return undefined;
114
+ }
115
+ return _str(obj[field]);
116
+ }
117
+
118
+ function _safeObj(v: unknown): Record<string, unknown> {
119
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
120
+ }
121
+
122
+ function _str(v: unknown): string | undefined {
123
+ if (typeof v === "string") { const s = v.trim(); return s.length > 0 ? s : undefined; }
124
+ if (typeof v === "number") return String(v);
125
+ return undefined;
126
+ }
127
+
128
+ function _toArray(data: unknown): unknown[] {
129
+ if (Array.isArray(data)) return data;
130
+ if (data && typeof data === "object") {
131
+ const obj = data as Record<string, unknown>;
132
+ for (const key of ["results", "items", "data", "hits", "entries", "docs"]) {
133
+ const v = obj[key];
134
+ if (Array.isArray(v)) return v;
135
+ }
136
+ }
137
+ return [];
138
+ }
@@ -0,0 +1,38 @@
1
+ // src/request/queue.ts
2
+ import { getConfig } from "../config.js";
3
+ import { getIsLowMemory } from "../memory.js";
4
+
5
+ export type PriorityValue = 0 | 1 | 2;
6
+
7
+ export const Priority = {
8
+ LOW: 0 as PriorityValue,
9
+ NORMAL: 1 as PriorityValue,
10
+ HIGH: 2 as PriorityValue,
11
+ } as const;
12
+
13
+ type Task = () => Promise<void>;
14
+
15
+ const _queues: [Task[], Task[], Task[]] = [[], [], []];
16
+ let _running = 0;
17
+
18
+ export function enqueue(task: Task, priority: PriorityValue = Priority.NORMAL): void {
19
+ _queues[priority].push(task);
20
+ _drain();
21
+ }
22
+
23
+ export function clearQueues(): void {
24
+ _queues[0].length = 0;
25
+ _queues[1].length = 0;
26
+ _queues[2].length = 0;
27
+ }
28
+
29
+ function _drain(): void {
30
+ const cfg = getConfig();
31
+ const max = getIsLowMemory() ? cfg.MAX_CONCURRENT_LOW_MEMORY : cfg.MAX_CONCURRENT_REQUESTS;
32
+ while (_running < max) {
33
+ const task = _queues[2].shift() ?? _queues[1].shift() ?? _queues[0].shift();
34
+ if (!task) break;
35
+ _running++;
36
+ task().finally(() => { _running--; _drain(); });
37
+ }
38
+ }
@@ -0,0 +1,162 @@
1
+ // src/request/retry.ts
2
+ import { getConfig } from "../config.js";
3
+
4
+ export interface FetchResult {
5
+ ok: boolean;
6
+ data?: unknown;
7
+ error?: string;
8
+ status?: number;
9
+ cached?: boolean;
10
+ stale?: boolean;
11
+ persistent?: boolean;
12
+ streamed?: boolean;
13
+ }
14
+
15
+ const controllers = new Map<string, AbortController>();
16
+
17
+ export function cancel(key: string): void {
18
+ controllers.get(key)?.abort();
19
+ controllers.delete(key);
20
+ }
21
+
22
+ export function cancelAll(): void {
23
+ for (const ctrl of controllers.values()) ctrl.abort();
24
+ controllers.clear();
25
+ }
26
+
27
+ export async function fetchWithRetry(
28
+ url: string,
29
+ opts: RequestInit,
30
+ key: string,
31
+ onChunk?: (chunk: unknown) => void
32
+ ): Promise<FetchResult> {
33
+ const cfg = getConfig();
34
+ const canAbort = typeof AbortController !== "undefined";
35
+ const canStream = typeof ReadableStream !== "undefined" && !!onChunk;
36
+
37
+ for (let attempt = 0; attempt <= cfg.RETRIES; attempt++) {
38
+ controllers.get(key)?.abort();
39
+ const ctrl = canAbort ? new AbortController() : null;
40
+ if (ctrl) controllers.set(key, ctrl);
41
+
42
+ const externalSignal = opts.signal as AbortSignal | undefined;
43
+ let mergedSignal: AbortSignal | undefined;
44
+
45
+ if (ctrl && externalSignal) {
46
+ if (typeof AbortSignal.any === "function") {
47
+ mergedSignal = AbortSignal.any([ctrl.signal, externalSignal]);
48
+ } else {
49
+ const bridge = (): void => ctrl.abort();
50
+ externalSignal.addEventListener("abort", bridge, { once: true });
51
+ mergedSignal = ctrl.signal;
52
+ }
53
+ } else {
54
+ mergedSignal = ctrl?.signal ?? externalSignal ?? undefined;
55
+ }
56
+
57
+ const fetchOpts: RequestInit = mergedSignal ? { ...opts, signal: mergedSignal } : opts;
58
+ const tid = ctrl ? setTimeout(() => ctrl.abort(), cfg.TIMEOUT) : undefined;
59
+
60
+ try {
61
+ const res = await fetch(url, fetchOpts);
62
+ clearTimeout(tid);
63
+ controllers.delete(key);
64
+
65
+ if (!res.ok) {
66
+ return {
67
+ ok: false,
68
+ error: res.status >= 500 ? "server_error" : "client_error",
69
+ status: res.status,
70
+ };
71
+ }
72
+
73
+ if (canStream && res.body) return await _readStream(res.body, ctrl, onChunk!, cfg);
74
+ return { ok: true, data: await res.json() };
75
+
76
+ } catch (err) {
77
+ clearTimeout(tid);
78
+ controllers.delete(key);
79
+
80
+ if (err instanceof Error) {
81
+ if (err.name === "AbortError" || err.message?.includes("aborted")) {
82
+ return { ok: false, error: "cancelled" };
83
+ }
84
+ if (err instanceof TypeError) {
85
+ if (attempt === cfg.RETRIES) return { ok: false, error: "network_error" };
86
+ await _sleep(cfg.RETRY_BACKOFF_BASE * 2 ** attempt);
87
+ continue;
88
+ }
89
+ }
90
+ return { ok: false, error: "unknown_error" };
91
+ }
92
+ }
93
+
94
+ return { ok: false, error: "max_retries_exceeded" };
95
+ }
96
+
97
+ const _sleep = (ms: number): Promise<void> => new Promise<void>((r) => setTimeout(r, ms));
98
+
99
+ async function _readStream(
100
+ body: ReadableStream<Uint8Array>,
101
+ ctrl: AbortController | null,
102
+ onChunk: (chunk: unknown) => void,
103
+ cfg: ReturnType<typeof getConfig>
104
+ ): Promise<FetchResult> {
105
+ const reader = body.getReader();
106
+ const dec = new TextDecoder();
107
+ let buf = "";
108
+ let braces = 0;
109
+ let inStr = false;
110
+ let esc = false;
111
+ let aborted = false;
112
+ const chunks: unknown[] = [];
113
+
114
+ const onAbort = (): void => { aborted = true; reader.cancel().catch(() => {}); };
115
+ ctrl?.signal.addEventListener("abort", onAbort, { once: true });
116
+
117
+ try {
118
+ outer: while (!aborted) {
119
+ let readResult: ReadableStreamReadResult<Uint8Array>;
120
+ try { readResult = await reader.read(); }
121
+ catch { break outer; }
122
+ if (readResult.done) break;
123
+ buf += dec.decode(readResult.value, { stream: true });
124
+ if (buf.length > cfg.STREAMING_BUFFER_SIZE) _flush();
125
+ }
126
+ if (!aborted) _flush();
127
+ } finally {
128
+ ctrl?.signal.removeEventListener("abort", onAbort);
129
+ try { reader.releaseLock(); } catch { /* already released */ }
130
+ buf = "";
131
+ }
132
+
133
+ return {
134
+ ok: !aborted,
135
+ ...(aborted ? { error: "cancelled" } : {}),
136
+ data: chunks.length === 1 ? chunks[0] : chunks,
137
+ streamed: true,
138
+ };
139
+
140
+ function _flush(): void {
141
+ let start = 0;
142
+ for (let i = 0; i < buf.length; i++) {
143
+ const c = buf[i];
144
+ if (esc) { esc = false; continue; }
145
+ if (c === "\\") { esc = true; continue; }
146
+ if (c === '"') { inStr = !inStr; continue; }
147
+ if (inStr) continue;
148
+ if (c === "{") { if (!braces) start = i; braces++; }
149
+ else if (c === "}") {
150
+ if (!--braces) {
151
+ try {
152
+ const obj: unknown = JSON.parse(buf.slice(start, i + 1));
153
+ chunks.push(obj);
154
+ onChunk(obj);
155
+ } catch { /* malformed JSON */ }
156
+ buf = buf.slice(i + 1);
157
+ i = -1;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
package/src/suggest.ts ADDED
@@ -0,0 +1,174 @@
1
+ // src/suggest.ts
2
+ import { getConfig } from "./config.js";
3
+ import { getIsLowMemory } from "./memory.js";
4
+ import { fetchWithRetry, type FetchResult } from "./request/retry.js";
5
+ import { enqueue, Priority } from "./request/queue.js";
6
+ import { debounce } from "./utils.js";
7
+
8
+ export interface SuggestItem { title: string; }
9
+
10
+ export interface SuggestResult {
11
+ ok: boolean;
12
+ query: string;
13
+ items: SuggestItem[];
14
+ cached?: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ interface SuggestCacheEntry { items: SuggestItem[]; time: number; }
19
+
20
+ const _cache = new Map<string, SuggestCacheEntry>();
21
+ const SUGGEST_CACHE_MAX = 50;
22
+
23
+ function _cacheKey(q: string): string { return q.trim().toLowerCase(); }
24
+
25
+ function _cacheGet(key: string): SuggestCacheEntry | null {
26
+ const entry = _cache.get(key);
27
+ if (!entry) return null;
28
+ if (Date.now() - entry.time > getConfig().SUGGEST_TTL) { _cache.delete(key); return null; }
29
+ _cache.delete(key);
30
+ _cache.set(key, entry);
31
+ return entry;
32
+ }
33
+
34
+ function _cacheSet(key: string, items: SuggestItem[]): void {
35
+ if (_cache.has(key)) _cache.delete(key);
36
+ while (_cache.size >= SUGGEST_CACHE_MAX) {
37
+ const first = _cache.keys().next().value;
38
+ if (first !== undefined) _cache.delete(first as string);
39
+ else break;
40
+ }
41
+ _cache.set(key, { items, time: Date.now() });
42
+ }
43
+
44
+ function _cacheFindPrefix(q: string): SuggestCacheEntry | null {
45
+ const key = _cacheKey(q);
46
+ const exact = _cacheGet(key);
47
+ if (exact) return exact;
48
+ const now = Date.now();
49
+ const ttl = getConfig().SUGGEST_TTL;
50
+ for (const [k, entry] of _cache) {
51
+ if (now - entry.time > ttl) { _cache.delete(k); continue; }
52
+ if (key.startsWith(k)) {
53
+ _cache.delete(k);
54
+ _cache.set(k, entry);
55
+ const filtered = entry.items.filter((item) => item.title.toLowerCase().includes(key));
56
+ if (filtered.length > 0) return { items: filtered, time: entry.time };
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function clearSuggestCache(): void { _cache.clear(); }
63
+
64
+ const _inFlight = new Map<string, Promise<SuggestResult>>();
65
+
66
+ const _fetchOpts: RequestInit = Object.freeze({
67
+ method: "GET",
68
+ headers: Object.freeze({ Accept: "application/json" }),
69
+ });
70
+
71
+ async function _fetchSuggest(q: string): Promise<SuggestResult> {
72
+ const key = _cacheKey(q);
73
+ const cached = _cacheFindPrefix(q);
74
+ if (cached) return { ok: true, query: q, items: cached.items, cached: true };
75
+
76
+ const existing = _inFlight.get(key);
77
+ if (existing) return existing;
78
+
79
+ const cfg = getConfig();
80
+ const url = new URL(cfg.API_BASE + "/search");
81
+ url.searchParams.set("q", q.trim());
82
+ url.searchParams.set("type", "suggest");
83
+
84
+ const promise = new Promise<SuggestResult>((resolve) => {
85
+ enqueue(async () => {
86
+ try {
87
+ const result: FetchResult = await fetchWithRetry(
88
+ url.toString(), _fetchOpts, "suggest\x00" + key
89
+ );
90
+ if (!result.ok) { resolve({ ok: false, query: q, items: [], error: result.error }); return; }
91
+ const items = _parse(result.data);
92
+ _cacheSet(key, items);
93
+ resolve({ ok: true, query: q, items });
94
+ } catch {
95
+ resolve({ ok: false, query: q, items: [], error: "unknown_error" });
96
+ } finally {
97
+ _inFlight.delete(key);
98
+ }
99
+ }, Priority.HIGH);
100
+ });
101
+
102
+ _inFlight.set(key, promise);
103
+ return promise;
104
+ }
105
+
106
+ function _parse(data: unknown): SuggestItem[] {
107
+ return _toArray(data).reduce<SuggestItem[]>((acc, item) => {
108
+ const title = _title(item);
109
+ if (title) acc.push({ title });
110
+ return acc;
111
+ }, []);
112
+ }
113
+
114
+ function _title(item: unknown): string | undefined {
115
+ if (typeof item === "string" && item.length > 0) return item;
116
+ if (item && typeof item === "object") {
117
+ const obj = item as Record<string, unknown>;
118
+ const v = obj.title ?? obj.text ?? obj.value ?? obj.query;
119
+ if (typeof v === "string" && v.length > 0) return v;
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ function _toArray(data: unknown): unknown[] {
125
+ if (Array.isArray(data)) return data;
126
+ if (data && typeof data === "object") {
127
+ const obj = data as Record<string, unknown>;
128
+ for (const key of ["results", "items", "suggestions", "data"]) {
129
+ if (Array.isArray(obj[key])) return obj[key] as unknown[];
130
+ }
131
+ }
132
+ return [];
133
+ }
134
+
135
+ export function getSuggest(q: string): Promise<SuggestResult> {
136
+ if (!q.trim()) return Promise.resolve({ ok: true, query: q, items: [] });
137
+ if (getIsLowMemory()) {
138
+ const half = Math.ceil(SUGGEST_CACHE_MAX / 2);
139
+ while (_cache.size > half) {
140
+ const first = _cache.keys().next().value;
141
+ if (first !== undefined) _cache.delete(first as string);
142
+ else break;
143
+ }
144
+ }
145
+ return _fetchSuggest(q);
146
+ }
147
+
148
+ export function getSuggestDebounced(
149
+ q: string,
150
+ callback: (result: SuggestResult) => void,
151
+ wait?: number
152
+ ): void {
153
+ _debouncedInner(q, callback, wait ?? getConfig().SUGGEST_DEBOUNCE_MS);
154
+ }
155
+
156
+ const _debouncedFns = new Map<number, (q: string, cb: (r: SuggestResult) => void) => void>();
157
+
158
+ function _debouncedInner(
159
+ q: string,
160
+ callback: (result: SuggestResult) => void,
161
+ wait: number
162
+ ): void {
163
+ if (!_debouncedFns.has(wait)) {
164
+ // debounce に渡すラッパーを明示的に unknown[] 型にする
165
+ const fn = (innerQ: unknown, cb: unknown): void => {
166
+ getSuggest(innerQ as string)
167
+ .then(cb as (r: SuggestResult) => void)
168
+ .catch(() => (cb as (r: SuggestResult) => void)({ ok: false, query: innerQ as string, items: [], error: "unknown" }));
169
+ };
170
+ _debouncedFns.set(wait, debounce(fn, { delay: wait, usePromise: false }) as unknown as
171
+ (q: string, cb: (r: SuggestResult) => void) => void);
172
+ }
173
+ _debouncedFns.get(wait)!(q, callback);
174
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,101 @@
1
+ // src/utils.ts
2
+ import { getIsLowMemory } from "./memory.js";
3
+
4
+ // AnyFunction は内部型のみ。外部引数は T extends (...args: never[]) => unknown で表現
5
+ type AnyFunction = (...args: never[]) => unknown;
6
+
7
+ interface DebounceOptions {
8
+ delay?: number;
9
+ leading?: boolean;
10
+ trailing?: boolean;
11
+ usePromise?: boolean;
12
+ }
13
+
14
+ export function debounce<T extends AnyFunction>(
15
+ fn: T,
16
+ options: DebounceOptions | number = {}
17
+ ) {
18
+ const opts: DebounceOptions =
19
+ typeof options === "number" ? { delay: options } : options;
20
+
21
+ const {
22
+ delay = 300,
23
+ leading = false,
24
+ trailing = true,
25
+ usePromise = true,
26
+ } = opts;
27
+
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+ let lastArgs: Parameters<T> | undefined;
30
+ let pending:
31
+ | { resolve: (v: ReturnType<T>) => void; reject: (e: unknown) => void }
32
+ | undefined;
33
+
34
+ const getDelay = (): number => (getIsLowMemory() ? delay * 2 : delay);
35
+
36
+ const execute = async (): Promise<ReturnType<T>> => {
37
+ if (!lastArgs) return undefined as ReturnType<T>;
38
+ try {
39
+ const result = await (fn as (...a: Parameters<T>) => ReturnType<T>)(...lastArgs);
40
+ pending?.resolve(result);
41
+ return result;
42
+ } catch (e) {
43
+ pending?.reject(e);
44
+ throw e;
45
+ } finally {
46
+ pending = undefined;
47
+ }
48
+ };
49
+
50
+ const debounced = (...args: Parameters<T>): Promise<ReturnType<T>> | void => {
51
+ lastArgs = args;
52
+ const shouldCallNow = leading && !timer;
53
+ if (timer) clearTimeout(timer);
54
+
55
+ if (shouldCallNow) {
56
+ if (usePromise) {
57
+ return Promise.resolve(
58
+ (fn as (...a: Parameters<T>) => ReturnType<T>)(...args)
59
+ );
60
+ }
61
+ (fn as (...a: Parameters<T>) => unknown)(...args);
62
+ return;
63
+ }
64
+
65
+ if (!trailing) return;
66
+
67
+ if (usePromise) {
68
+ return new Promise<ReturnType<T>>((resolve, reject) => {
69
+ pending?.reject(new Error("debounced_cancelled"));
70
+ pending = { resolve, reject };
71
+ timer = setTimeout(() => {
72
+ timer = undefined;
73
+ void execute();
74
+ }, getDelay());
75
+ });
76
+ }
77
+
78
+ timer = setTimeout(() => {
79
+ timer = undefined;
80
+ void (fn as (...a: Parameters<T>) => unknown)(...(lastArgs as Parameters<T>));
81
+ }, getDelay());
82
+ };
83
+
84
+ debounced.cancel = (): void => {
85
+ if (timer) { clearTimeout(timer); timer = undefined; }
86
+ pending?.reject(new Error("debounced_cancelled"));
87
+ pending = undefined;
88
+ };
89
+
90
+ debounced.flush = (): Promise<ReturnType<T>> | undefined => {
91
+ if (!timer) return;
92
+ clearTimeout(timer);
93
+ timer = undefined;
94
+ return execute();
95
+ };
96
+
97
+ return debounced as typeof debounced & {
98
+ cancel: () => void;
99
+ flush: () => Promise<ReturnType<T>> | undefined;
100
+ };
101
+ }