albex 0.1.0 → 0.6.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 (72) hide show
  1. package/CHANGELOG.md +416 -0
  2. package/README.md +244 -112
  3. package/dist/albex-worker.d.ts +70 -0
  4. package/dist/albex-worker.d.ts.map +1 -0
  5. package/dist/albex-worker.js +153 -0
  6. package/dist/albex-worker.js.map +1 -0
  7. package/dist/albex.d.ts +508 -6
  8. package/dist/albex.d.ts.map +1 -1
  9. package/dist/albex.js +1911 -141
  10. package/dist/albex.js.map +1 -1
  11. package/dist/errors.d.ts +52 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +66 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/gpu/bloom-runtime.d.ts +60 -0
  16. package/dist/gpu/bloom-runtime.d.ts.map +1 -0
  17. package/dist/gpu/bloom-runtime.js +176 -0
  18. package/dist/gpu/bloom-runtime.js.map +1 -0
  19. package/dist/gpu/bloom-shader.wgsl.d.ts +19 -0
  20. package/dist/gpu/bloom-shader.wgsl.d.ts.map +1 -0
  21. package/dist/gpu/bloom-shader.wgsl.js +49 -0
  22. package/dist/gpu/bloom-shader.wgsl.js.map +1 -0
  23. package/dist/persistence.d.ts +21 -0
  24. package/dist/persistence.d.ts.map +1 -0
  25. package/dist/persistence.js +174 -0
  26. package/dist/persistence.js.map +1 -0
  27. package/dist/pool/coordinator.d.ts +98 -0
  28. package/dist/pool/coordinator.d.ts.map +1 -0
  29. package/dist/pool/coordinator.js +247 -0
  30. package/dist/pool/coordinator.js.map +1 -0
  31. package/dist/profile.d.ts +100 -0
  32. package/dist/profile.d.ts.map +1 -0
  33. package/dist/profile.js +200 -0
  34. package/dist/profile.js.map +1 -0
  35. package/dist/resource-manager.d.ts +56 -0
  36. package/dist/resource-manager.d.ts.map +1 -0
  37. package/dist/resource-manager.js +138 -0
  38. package/dist/resource-manager.js.map +1 -0
  39. package/dist/tiered-store.d.ts +98 -0
  40. package/dist/tiered-store.d.ts.map +1 -0
  41. package/dist/tiered-store.js +238 -0
  42. package/dist/tiered-store.js.map +1 -0
  43. package/dist/wasm-bindings.d.ts +180 -0
  44. package/dist/wasm-bindings.d.ts.map +1 -0
  45. package/dist/wasm-bindings.js +128 -0
  46. package/dist/wasm-bindings.js.map +1 -0
  47. package/dist/worker-protocol.d.ts +86 -0
  48. package/dist/worker-protocol.d.ts.map +1 -0
  49. package/dist/worker-protocol.js +20 -0
  50. package/dist/worker-protocol.js.map +1 -0
  51. package/dist/worker-runtime.d.ts +14 -0
  52. package/dist/worker-runtime.d.ts.map +1 -0
  53. package/dist/worker-runtime.js +109 -0
  54. package/dist/worker-runtime.js.map +1 -0
  55. package/package.json +60 -13
  56. package/src/albex-worker.ts +187 -0
  57. package/src/albex.ts +2136 -189
  58. package/src/errors.ts +76 -0
  59. package/src/gpu/bloom-runtime.ts +229 -0
  60. package/src/gpu/bloom-shader.wgsl.ts +48 -0
  61. package/src/persistence.ts +175 -0
  62. package/src/pool/coordinator.ts +324 -0
  63. package/src/profile.ts +280 -0
  64. package/src/resource-manager.ts +167 -0
  65. package/src/tiered-store.ts +259 -0
  66. package/src/wasm-bindings.ts +349 -0
  67. package/src/worker-protocol.ts +48 -0
  68. package/src/worker-runtime.ts +106 -0
  69. package/wasm/pkg/albex_pdf.wasm +0 -0
  70. package/wasm/pkg/albex_wasm.wasm +0 -0
  71. package/wasm/pkg/albex_wasm_bg.wasm +0 -0
  72. package/wasm/pkg/albex_wasm_simd.wasm +0 -0
