From f83479710c9b5feca5dcb2da8584dd4c71db416e Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Wed, 12 Aug 2026 23:06:43 -0700 Subject: [PATCH] Extract worker to separate file. --- src/async.ts | 2 +- src/lib/nano25519.ts | 303 +----------------------------------------- src/lib/worker.ts | 309 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 303 deletions(-) create mode 100644 src/lib/worker.ts diff --git a/src/async.ts b/src/async.ts index 1818565..d7eb044 100644 --- a/src/async.ts +++ b/src/async.ts @@ -1,7 +1,7 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { run } from './lib/nano25519' +import { run } from './lib/worker' /** * Asynchronous Nano public key derivation using WebAssembly. diff --git a/src/lib/nano25519.ts b/src/lib/nano25519.ts index ff2fc60..8fd0115 100644 --- a/src/lib/nano25519.ts +++ b/src/lib/nano25519.ts @@ -1,20 +1,9 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { MessagePort as NodeMessagePort, Worker as NodeWorker } from 'node:worker_threads' //@ts-expect-error import nano25519_wasm from '../../build/nano25519.wasm' -type Data = { - url: string - id: string - action: string - message?: string | ArrayBuffer - privateKey?: string - publicKey?: string - secretKey?: string - signature?: string -} type Exports = { exports: { derive: () => void, @@ -27,7 +16,7 @@ type Exports = { } } -const nano25519_init = (bytes: number[]): { derive: typeof derive, sign: typeof sign, verify: typeof verify } => { +export const nano25519_init = (bytes: number[]): { derive: typeof derive, sign: typeof sign, verify: typeof verify } => { const wasm: Uint8Array = Uint8Array.from(bytes) const module = new WebAssembly.Module(wasm) const { exports } = new WebAssembly.Instance(module, { @@ -217,293 +206,3 @@ const nano25519_init = (bytes: number[]): { derive: typeof derive, sign: typeof } export const nano25519 = nano25519_init(nano25519_wasm) - -const nano25519_worker_init = ({ derive, sign, verify }: typeof nano25519) => { - let isListening = false - let host: NodeMessagePort | null = null - let client: string | undefined = globalThis.location?.href - - /** - * Parses inbound data when nano25519 is started as a Web Worker. Only called - * by functions in `async` module. - * @param {object} message.data - Worker commands and related data - */ - function handleMessage (message: unknown): void { - NODE: if (host == null) return queueMicrotask(() => handleMessage(message)) - if (message == null - || typeof message !== 'object' - || !('data' in message) - || message.data == null - || typeof message.data !== 'object' - || !('url' in message.data) - || typeof message.data.url !== 'string' - || !('id' in message.data) - || typeof message.data.id !== 'string' - || !('action' in message.data) - || typeof message.data.action !== 'string' - ) return - let result: undefined | boolean | string | Uint8Array - let url: undefined | string - let id: undefined | string - try { - const data: Data = message.data as object & { url: string, id: string, action: string } - { ({ url, id } = data) } - if (url !== client) return - - if (data.action === 'start') { - isListening = true - result = 'started' - } else if (data.action === 'stop') { - isListening = false - result = 'stopped' - } else if (isListening) { - const { action } = data - if (action === 'derive') { - const { privateKey } = data - const publicKey = derive(privateKey) - if (publicKey == null) { - throw new TypeError('Invalid public key from WASM derive()') - } - result = publicKey - } else if (action === 'sign') { - const { message, secretKey } = data - const signature = sign(message, secretKey) - if (signature == null) { - throw new TypeError('Invalid signature from WASM sign()') - } - result = signature - } else if (action === 'verify') { - const { message, publicKey, signature } = data - const verification = verify(signature, message, publicKey) - if (verification == null) { - throw new TypeError('Invalid verification from WASM verify()') - } - result = verification - } - } - } catch (err: unknown) { - if (typeof err === 'object' && err != null) { - const { message } = err as { [k: string]: unknown } - if (typeof message === 'string' && message !== 'divide by zero') { - result = message - } - } else { - result = JSON.stringify(err) - } - } finally { - BROWSER: postMessage({ url, id, result }) - NODE: host?.postMessage({ data: { url, id, result } }) - } - } - BROWSER: addEventListener('message', handleMessage) - NODE: { - if (host == null) { - import('node:worker_threads') - .then(({ parentPort, threadId }): void => { - host = parentPort - client = threadId.toString() - host?.on('message', handleMessage) - }) - } - } -} - -const nano25519_worker = `;(${nano25519_worker_init})((${nano25519_init})([${nano25519_wasm}]));` - -/** - * Host code for asynchronous Web Worker - */ -let isWorkerReady: boolean = false -let isWorkerListening: boolean = false -let tasks: Map[0]>> = new Map() -let worker: Worker | NodeWorker -let url: string - -function isBytes (a: unknown): a is Uint8Array { - return a instanceof Uint8Array && a.buffer instanceof ArrayBuffer -} - -// Create worker module -function init (): void { - try { - BROWSER: { - url = URL.createObjectURL(new Blob([nano25519_worker], { type: 'text/javascript' })) - worker = new Worker(url, { type: 'module' }) - } - NODE: { - worker = new NodeWorker(nano25519_worker, { - eval: true, - stderr: false, - stdout: false - }) - url = worker.threadId.toString() - } - console.log(`nano25519 initialized.`) - isWorkerReady = true - } catch (err: any) { - isWorkerReady = false - throw new Error('nano25519 initialization failed') - } -} - -// Reconstruct worker when errors occur -function reset (): void { - console.warn(`nano25519 encountered an error. Reinitializing...`) - isWorkerReady = false - worker.terminate() - init() -} - -function onresult (msg: { data: Record }): void { - const { data } = msg - if (data.url !== url) return - if (!('id' in data) || typeof data.id !== 'string') return - - const executor = tasks.get(data.id) - if (executor == null) return - const [ok, err] = executor - - const { result } = data - console.log('received result from worker') - if (typeof result !== 'boolean' && typeof result !== 'string' && !isBytes(result)) { - err('Invalid return type') - } - ok(result) -} - -// Check that the worker is running and listening before sending messages -async function start (): Promise { - if (!isWorkerReady) init() - if (!isWorkerListening) { - return new Promise(async (resolve, reject): Promise => { - const onstarted = (msg: { data: Record }): void => { - if (msg.data.url !== url) return - const { result } = msg.data - if (result === 'started') { - console.log('worker started successfully') - BROWSER: { (worker as Worker).onmessage = onresult } - NODE: { (worker as NodeWorker).on('message', onresult) } - isWorkerListening = true - resolve() - } else { - isWorkerListening = false - reject() - } - } - console.log(`starting worker`) - const id = crypto.randomUUID() - const data = { url, id, action: 'start' } - BROWSER: { - worker = worker as Worker - worker.onerror = reject - worker.onmessage = onstarted - worker.postMessage(data) - } - NODE: { - worker = worker as unknown as NodeWorker - worker.on('error', reject) - worker.on('message', onstarted) - worker.postMessage({ data }) - } - }) - } -} - -// Send command and relevant data to nano25519 worker -async function dispatch (data: Record>): Promise { - const id = crypto.randomUUID() - const transfer: ArrayBuffer[] = [] - for (let k of Object.keys(data)) { - if (isBytes(data[k])) { - data[k] = data[k].buffer.slice() - transfer.push(data[k]) - } - } - console.log('sending data to worker') - data.url = url - data.id = id - return new Promise((resolve, reject) => { - tasks.set(id, [resolve, reject]) - console.log('tasks', tasks) - BROWSER: { (worker as Worker).postMessage(data, transfer) } - NODE: { (worker as NodeWorker).postMessage({ data }, transfer) } - }) -} - -// Request that the worker stop listening without terminating -async function stop (): Promise { - return new Promise((resolve, reject): void => { - const onstop = (msg: { data: Record }): void => { - const { data } = msg - if (data.url !== url) return - const { result } = data - if (result === 'stopped') { - console.log('worker stopped successfully') - isWorkerListening = false - resolve() - } else { - reject(result) - } - } - console.log(`stopping worker`) - const id = crypto.randomUUID() - BROWSER: { - worker = worker as Worker - worker.onerror = reject - worker.onmessage = onstop - worker.postMessage({ url, id, action: 'stop' }) - } - NODE: { - worker = worker as unknown as NodeWorker - worker.on('error', reject) - worker.on('message', onstop) - worker.postMessage({ data: { url, id, action: 'stop' } }) - } - }) -} - -export async function run (data: Record<'action', 'derive'> & Record>): Promise> -export async function run (data: Record<'action', 'sign'> & Record>): Promise> -export async function run (data: Record<'action', 'verify'> & Record>): Promise -export async function run (data: Record<'action', 'derive' | 'sign' | 'verify'> & Record>): Promise> { - try { - await start() - } catch (err: any) { - throw new Error('Error initializing worker') - } - try { - const result = await dispatch(data) - switch (data.action) { - case 'derive': { - if ((isBytes(result) && result.byteLength === 32) || (typeof result === 'string' && /^[0-9a-f]{64}$/i.test(result))) { - return result - } else { - throw new Error('derive result invalid') - } - } - case 'sign': { - if ((isBytes(result) && result.byteLength === 64) || (typeof result === 'string' && /^[0-9a-f]{128}$/i.test(result))) { - return result - } else { - throw new Error('sign result invalid') - } - } - case 'verify': { - if (typeof result === 'boolean') { - return result - } else { - throw new Error('verify result invalid') - } - } - } - } catch (err: any) { - try { - await stop() - } catch (e: any) { - console.error('failed to stop worker') - reset() - } finally { - throw new Error('Error dispatching async request') - } - } -} diff --git a/src/lib/worker.ts b/src/lib/worker.ts new file mode 100644 index 0000000..dea84e4 --- /dev/null +++ b/src/lib/worker.ts @@ -0,0 +1,309 @@ +//! SPDX-FileCopyrightText: 2026 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { MessagePort as NodeMessagePort, Worker as NodeWorker } from 'node:worker_threads' +//@ts-expect-error +import nano25519_wasm from '../../build/nano25519.wasm' + +import { nano25519_init } from './nano25519' + +type Data = { + url: string + id: string + action: string + message?: string | ArrayBuffer + privateKey?: string + publicKey?: string + secretKey?: string + signature?: string +} + +const nano25519_worker_init = ({ derive, sign, verify }: ReturnType) => { + let isListening = false + let host: NodeMessagePort | null = null + let client: string | undefined = globalThis.location?.href + + /** + * Parses inbound data when nano25519 is started as a Web Worker. Only called + * by functions in `async` module. + * @param {object} message.data - Worker commands and related data + */ + function handleMessage (message: unknown): void { + NODE: if (host == null) return queueMicrotask(() => handleMessage(message)) + if (message == null + || typeof message !== 'object' + || !('data' in message) + || message.data == null + || typeof message.data !== 'object' + || !('url' in message.data) + || typeof message.data.url !== 'string' + || !('id' in message.data) + || typeof message.data.id !== 'string' + || !('action' in message.data) + || typeof message.data.action !== 'string' + ) return + let result: undefined | boolean | string | Uint8Array + let url: undefined | string + let id: undefined | string + try { + const data: Data = message.data as object & { url: string, id: string, action: string } + { ({ url, id } = data) } + if (url !== client) return + + if (data.action === 'start') { + isListening = true + result = 'started' + } else if (data.action === 'stop') { + isListening = false + result = 'stopped' + } else if (isListening) { + const { action } = data + if (action === 'derive') { + const { privateKey } = data + const publicKey = derive(privateKey) + if (publicKey == null) { + throw new TypeError('Invalid public key from WASM derive()') + } + result = publicKey + } else if (action === 'sign') { + const { message, secretKey } = data + const signature = sign(message, secretKey) + if (signature == null) { + throw new TypeError('Invalid signature from WASM sign()') + } + result = signature + } else if (action === 'verify') { + const { message, publicKey, signature } = data + const verification = verify(signature, message, publicKey) + if (verification == null) { + throw new TypeError('Invalid verification from WASM verify()') + } + result = verification + } + } + } catch (err: unknown) { + if (typeof err === 'object' && err != null) { + const { message } = err as { [k: string]: unknown } + if (typeof message === 'string' && message !== 'divide by zero') { + result = message + } + } else { + result = JSON.stringify(err) + } + } finally { + BROWSER: postMessage({ url, id, result }) + NODE: host?.postMessage({ data: { url, id, result } }) + } + } + BROWSER: addEventListener('message', handleMessage) + NODE: { + if (host == null) { + import('node:worker_threads') + .then(({ parentPort, threadId }): void => { + host = parentPort + client = threadId.toString() + host?.on('message', handleMessage) + }) + } + } +} + +const nano25519_worker = `;(${nano25519_worker_init})((${nano25519_init})([${nano25519_wasm}]));` + +/** + * Host code for asynchronous Web Worker + */ +let isWorkerReady: boolean = false +let isWorkerListening: boolean = false +let tasks: Map[0]>> = new Map() +let worker: Worker | NodeWorker +let url: string + +function isBytes (a: unknown): a is Uint8Array { + return a instanceof Uint8Array && a.buffer instanceof ArrayBuffer +} + +// Create worker module +function init (): void { + try { + BROWSER: { + url = URL.createObjectURL(new Blob([nano25519_worker], { type: 'text/javascript' })) + worker = new Worker(url, { type: 'module' }) + } + NODE: { + worker = new NodeWorker(nano25519_worker, { + eval: true, + stderr: false, + stdout: false + }) + url = worker.threadId.toString() + } + console.log(`nano25519 initialized.`) + isWorkerReady = true + } catch (err: any) { + isWorkerReady = false + throw new Error('nano25519 initialization failed') + } +} + +// Reconstruct worker when errors occur +function reset (): void { + console.warn(`nano25519 encountered an error. Reinitializing...`) + isWorkerReady = false + worker.terminate() + init() +} + +function onresult (msg: { data: Record }): void { + const { data } = msg + if (data.url !== url) return + if (!('id' in data) || typeof data.id !== 'string') return + + const executor = tasks.get(data.id) + if (executor == null) return + const [ok, err] = executor + + const { result } = data + console.log('received result from worker') + if (typeof result !== 'boolean' && typeof result !== 'string' && !isBytes(result)) { + err('Invalid return type') + } + ok(result) +} + +// Check that the worker is running and listening before sending messages +async function start (): Promise { + if (!isWorkerReady) init() + if (!isWorkerListening) { + return new Promise(async (resolve, reject): Promise => { + const onstarted = (msg: { data: Record }): void => { + if (msg.data.url !== url) return + const { result } = msg.data + if (result === 'started') { + console.log('worker started successfully') + BROWSER: { (worker as Worker).onmessage = onresult } + NODE: { (worker as NodeWorker).on('message', onresult) } + isWorkerListening = true + resolve() + } else { + isWorkerListening = false + reject() + } + } + console.log(`starting worker`) + const id = crypto.randomUUID() + const data = { url, id, action: 'start' } + BROWSER: { + worker = worker as Worker + worker.onerror = reject + worker.onmessage = onstarted + worker.postMessage(data) + } + NODE: { + worker = worker as unknown as NodeWorker + worker.on('error', reject) + worker.on('message', onstarted) + worker.postMessage({ data }) + } + }) + } +} + +// Send command and relevant data to nano25519 worker +async function dispatch (data: Record>): Promise { + const id = crypto.randomUUID() + const transfer: ArrayBuffer[] = [] + for (let k of Object.keys(data)) { + if (isBytes(data[k])) { + data[k] = data[k].buffer.slice() + transfer.push(data[k]) + } + } + console.log('sending data to worker') + data.url = url + data.id = id + return new Promise((resolve, reject) => { + tasks.set(id, [resolve, reject]) + console.log('tasks', tasks) + BROWSER: { (worker as Worker).postMessage(data, transfer) } + NODE: { (worker as NodeWorker).postMessage({ data }, transfer) } + }) +} + +// Request that the worker stop listening without terminating +async function stop (): Promise { + return new Promise((resolve, reject): void => { + const onstop = (msg: { data: Record }): void => { + const { data } = msg + if (data.url !== url) return + const { result } = data + if (result === 'stopped') { + console.log('worker stopped successfully') + isWorkerListening = false + resolve() + } else { + reject(result) + } + } + console.log(`stopping worker`) + const id = crypto.randomUUID() + BROWSER: { + worker = worker as Worker + worker.onerror = reject + worker.onmessage = onstop + worker.postMessage({ url, id, action: 'stop' }) + } + NODE: { + worker = worker as unknown as NodeWorker + worker.on('error', reject) + worker.on('message', onstop) + worker.postMessage({ data: { url, id, action: 'stop' } }) + } + }) +} + +export async function run (data: Record<'action', 'derive'> & Record>): Promise> +export async function run (data: Record<'action', 'sign'> & Record>): Promise> +export async function run (data: Record<'action', 'verify'> & Record>): Promise +export async function run (data: Record<'action', 'derive' | 'sign' | 'verify'> & Record>): Promise> { + try { + await start() + } catch (err: any) { + throw new Error('Error initializing worker') + } + try { + const result = await dispatch(data) + switch (data.action) { + case 'derive': { + if ((isBytes(result) && result.byteLength === 32) || (typeof result === 'string' && /^[0-9a-f]{64}$/i.test(result))) { + return result + } else { + throw new Error('derive result invalid') + } + } + case 'sign': { + if ((isBytes(result) && result.byteLength === 64) || (typeof result === 'string' && /^[0-9a-f]{128}$/i.test(result))) { + return result + } else { + throw new Error('sign result invalid') + } + } + case 'verify': { + if (typeof result === 'boolean') { + return result + } else { + throw new Error('verify result invalid') + } + } + } + } catch (err: any) { + try { + await stop() + } catch (e: any) { + console.error('failed to stop worker') + reset() + } finally { + throw new Error('Error dispatching async request') + } + } +} -- 2.52.0