diff --git a/lib/workerClient.ts b/lib/workerClient.ts new file mode 100644 index 0000000..d55edb9 --- /dev/null +++ b/lib/workerClient.ts @@ -0,0 +1,207 @@ +export type { + WorkerTask, + WorkerResponse, +} from '@/lib/workers/dataWorker'; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + timer: ReturnType; +}; + +const IDLE_TIMEOUT = 30_000; + +let workerInstance: Worker | null = null; +let requestId = 0; +const pending = new Map(); +let idleTimer: ReturnType | null = null; + +function clearIdleTimer() { + if (idleTimer !== null) { + clearTimeout(idleTimer); + idleTimer = null; + } +} + +function setIdleTimer() { + clearIdleTimer(); + if (pending.size === 0) { + idleTimer = setTimeout(() => { + terminateWorker(); + }, IDLE_TIMEOUT); + } +} + +function getWorker(): Worker | null { + if (typeof Worker === 'undefined') return null; + if (workerInstance) return workerInstance; + try { + workerInstance = new Worker( + new URL('@/lib/workers/dataWorker', import.meta.url), + { type: 'module' } + ); + workerInstance.onmessage = (e: MessageEvent) => { + const { id, response } = e.data as { id?: number; response: unknown }; + if (id !== undefined) { + const req = pending.get(id); + if (req) { + clearIdleTimer(); + pending.delete(id); + req.resolve(response); + setIdleTimer(); + } + } + }; + workerInstance.onerror = (err) => { + for (const [id, req] of pending) { + clearIdleTimer(); + pending.delete(id); + req.reject(new Error(err.message || 'Worker error')); + } + }; + return workerInstance; + } catch { + workerInstance = null; + return null; + } +} + +function terminateWorker() { + if (workerInstance) { + workerInstance.terminate(); + workerInstance = null; + } +} + +function sendToWorker(task: import('@/lib/workers/dataWorker').WorkerTask): Promise { + return new Promise((resolve, reject) => { + const id = ++requestId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('Worker request timed out')); + }, 10_000); + + const worker = getWorker(); + + if (!worker) { + clearTimeout(timer); + fallbackExecute(task).then(resolve).catch(reject); + return; + } + + pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); + worker.postMessage({ id, ...task }); + }); +} + +function fallbackExecute(task: import('@/lib/workers/dataWorker').WorkerTask): Promise { + return new Promise((resolve, reject) => { + try { + let result: unknown; + switch (task.type) { + case 'sort': { + const sorted = [...task.data].sort((a: unknown, b: unknown) => { + const aVal = (a as Record)[task.key]; + const bVal = (b as Record)[task.key]; + if (aVal < bVal) return task.order === 'asc' ? -1 : 1; + if (aVal > bVal) return task.order === 'asc' ? 1 : -1; + return 0; + }); + result = sorted; + break; + } + case 'filter': { + const lower = task.query.toLowerCase(); + result = task.data.filter((item: unknown) => { + const record = item as Record; + return task.fields.some((field) => { + const val = record[field]; + return typeof val === 'string' && val.toLowerCase().includes(lower); + }); + }); + break; + } + case 'waveform': { + const count = Math.max(64, Math.min(4096, task.samples)); + if (task.peaks && task.peaks.length > 0) { + const out: number[] = []; + const step = Math.max(1, Math.floor(task.peaks.length / count)); + for (let i = 0; i < count; i++) { + const start = i * step; + const end = Math.min(start + step, task.peaks.length); + let sum = 0; + for (let j = start; j < end; j++) { + sum += Math.abs(task.peaks[j]); + } + out.push(end > start ? sum / (end - start) : 0); + } + result = out; + } else { + const out: number[] = []; + for (let i = 0; i < count; i++) { + const t = (i / count) * task.duration; + const val = + 0.3 + + 0.4 * Math.abs(Math.sin(t * 2 * Math.PI * 3)) + + 0.3 * Math.abs(Math.cos(t * 2 * Math.PI * 7)); + out.push(Math.min(1, val)); + } + result = out; + } + break; + } + case 'ping': + result = 'pong'; + break; + } + resolve(result as T); + } catch (err) { + reject(err); + } + }); +} + +export const workerClient = { + sort( + data: T[], + key: keyof T, + order?: 'asc' | 'desc' + ): Promise { + return sendToWorker({ + type: 'sort', + data: data as unknown[], + key: key as string, + order, + }); + }, + + filter( + data: T[], + query: string, + fields: (keyof T)[] + ): Promise { + return sendToWorker({ + type: 'filter', + data: data as unknown[], + query, + fields: fields as string[], + }); + }, + + computeWaveform( + samples: number, + duration: number, + peaks?: number[] + ): Promise { + return sendToWorker({ + type: 'waveform', + samples, + duration, + peaks, + }); + }, + + terminate(): void { + terminateWorker(); + }, +}; diff --git a/lib/workers/dataWorker.ts b/lib/workers/dataWorker.ts new file mode 100644 index 0000000..9792eaf --- /dev/null +++ b/lib/workers/dataWorker.ts @@ -0,0 +1,97 @@ +export type WorkerTask = + | { type: 'sort'; data: unknown[]; key: string; order?: 'asc' | 'desc' } + | { type: 'filter'; data: unknown[]; query: string; fields: string[] } + | { type: 'waveform'; samples: number; duration: number; peaks?: number[] } + | { type: 'ping' }; + +export type WorkerResponse = + | { type: 'sort'; result: unknown[] } + | { type: 'filter'; result: unknown[] } + | { type: 'waveform'; result: number[] } + | { type: 'pong' } + | { type: 'error'; message: string }; + +function sortTask(data: unknown[], key: string, order: 'asc' | 'desc'): unknown[] { + const sorted = [...data].sort((a: unknown, b: unknown) => { + const aVal = (a as Record)[key]; + const bVal = (b as Record)[key]; + if (aVal < bVal) return order === 'asc' ? -1 : 1; + if (aVal > bVal) return order === 'asc' ? 1 : -1; + return 0; + }); + return sorted; +} + +function filterTask(data: unknown[], query: string, fields: string[]): unknown[] { + const lower = query.toLowerCase(); + return data.filter((item: unknown) => { + const record = item as Record; + return fields.some((field) => { + const val = record[field]; + return typeof val === 'string' && val.toLowerCase().includes(lower); + }); + }); +} + +function waveformTask( + samples: number, + duration: number, + peaks?: number[] +): number[] { + const count = Math.max(64, Math.min(4096, samples)); + if (peaks && peaks.length > 0) { + const result: number[] = []; + const step = Math.max(1, Math.floor(peaks.length / count)); + for (let i = 0; i < count; i++) { + const start = i * step; + const end = Math.min(start + step, peaks.length); + let sum = 0; + for (let j = start; j < end; j++) { + sum += Math.abs(peaks[j]); + } + result.push(end > start ? sum / (end - start) : 0); + } + return result; + } + const result: number[] = []; + for (let i = 0; i < count; i++) { + const t = (i / count) * duration; + const synthetic = + 0.3 + + 0.4 * Math.abs(Math.sin(t * 2 * Math.PI * 3)) + + 0.3 * Math.abs(Math.cos(t * 2 * Math.PI * 7)); + result.push(Math.min(1, synthetic)); + } + return result; +} + +self.onmessage = (e: MessageEvent) => { + const task = e.data; + try { + switch (task.type) { + case 'sort': { + const result = sortTask(task.data, task.key, task.order ?? 'asc'); + self.postMessage({ type: 'sort', result } satisfies WorkerResponse); + break; + } + case 'filter': { + const result = filterTask(task.data, task.query, task.fields); + self.postMessage({ type: 'filter', result } satisfies WorkerResponse); + break; + } + case 'waveform': { + const result = waveformTask(task.samples, task.duration, task.peaks); + self.postMessage({ type: 'waveform', result } satisfies WorkerResponse); + break; + } + case 'ping': + self.postMessage({ type: 'pong' } satisfies WorkerResponse); + break; + } + } catch (err) { + self.postMessage({ + type: 'error', + message: err instanceof Error ? err.message : 'Unknown worker error', + } satisfies WorkerResponse); + } +}; diff --git a/next.config.ts b/next.config.ts index c460363..a1f3fac 100644 --- a/next.config.ts +++ b/next.config.ts @@ -25,6 +25,15 @@ const nextConfig: NextConfig = { ...config.resolve.alias, '@react-native-async-storage/async-storage': false, }; + + // Web Worker support (#111): output workers as separate static assets + // Workers are loaded via `new Worker(new URL('./worker', import.meta.url))` + // which webpack 5 handles natively. This config ensures they are placed + // in the output static directory for long-term caching. + if (config.output && !config.output.webassemblyModuleFilename) { + config.output.webassemblyModuleFilename = 'static/chunks/[id].[hash:8].wasm'; + } + return config; }, };