@@ -0,0 +1,20 @@
1
+ /*!
2
+ * albex v0.6.0
3
+ * Zero-config local full-text search for documents — runs entirely in the browser, no server, no upload.
4
+ * (c) 2026 RafaCalRob
5
+ * @license MIT
6
+ * https://github.com/RafaCalRob/Albex#readme
7
+ */
8
+ /**
9
+ * Wire protocol between the main thread and the AlbexEngineWorker runtime.
10
+ *
11
+ * One request/response pair per call, identified by `id`. The runtime is
12
+ * single-threaded so we serialise requests on the main side (one in-flight
13
+ * call at a time per worker) — keeps the protocol trivial and matches the
14
+ * actual constraint of `static mut` WASM state.
15
+ *
16
+ * `Transferable` is opt-in per op; we use it for `indexFile` to avoid
17
+ * copying the file bytes into the worker.
18
+ */
19
+ export {};
20
+ //# sourceMappingURL=worker-protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-protocol.js","sourceRoot":"","sources":["../src/worker-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Albex worker runtime.
3
+ *
4
+ * Loads inside a Web Worker, instantiates an `AlbexEngine`, and serves the
5
+ * wire protocol from `worker-protocol.ts`. Designed to be referenced as:
6
+ *
7
+ * new Worker(new URL('./worker-runtime.js', import.meta.url),
8
+ * { type: 'module' });
9
+ *
10
+ * The runtime is intentionally side-effectful (registers `onmessage` at
11
+ * import time). It is not meant to be imported from the main thread.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=worker-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-runtime.d.ts","sourceRoot":"","sources":["../src/worker-runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,109 @@
1
+ /*!
2
+ * albex v0.6.0
3
+ * Zero-config local full-text search for documents — runs entirely in the browser, no server, no upload.
4
+ * (c) 2026 RafaCalRob
5
+ * @license MIT
6
+ * https://github.com/RafaCalRob/Albex#readme
7
+ */
8
+ /**
9
+ * Albex worker runtime.
10
+ *
11
+ * Loads inside a Web Worker, instantiates an `AlbexEngine`, and serves the
12
+ * wire protocol from `worker-protocol.ts`. Designed to be referenced as:
13
+ *
14
+ * new Worker(new URL('./worker-runtime.js', import.meta.url),
15
+ * { type: 'module' });
16
+ *
17
+ * The runtime is intentionally side-effectful (registers `onmessage` at
18
+ * import time). It is not meant to be imported from the main thread.
19
+ */
20
+ import { AlbexEngine, AlbexError } from './albex.js';
21
+ let engine = null;
22
+ function ensureEngine() {
23
+ if (!engine)
24
+ throw new Error('Worker runtime: init() not called yet');
25
+ return engine;
26
+ }
27
+ async function dispatch(op) {
28
+ switch (op.kind) {
29
+ case 'init': {
30
+ engine = new AlbexEngine(op.opts);
31
+ await engine.init();
32
+ return undefined;
33
+ }
34
+ case 'indexFile': {
35
+ // Wrap the transferred buffer in a File-like object so existing
36
+ // indexers work unchanged.
37
+ const file = new File([op.buffer], op.name);
38
+ return ensureEngine().indexFile(file);
39
+ }
40
+ case 'search':
41
+ return ensureEngine().search(op.query, op.options);
42
+ case 'removeDocument':
43
+ return ensureEngine().removeDocument(op.id);
44
+ case 'compact':
45
+ ensureEngine().compact();
46
+ return undefined;
47
+ case 'reset':
48
+ ensureEngine().reset();
49
+ return undefined;
50
+ case 'getStats':
51
+ return ensureEngine().getStats();
52
+ case 'getLastSearchStats':
53
+ return ensureEngine().getLastSearchStats();
54
+ case 'getDocuments':
55
+ return ensureEngine().documents.slice();
56
+ case 'setMaxErrors':
57
+ ensureEngine().setMaxErrors(op.n);
58
+ return undefined;
59
+ case 'setThreshold':
60
+ ensureEngine().setThreshold(op.n);
61
+ return undefined;
62
+ case 'setMaxResults':
63
+ ensureEngine().setMaxResults(op.n);
64
+ return undefined;
65
+ case 'setLanguage':
66
+ ensureEngine().setLanguage(op.lang);
67
+ return undefined;
68
+ case 'save':
69
+ return ensureEngine().save(op.name);
70
+ case 'load':
71
+ return ensureEngine().load(op.name);
72
+ case 'loadOrInit':
73
+ return ensureEngine().loadOrInit(op.name);
74
+ case 'deleteSnapshot':
75
+ return ensureEngine().deleteSnapshot(op.name);
76
+ case 'listSnapshots':
77
+ return ensureEngine().listSnapshots();
78
+ }
79
+ }
80
+ async function handle(req) {
81
+ const { id, op } = req;
82
+ try {
83
+ const result = await dispatch(op);
84
+ const res = { id, ok: true, result };
85
+ self.postMessage(res);
86
+ }
87
+ catch (err) {
88
+ const e = err;
89
+ const res = {
90
+ id, ok: false,
91
+ error: {
92
+ name: e.name ?? 'Error',
93
+ kind: err instanceof AlbexError ? err.kind : undefined,
94
+ message: e.message ?? String(err),
95
+ },
96
+ };
97
+ self.postMessage(res);
98
+ }
99
+ }
100
+ // Process messages strictly in arrival order. The engine guards its own
101
+ // state, but a sync `search` arriving mid-`indexFile` await would otherwise
102
+ // be rejected as "busy"; queueing keeps the worker's externally-observable
103
+ // behaviour serial and matches the main-thread engine's serialization.
104
+ let _queue = Promise.resolve();
105
+ self.onmessage = (ev) => {
106
+ const req = ev.data;
107
+ _queue = _queue.then(() => handle(req));
108
+ };
109
+ //# sourceMappingURL=worker-runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-runtime.js","sourceRoot":"","sources":["../src/worker-runtime.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "albex",
3
- "version": "0.1.0",
4
- "description": "Local full-text search enginedocuments never leave the browser",
3
+ "version": "0.6.0",
4
+ "description": "Zero-config local full-text search for documents runs entirely in the browser, no server, no upload.",
5
5
  "type": "module",
