258 lines
8.8 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 13:21:13 +02:00
/**
* 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
*/
'use strict';
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
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', (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('fuse-worker.cjs', document.baseURI).href)));
}
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();
}
}
exports.FuseWorker = FuseWorker;