255 lines
8.5 KiB
JavaScript
255 lines
8.5 KiB
JavaScript
|
|
/**
|
||
|
|
* 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.`;
|
||
|
|
|
||
|
|
/// <reference lib="dom" />
|
||
|
|
|
||
|
|
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 };
|