6
6
  "main": "./dist/albex.js",
7
7
  "module": "./dist/albex.js",
@@ -10,21 +10,53 @@
10
10
  ".": {
11
11
  "import": "./dist/albex.js",
12
12
  "types": "./dist/albex.d.ts"
13
+ },
14
+ "./worker": {
15
+ "import": "./dist/albex-worker.js",
16
+ "types": "./dist/albex-worker.d.ts"
17
+ },
18
+ "./worker-runtime": {
19
+ "import": "./dist/worker-runtime.js",
20
+ "types": "./dist/worker-runtime.d.ts"
21
+ },
22
+ "./pool": {
23
+ "import": "./dist/pool/coordinator.js",
24
+ "types": "./dist/pool/coordinator.d.ts"
25
+ },
26
+ "./gpu": {
27
+ "import": "./dist/gpu/bloom-runtime.js",
28
+ "types": "./dist/gpu/bloom-runtime.d.ts"
29
+ },
30
+ "./tiered": {
31
+ "import": "./dist/tiered-store.js",
32
+ "types": "./dist/tiered-store.d.ts"
13
33
  }
14
34
  },
15
35
  "files": [
16
36
  "dist/",
17
37
  "src/",
18
- "wasm/pkg/albex_wasm_bg.wasm",
19
- "wasm/pkg/albex_pdf.wasm"
38
+ "wasm/pkg/*.wasm",
39
+ "README.md",
40
+ "CHANGELOG.md",
41
+ "LICENSE"
20
42
  ],
