Skip to content

Commit 0477000

Browse files
authored
Merge pull request #88 from Vexxo-Dev/develop
feat(worker): offload browser search to web worker
2 parents 4b57dc4 + c3d8b5b commit 0477000

15 files changed

Lines changed: 882 additions & 90 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Stateless, UI-agnostic Quran (Qur'an) search engine for Arabic text in pure Type
3737
- [Multi-word search](#multi-word-search)
3838
- [Caching with LRUCache](#caching-with-lrucache)
3939
- [Performance Optimization](#performance-optimization-advanced)
40+
- [Web Worker Offloading](#web-worker-offloading-browser)
4041
- [Core types](#core-types)
4142
- [Non-goals](#non-goals)
4243
- [Example apps](#example-apps)
@@ -1226,10 +1227,12 @@ src/
12261227
│ ├── search.test.ts # Search algorithm, inverted index, LRU cache, and Fuse tests
12271228
│ ├── lru-cache.test.ts # LRU cache tests
12281229
│ └── tokenization.test.ts # Token matching tests
1229-
└── utils/
1230-
├── loader.test.ts # Data loading tests
1231-
├── normalization.test.ts # Text processing tests
1232-
└── highlight.ts # Highlighting utilities
1230+
├── utils/
1231+
│ ├── loader.test.ts # Data loading tests
1232+
│ ├── normalization.test.ts # Text processing tests
1233+
│ └── highlight.ts # Highlighting utilities
1234+
└── worker/
1235+
└── searchWorkerClient.test.ts # Worker client + fallback tests
12331236
```
12341237

12351238
## Development

eslint.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import js from '@eslint/js';
22
import tseslint from 'typescript-eslint';
33
import prettier from 'eslint-plugin-prettier/recommended';
44
import globals from 'globals';
5+
import { fileURLToPath } from 'node:url';
6+
import path from 'node:path';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
59

610
export default tseslint.config(
711
{
@@ -24,6 +28,9 @@ export default tseslint.config(
2428
languageOptions: {
2529
ecmaVersion: 2020,
2630
sourceType: 'module',
31+
parserOptions: {
32+
tsconfigRootDir: __dirname,
33+
},
2734
globals: {
2835
...globals.node,
2936
...globals.jest,

examples/angular/src/app/app.component.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import {
88
loadWordMap,
99
search,
1010
LRUCache,
11+
createSearchWorker,
12+
supportsWorkers,
1113
type AdvancedSearchOptions,
1214
type MatchType,
1315
type MorphologyAya,
1416
type QuranText,
1517
type ScoredVerse,
1618
type SearchResponse,
19+
type SearchWorkerClient,
1720
type WordMap,
1821
} from 'quran-search-engine';
1922

@@ -375,21 +378,37 @@ export class AppComponent implements OnInit, OnDestroy {
375378

376379
private debounceHandle: number | null = null;
377380
private searchCache = new LRUCache<string, SearchResponse<QuranText>>(50);
381+
private workerClient: SearchWorkerClient | null = null;
378382

379383
async ngOnInit(): Promise<void> {
380384
this.loadState = 'loading';
381385
this.errorMessage = '';
382386

383387
try {
384-
const [quranData, morphologyMap, wordMap] = await Promise.all([
385-
loadQuranData(),
386-
loadMorphology(),
387-
loadWordMap(),
388-
]);
389-
390-
this.quranData = quranData;
391-
this.morphologyMap = morphologyMap;
392-
this.wordMap = wordMap;
388+
if (supportsWorkers()) {
389+
try {
390+
const client = createSearchWorker({
391+
workerUrl: new URL('quran-search-engine/worker', import.meta.url),
392+
});
393+
await client.initData();
394+
this.workerClient = client;
395+
} catch (err) {
396+
console.warn('Web Worker init failed, falling back to main thread:', err);
397+
}
398+
}
399+
400+
if (!this.workerClient) {
401+
const [quranData, morphologyMap, wordMap] = await Promise.all([
402+
loadQuranData(),
403+
loadMorphology(),
404+
loadWordMap(),
405+
]);
406+
407+
this.quranData = quranData;
408+
this.morphologyMap = morphologyMap;
409+
this.wordMap = wordMap;
410+
}
411+
393412
this.loadState = 'ready';
394413
} catch (err: unknown) {
395414
this.loadState = 'error';
@@ -401,6 +420,7 @@ export class AppComponent implements OnInit, OnDestroy {
401420
if (this.debounceHandle !== null) {
402421
window.clearTimeout(this.debounceHandle);
403422
}
423+
this.workerClient?.terminate();
404424
}
405425

406426
onQueryChange(): void {
@@ -414,8 +434,6 @@ export class AppComponent implements OnInit, OnDestroy {
414434
}
415435

416436
runSearch(resetPage: boolean): void {
417-
if (!this.quranData || !this.morphologyMap || !this.wordMap) return;
418-
419437
const trimmed = this.query.trim();
420438
if (!trimmed) {
421439
this.response = null;
@@ -429,22 +447,34 @@ export class AppComponent implements OnInit, OnDestroy {
429447
lemma: this.options.lemma,
430448
root: this.options.root,
431449
fuzzy: this.options.fuzzy,
432-
//+
433450
suraId: this.options.suraId,
434451
juzId: this.options.juzId,
435452
suraName: this.options.suraName,
436453
semantic: this.options.semantic,
437454
};
438455

456+
if (this.workerClient) {
457+
this.workerClient
458+
.runSearch(trimmed, searchOptions, { page: this.page, limit: this.limit })
459+
.then((res) => {
460+
this.response = res;
461+
this.rebuildHighlightCache();
462+
})
463+
.catch((err) => console.error('Worker search error:', err));
464+
return;
465+
}
466+
467+
if (!this.quranData || !this.morphologyMap || !this.wordMap) return;
468+
439469
this.response = search(
440470
trimmed,
441471
this.quranData,
442472
this.morphologyMap,
443473
this.wordMap,
444474
searchOptions,
445475
{ page: this.page, limit: this.limit },
446-
undefined, // preComputedFuseIndex
447-
this.searchCache, // LRU cache — identical queries return cached results
476+
undefined,
477+
this.searchCache,
448478
);
449479

450480
this.rebuildHighlightCache();

examples/vanilla-ts/src/main.ts

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import {
44
loadWordMap,
55
search,
66
LRUCache,
7+
createSearchWorker,
8+
supportsWorkers,
79
type QuranText,
810
type MorphologyAya,
911
type WordMap,
1012
type SearchResponse,
13+
type SearchWorkerClient,
1114
getHighlightRanges,
1215
} from 'quran-search-engine';
1316

@@ -17,6 +20,7 @@ class QuranSearchApp {
1720
private wordMap: WordMap | null = null;
1821
private loading = true;
1922
private cache = new LRUCache<string, SearchResponse>(50);
23+
private workerClient: SearchWorkerClient | null = null;
2024

2125
private searchInput: HTMLInputElement;
2226
private lemmaCheckbox: HTMLInputElement;
@@ -46,14 +50,29 @@ class QuranSearchApp {
4650
private async init() {
4751
try {
4852
this.showLoading();
49-
const [data, morphology, dictionary] = await Promise.all([
50-
loadQuranData(),
51-
loadMorphology(),
52-
loadWordMap(),
53-
]);
54-
this.quranData = data;
55-
this.morphologyMap = morphology;
56-
this.wordMap = dictionary;
53+
54+
if (supportsWorkers()) {
55+
try {
56+
const client = createSearchWorker({
57+
workerUrl: new URL('quran-search-engine/worker', import.meta.url),
58+
});
59+
await client.initData();
60+
this.workerClient = client;
61+
} catch (err) {
62+
console.warn('Web Worker init failed, falling back to main thread:', err);
63+
}
64+
}
65+
66+
if (!this.workerClient) {
67+
const [data, morphology, dictionary] = await Promise.all([
68+
loadQuranData(),
69+
loadMorphology(),
70+
loadWordMap(),
71+
]);
72+
this.quranData = data;
73+
this.morphologyMap = morphology;
74+
this.wordMap = dictionary;
75+
}
5776
} catch (error) {
5877
console.error('Failed to load Quran data:', error);
5978
this.showError('Failed to load Quran data');
@@ -79,7 +98,7 @@ class QuranSearchApp {
7998
}) as T;
8099
}
81100

82-
private handleSearch() {
101+
private async handleSearch() {
83102
const query = this.searchInput.value.trim();
84103
if (!query || this.loading) {
85104
this.resultsDiv.innerHTML = '';
@@ -91,23 +110,28 @@ class QuranSearchApp {
91110
root: this.rootCheckbox.checked,
92111
fuzzy: this.fuzzyCheckbox.checked,
93112
semantic: this.semanticCheckbox.checked,
94-
//new
95113
suraId: this.suraIdInput.value ? parseInt(this.suraIdInput.value) : undefined,
96114
juzId: this.juzIdInput.value ? parseInt(this.juzIdInput.value) : undefined,
97115
suraName: this.suraNameInput.value || undefined,
98116
};
99117

100118
try {
101-
const response = search(
102-
query,
103-
this.quranData,
104-
this.morphologyMap!,
105-
this.wordMap!,
106-
options,
107-
{ page: 1, limit: 20 },
108-
undefined, // preComputedFuseIndex
109-
this.cache, // LRU cache
110-
);
119+
let response: SearchResponse;
120+
121+
if (this.workerClient) {
122+
response = await this.workerClient.runSearch(query, options, { page: 1, limit: 20 });
123+
} else {
124+
response = search(
125+
query,
126+
this.quranData,
127+
this.morphologyMap!,
128+
this.wordMap!,
129+
options,
130+
{ page: 1, limit: 20 },
131+
undefined,
132+
this.cache,
133+
);
134+
}
111135

112136
this.renderResults(response);
113137
} catch (error) {

0 commit comments

Comments
 (0)