/** * Fuse.js v7.4.0 - Lightweight fuzzy-search (http://fusejs.io) * * Copyright (c) 2026 Kiro Risk (http://kiro.me) * All Rights Reserved. Apache Software License 2.0 * * http://www.apache.org/licenses/LICENSE-2.0 */ const FUSE_WORKER_UNSUPPORTED_FN_OPTION = option => `FuseWorker does not support function-valued option '${option}': ` + `functions cannot be transferred to Web Workers via postMessage. ` + `Remove this option or fall back to Fuse.`; const FUSE_WORKER_TOKEN_SEARCH_UNSUPPORTED = `FuseWorker does not support useTokenSearch: token search depends on ` + `corpus-level statistics (df, fieldCount) that are computed per shard, ` + `so per-shard scores would diverge from single-thread Fuse. Use Fuse on ` + `the main thread for token search.`; /// const DEFAULT_MAX_WORKERS = 8; function getDefaultWorkerCount() { const hw = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4; return Math.min(hw, DEFAULT_MAX_WORKERS); } class FuseWorker { _shards = null; _addCursor = 0; _initPromise = null; _pending = new Map(); _nextId = 0; constructor(docs, options, workerOptions) { this._docs = docs.slice(); this._options = options || {}; this._workerOptions = workerOptions || {}; // Reject function-valued options eagerly. Without this check, postMessage // throws DataCloneError on first search() rather than at construction. FuseWorker._assertNoFunctionOptions(this._options); // Token search needs global corpus statistics, but each shard would build // its own — scores would diverge from single-thread Fuse. Refuse upfront // rather than silently returning ordering that doesn't match. if (this._options.useTokenSearch) { throw new Error(FUSE_WORKER_TOKEN_SEARCH_UNSUPPORTED); } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -- import.meta.url is resolved by Rollup at build time this._workerUrl = this._workerOptions.workerUrl || new URL('./fuse.worker.mjs', import.meta.url); } static _assertNoFunctionOptions(options) { if (typeof options.sortFn === 'function') { throw new Error(FUSE_WORKER_UNSUPPORTED_FN_OPTION('sortFn')); } if (typeof options.getFn === 'function') { throw new Error(FUSE_WORKER_UNSUPPORTED_FN_OPTION('getFn')); } if (typeof options.tokenize === 'function') { throw new Error(FUSE_WORKER_UNSUPPORTED_FN_OPTION('tokenize')); } const keys = options.keys; if (Array.isArray(keys)) { for (let i = 0, len = keys.length; i < len; i += 1) { const key = keys[i]; if (key && typeof key === 'object' && !Array.isArray(key)) { if (typeof key.getFn === 'function') { const name = key.name; const label = Array.isArray(name) ? name.join('.') : name ?? String(i); throw new Error(FUSE_WORKER_UNSUPPORTED_FN_OPTION(`keys[${label}].getFn`)); } } } } } _getNumWorkers() { return this._workerOptions.numWorkers || getDefaultWorkerCount(); } _ensureInit() { if (this._initPromise) return this._initPromise; this._initPromise = this._init(); return this._initPromise; } _spawnWorker() { const worker = new Worker(this._workerUrl, { type: 'module' }); worker.onmessage = e => { const { id, result, error } = e.data; const handler = this._pending.get(id); if (!handler) return; this._pending.delete(id); if (error) { handler.reject(new Error(error)); } else { handler.resolve(result); } }; worker.onerror = e => { for (const [, handler] of this._pending) { handler.reject(new Error(e.message)); } }; return worker; } _workerInitOptions() { // Force includeScore so the main thread can do a global (score, idx) // tie-break across shards. Score is stripped from the final result if the // caller didn't ask for it. return { ...this._options, includeScore: true }; } async _init() { const numWorkers = this._getNumWorkers(); const chunkSize = Math.ceil(this._docs.length / numWorkers); this._shards = []; this._addCursor = 0; const workerInitOptions = this._workerInitOptions(); const initPromises = []; for (let i = 0; i < numWorkers; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, this._docs.length); const chunk = this._docs.slice(start, end); const globalIndices = []; for (let j = start; j < end; j += 1) globalIndices.push(j); const shard = { worker: this._spawnWorker(), globalIndices }; this._shards.push(shard); initPromises.push(this._call(shard.worker, 'init', [chunk, workerInitOptions])); } await Promise.all(initPromises); } _call(worker, method, args) { const id = this._nextId++; return new Promise((resolve, reject) => { this._pending.set(id, { resolve, reject }); worker.postMessage({ id, method, args }); }); } async search(query, options) { await this._ensureInit(); const shards = this._shards; const results = await Promise.all(shards.map(s => this._call(s.worker, 'search', [query, options]))); // Merge results from all shards, rewriting refIndex from shard-local to global const merged = []; for (let i = 0, len = results.length; i < len; i += 1) { const { globalIndices } = shards[i]; for (const r of results[i]) { merged.push({ ...r, refIndex: globalIndices[r.refIndex] }); } } const shouldSort = this._options.shouldSort !== false; if (shouldSort) { // Mirror Fuse's default sortFn: (score, idx) tie-break, but on the // GLOBAL refIndex so equal-score results across shards collapse into a // deterministic order matching single-thread Fuse. merged.sort((a, b) => { const sa = a.score ?? 0; const sb = b.score ?? 0; if (sa === sb) { return a.refIndex < b.refIndex ? -1 : 1; } return sa < sb ? -1 : 1; }); } else { // Restore global collection order. Round-robin add() and // shard-concatenation order otherwise leak through. merged.sort((a, b) => a.refIndex - b.refIndex); } // Workers always include score so the merge above can tie-break; strip it // here if the caller didn't ask for it. Rebuild the object instead of // `delete`-ing — `delete` deopts the shape and roughly doubles the // post-sort cost on large result sets. if (!this._options.includeScore) { for (let i = 0, len = merged.length; i < len; i += 1) { const r = merged[i]; merged[i] = r.matches !== undefined ? { item: r.item, refIndex: r.refIndex, matches: r.matches } : { item: r.item, refIndex: r.refIndex }; } } const limit = options?.limit; if (limit && limit > 0) { return merged.slice(0, limit); } return merged; } async add(doc) { await this._ensureInit(); const shards = this._shards; const shard = shards[this._addCursor % shards.length]; this._addCursor += 1; const globalIdx = this._docs.length; this._docs.push(doc); shard.globalIndices.push(globalIdx); await this._call(shard.worker, 'add', [doc]); } async setCollection(docs) { this._docs = docs.slice(); if (!this._shards) { this._initPromise = null; return; } const shards = this._shards; const chunkSize = Math.ceil(this._docs.length / shards.length); this._addCursor = 0; const tasks = []; for (let i = 0, len = shards.length; i < len; i += 1) { const start = i * chunkSize; const end = Math.min(start + chunkSize, this._docs.length); const chunk = this._docs.slice(start, end); const globalIndices = []; for (let j = start; j < end; j += 1) globalIndices.push(j); shards[i].globalIndices = globalIndices; tasks.push(this._call(shards[i].worker, 'setCollection', [chunk])); } await Promise.all(tasks); } terminate() { if (this._shards) { for (const { worker } of this._shards) { worker.terminate(); } this._shards = null; } this._initPromise = null; for (const [, handler] of this._pending) { handler.reject(new Error('FuseWorker terminated')); } this._pending.clear(); } } export { FuseWorker };