21
43
  "scripts": {
22
- "build": "tsc",
23
- "prepublishOnly": "npm run build",
24
- "build:wasm": "cd wasm && cargo build --target wasm32-unknown-unknown --release && cp ../target/wasm32-unknown-unknown/release/albex_wasm.wasm pkg/albex_wasm_bg.wasm",
25
- "build:pdf-wasm": "cd pdf-wasm && cargo build --target wasm32-unknown-unknown --release && cp ../target/wasm32-unknown-unknown/release/albex_pdf.wasm ../wasm/pkg/albex_pdf.wasm",
44
+ "build": "tsc && node scripts/banner.mjs",
45
+ "prepublishOnly": "npm run build:all && npm test",
46
+ "build:wasm": "node scripts/build-wasm.mjs && node scripts/wasm-banner.mjs",
47
+ "build:wasm:baseline": "node scripts/build-wasm.mjs --no-simd && node scripts/wasm-banner.mjs",
48
+ "build:pdf-wasm": "node scripts/build-pdf-wasm.mjs && node scripts/wasm-banner.mjs",
26
49
  "build:all": "npm run build:wasm && npm run build:pdf-wasm && npm run build",
27
- "typecheck": "tsc --noEmit"
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "bench": "vitest bench --run",
54
+ "test:e2e": "playwright test",
55
+ "build:ocr": "npm --prefix packages/ocr run build",
56
+ "clean": "node scripts/clean.mjs",
57
+ "clean:all": "node scripts/clean.mjs --all",
58
+ "serve": "npx --yes serve@14 -p 5173 -L .",
59
+ "relaunch": "npm run clean && npm run build:all && npm run build:ocr && npm test && npm pack && node scripts/relaunch-banner.mjs && npm run serve"
28
60
  },
29
61
  "repository": {
30
62
  "type": "git",
@@ -38,13 +70,26 @@
38
70
  "search",
39
71
  "full-text",
40
72
  "wasm",
73
+ "webassembly",
41
74
  "browser",
75
+ "client-side",
42
76
  "offline",
77
+ "privacy",
78
+ "fuzzy",
79
+ "bitap",
80
+ "bloom-filter",
43
81
  "docx",
44
- "pdf",
45
82
  "xlsx",
46
- "fuzzy",
47
- "privacy"
83
+ "pdf",
84
+ "markdown",
85
+ "html",
86
+ "json",
87
+ "csv",
88
+ "eml",
89
+ "rtf",
90
+ "zero-config",
91
+ "opfs",
92
+ "webgpu"
48
93
  ],
49
94
  "author": "RafaCalRob",
50
95
  "license": "MIT",
@@ -52,6 +97,8 @@
52
97
  "node": ">=18"
53
98
  },
54
99
  "devDependencies": {
55
- "typescript": "^5.4.0"
100
+ "@playwright/test": "^1.60.0",
101
+ "typescript": "^5.4.0",
102
+ "vitest": "^2.0.0"
56
103
  }
57
104
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `AlbexEngineWorker` — a main-thread wrapper that runs the engine inside a
3
+ * Web Worker. Mirrors the surface of `AlbexEngine` so it can be swapped in
4
+ * without code changes.
5
+ *
6
+ * Usage:
7
+ *
8
+ * const engine = new AlbexEngineWorker({
9
+ * wasmUrl: '/assets/albex_wasm_bg.wasm',
10
+ * pdfWasmUrl: '/assets/albex_pdf.wasm',
11
+ * // Provide the URL to the bundled worker runtime.
12
+ * workerUrl: new URL('./worker-runtime.js', import.meta.url),
13
+ * });
14
+ * await engine.init();
15
+ *
16
+ * Why: a `search()` over 100k chunks can take 10–50 ms. On main thread that
17
+ * is visible jank for every keystroke. Off-main-thread keeps the UI at 60 fps.
18
+ *
19
+ * The runtime is single-threaded WASM, so requests are serialised: only one
20
+ * call is in flight at a time. This matches the actual `static mut` model
21
+ * inside the .wasm and is fine for an interactive search UI (each keystroke
22
+ * replaces the previous query).
23
+ */
24
+
25
+ import type {
26
+ AlbexOptions,
27
+ IndexedDocument,
28
+ SearchOptions,
29
+ SearchResult,
30
+ EngineStats,
31
+ SearchStats,
32
+ } from './albex.js';
33
+ import type {
34
+ WorkerRequest,
35
+ WorkerResponse,
36
+ WorkerOp,
37
+ } from './worker-protocol.js';
38
+ import {
39
+ AlbexError,
40
+ AlbexInitError,
41
+ AlbexUnsupportedFormatError,
42
+ AlbexParseError,
43
+ AlbexCapacityError,
44
+ } from './errors.js';
45
+
46
+ export interface AlbexWorkerOptions extends AlbexOptions {
47
+ /** URL to the bundled worker runtime script (worker-runtime.js). */
48
+ workerUrl: string | URL;
49
+ }
50
+
51
+ interface Pending {
52
+ resolve: (v: unknown) => void;
53
+ reject: (e: unknown) => void;
54
+ }
55
+
56
+ let _workerSearchStreamWarned = false;
57
+
58
+ export class AlbexEngineWorker {
59
+ private readonly _opts: AlbexWorkerOptions;
60
+ private _worker!: Worker;
61
+ private _nextId = 1;
62
+ private _pending = new Map<number, Pending>();
63
+ private _docsCache: IndexedDocument[] = [];
64
+
65
+ constructor(opts: AlbexWorkerOptions) {
66
+ this._opts = opts;
67
+ }
68
+
69
+ async init(): Promise<void> {
70
+ this._worker = new Worker(this._opts.workerUrl, { type: 'module' });
71
+ this._worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
72
+ const { id } = ev.data;
73
+ const p = this._pending.get(id);
74
+ if (!p) return;
75
+ this._pending.delete(id);
76
+ if (ev.data.ok) p.resolve(ev.data.result);
77
+ else p.reject(rehydrateError(ev.data.error));
78
+ };
79
+ this._worker.onerror = (e) => {
80
+ // Surface the error to every in-flight call.
81
+ const err = new AlbexInitError(`Worker crashed: ${e.message}`);
82
+ for (const [, p] of this._pending) p.reject(err);
83
+ this._pending.clear();
84
+ };
85
+ await this._send({ kind: 'init', opts: {
86
+ wasmUrl: this._opts.wasmUrl,
87
+ pdfWasmUrl: this._opts.pdfWasmUrl,
88
+ } });
89
+ }
90
+
91
+ private _send<T = unknown>(op: WorkerOp, transfer: Transferable[] = []): Promise<T> {
92
+ const id = this._nextId++;
93
+ const req: WorkerRequest = { id, op };
94
+ return new Promise<T>((resolve, reject) => {
95
+ this._pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
96
+ this._worker.postMessage(req, transfer);
97
+ });
98
+ }
99
+
100
+ async indexFile(file: File): Promise<IndexedDocument> {
101
+ const buffer = await file.arrayBuffer();
102
+ // Transfer the buffer to avoid a copy.
103
+ const doc = await this._send<IndexedDocument>(
104
+ { kind: 'indexFile', name: file.name, buffer },
105
+ [buffer],
106
+ );
107
+ this._docsCache.push(doc);
108
+ return doc;
109
+ }
110
+
111
+ search(query: string, opts: SearchOptions = {}): Promise<SearchResult[]> {
112
+ return this._send<SearchResult[]>({ kind: 'search', query, options: opts });
113
+ }
114
+
115
+ /**
116
+ * Cooperative variant of `search`. Today the wire still sends a single
117
+ * batch — the result array is fetched in one round-trip from the worker
118
+ * and then exposed as an async iterator so callers can `break` early.
119
+ * A future iteration may use a `MessagePort` to stream individual results
120
+ * from the worker side; the iterator shape is preserved across that
121
+ * transition.
122
+ */
123
+ async *searchCooperative(query: string, opts: SearchOptions = {}): AsyncIterable<SearchResult> {
124
+ const results = await this.search(query, opts);
125
+ for (const r of results) yield r;
126
+ }
127
+
128
+ /**
129
+ * @deprecated Renamed to `searchCooperative` in 0.3.0. Alias removed in 0.4.0.
130
+ */
131
+ async *searchStream(query: string, opts: SearchOptions = {}): AsyncIterable<SearchResult> {
132
+ if (!_workerSearchStreamWarned) {
133
+ _workerSearchStreamWarned = true;
134
+ console.warn('[albex] `AlbexEngineWorker.searchStream` is deprecated; rename to `searchCooperative`. Alias removed in 0.4.0.');
135
+ }
136
+ yield* this.searchCooperative(query, opts);
137
+ }
138
+
139
+ async removeDocument(id: string): Promise<boolean> {
140
+ const ok = await this._send<boolean>({ kind: 'removeDocument', id });
141
+ if (ok) this._docsCache = this._docsCache.filter(d => d.name !== id && d.contentHash !== id);
142
+ return ok;
143
+ }
144
+
145
+ async compact(): Promise<void> { await this._send({ kind: 'compact' }); }
146
+ async reset(): Promise<void> {
147
+ await this._send({ kind: 'reset' });
148
+ this._docsCache = [];
149
+ }
150
+
151
+ getStats(): Promise<EngineStats> { return this._send({ kind: 'getStats' }); }
152
+ getLastSearchStats(): Promise<SearchStats | null> { return this._send({ kind: 'getLastSearchStats' }); }
153
+ getDocuments(): Promise<readonly IndexedDocument[]> { return this._send({ kind: 'getDocuments' }); }
154
+
155
+ async setMaxErrors(n: 0 | 1 | 2 | 3): Promise<void> { await this._send({ kind: 'setMaxErrors', n }); }
156
+ async setThreshold(n: number): Promise<void> { await this._send({ kind: 'setThreshold', n }); }
157
+ async setMaxResults(n: number): Promise<void> { await this._send({ kind: 'setMaxResults', n }); }
158
+ async setLanguage(lang: 'off' | 'es'): Promise<void> { await this._send({ kind: 'setLanguage', lang }); }
159
+
160
+ // Persistence — mirror of AlbexEngine.
161
+ async save(name: string): Promise<void> { await this._send({ kind: 'save', name }); }
162
+ async load(name: string): Promise<boolean> { return this._send({ kind: 'load', name }); }
163
+ async loadOrInit(name: string): Promise<boolean> { return this._send({ kind: 'loadOrInit', name }); }
164
+ async deleteSnapshot(name: string): Promise<void> { await this._send({ kind: 'deleteSnapshot', name }); }
165
+ async listSnapshots(): Promise<string[]>{ return this._send({ kind: 'listSnapshots' }); }
166
+
167
+ [Symbol.dispose](): void {
168
+ for (const [, p] of this._pending) p.reject(new AlbexError('disposed', 'Engine disposed'));
169
+ this._pending.clear();
170
+ this._worker?.terminate();
171
+ this._docsCache = [];
172
+ }
173
+ }
174
+
175
+ function rehydrateError(e: { name: string; kind?: string; message: string }): Error {
176
+ switch (e.kind) {
177
+ case 'init': return new AlbexInitError(e.message);
178
+ case 'unsupported_format': return new AlbexUnsupportedFormatError(e.message.replace(/^Unsupported format: \./, ''));
179
+ case 'parse': return new AlbexParseError('unknown', e.message);
180
+ case 'capacity': return new AlbexCapacityError(e.message);
181
+ default: {
182
+ const err = new Error(e.message);
183
+ err.name = e.name;
184
+ return err;
185
+ }
186
+ }
187
+ }