From 9cf0e2e2a23b1176e5f522c5e7d1e06c98a054e5 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Wed, 19 Aug 2026 17:04:42 -0700 Subject: [PATCH] Split async host and worker into separate files and export worker as string with esbuid. --- esbuild/config.mjs | 7 + esbuild/inject/worker.mjs | 34 +++++ src/async.ts | 2 +- src/lib/host.ts | 168 +++++++++++++++++++++ src/lib/worker.ts | 297 +++++++++----------------------------- 5 files changed, 279 insertions(+), 229 deletions(-) create mode 100644 esbuild/inject/worker.mjs create mode 100644 src/lib/host.ts diff --git a/esbuild/config.mjs b/esbuild/config.mjs index e552886..bbd1dc9 100644 --- a/esbuild/config.mjs +++ b/esbuild/config.mjs @@ -1,6 +1,7 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { browserWorker, nodeWorker } from './inject/worker.mjs' /** * @type import("esbuild").BuildOptions */ @@ -27,6 +28,9 @@ export const browserOptions = { { in: 'src/index.ts', out: 'browser' } ], dropLabels: ['NODE'], + define: { + 'NANO25519_WORKER': browserWorker + }, } /** @@ -43,4 +47,7 @@ export const nodeOptions = { entryNames: '[name]', external: ['node:worker_threads'], dropLabels: ['BROWSER'], + define: { + 'NANO25519_WORKER': nodeWorker + }, } diff --git a/esbuild/inject/worker.mjs b/esbuild/inject/worker.mjs new file mode 100644 index 0000000..5d5602a --- /dev/null +++ b/esbuild/inject/worker.mjs @@ -0,0 +1,34 @@ +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { build } from 'esbuild' + +/** + * @type {import('esbuild').BuildOptions} + */ +const workerOptions = { + bundle: true, + loader: { + '.wasm': 'binary', + }, + entryPoints: ['./src/lib/worker.ts'], + format: 'esm', + legalComments: 'inline', + write: false, + drop: ['console', 'debugger'], + minify: true, +} + +export const browserWorker = JSON.stringify((await build({ + ...workerOptions, + platform: 'browser', + target: 'es2022', + dropLabels: ['NODE'], +})).outputFiles?.[0].text) + +export const nodeWorker = JSON.stringify((await build({ + ...workerOptions, + platform: 'node', + target: 'node22', + dropLabels: ['BROWSER'], +})).outputFiles?.[0].text) diff --git a/src/async.ts b/src/async.ts index d7eb044..fe8bdac 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/worker' +import { run } from './lib/host' /** * Asynchronous Nano public key derivation using WebAssembly. diff --git a/src/lib/host.ts b/src/lib/host.ts new file mode 100644 index 0000000..3548262 --- /dev/null +++ b/src/lib/host.ts @@ -0,0 +1,168 @@ +//! SPDX-FileCopyrightText: 2026 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { UUID } from 'node:crypto' +import { Worker as NodeWorker } from 'node:worker_threads' + +type Action = 'derive' | 'sign' | 'start' | 'verify' + +//@ts-expect-error +const nano25519_worker = NANO25519_WORKER + +/** + * Host code for asynchronous Web Worker + */ +let isWorkerReady: boolean = false +let starting: Promise | undefined +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: { + if (url) URL.revokeObjectURL(url) + url = URL.createObjectURL(new Blob([nano25519_worker], { type: 'text/javascript' })) + worker = new Worker(url, { type: 'module' }) + worker.onmessage = report + worker.onerror = reset + } + NODE: { + worker = new NodeWorker(nano25519_worker, { + eval: true, + stderr: false, + stdout: false + }) + worker.on('message', report) + worker.on('error', reset) + } + console.log(`nano25519/async initialized`) + isWorkerReady = true + } catch (e: any) { + isWorkerReady = false + throw new Nano25519WorkerError(e) + } +} + +// Helper for environment-specific messaging to worker +function post (id: UUID, data: Record & Record<'action', Action>, transfer?: ArrayBuffer[]): void { + data.id = id + BROWSER: worker.postMessage(data, transfer) + NODE: worker.postMessage({ data }, transfer) +} + +// Parse and validate worker message +function report (msg: { data: Record }): void { + const { data } = msg + if (!('id' in data) || typeof data.id !== 'string') return + + const executor = tasks.get(data.id) + if (executor == null) return + const [ok, err] = executor + tasks.delete(data.id) + + const { result } = data + console.log('received result from worker') + if (typeof result !== 'boolean' && typeof result !== 'string' && !isBytes(result)) { + return err(`expected boolean, string, or bytes; received ${result?.constructor?.name ?? typeof result} '${result}`) + } + return ok(result) +} + +// Reconstruct worker when errors occur +function reset (): void { + console.warn(`nano25519 encountered an error. Reinitializing...`) + starting = undefined + for (const [_, err] of tasks.values()) { + err(new Nano25519WorkerError('worker reset, try again')) + } + tasks.clear() + worker.terminate() + init() +} + +// Check that the worker is running and listening before sending messages +async function start (): Promise { + return starting ??= new Promise((resolve, reject): void => { + console.log('starting worker') + if (!isWorkerReady) init() + const id = crypto.randomUUID() + tasks.set(id, [resolve, reject]) + post(id, { action: 'start' }) + }) +} + +// Send command and relevant data to nano25519 worker +async function dispatch (data: Record<'action', Action> & Record>): Promise { + const id = crypto.randomUUID() + const transfer: ArrayBuffer[] = [] + for (let k of Object.keys(data)) { + if (isBytes(data[k])) { + data[k] = data[k].slice().buffer + transfer.push(data[k]) + } + } + console.log('sending data to worker') + return new Promise((resolve, reject) => { + tasks.set(id, [resolve, reject]) + post(id, data, transfer) + }) +} + +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 { + const result = await start() + if (result === 'listening') { + console.log('worker listening') + } else if (typeof result === 'string') { + throw new Error(result) + } else { + throw new Error('unknown error') + } + } catch (e: any) { + starting = undefined + throw new Nano25519WorkerError(e) + } + + 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 + } + break + } + case 'sign': { + if ((isBytes(result) && result.byteLength === 64) || (typeof result === 'string' && /^[0-9a-f]{128}$/i.test(result))) { + return result + } + break + } + case 'verify': { + if (typeof result === 'boolean') { + return result + } + break + } + } + throw new Nano25519ResultError(data.action, result) +} + +class Nano25519WorkerError extends Error { + constructor (cause?: unknown) { + super(`async process error`, { cause }) + } +} +class Nano25519ResultError extends Error { + constructor (action: string, cause?: unknown) { + super(`${action} result invalid`, { cause }) + } +} diff --git a/src/lib/worker.ts b/src/lib/worker.ts index ecb732f..01a2f6d 100644 --- a/src/lib/worker.ts +++ b/src/lib/worker.ts @@ -2,10 +2,10 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { UUID } from 'node:crypto' -import { MessagePort as NodeMessagePort, Worker as NodeWorker } from 'node:worker_threads' +import { MessagePort as NodeMessagePort } from 'node:worker_threads' +import { nano25519_init } from './nano25519' //@ts-expect-error import nano25519_wasm from '../../build/nano25519.wasm' -import { nano25519_init } from './nano25519' type Action = 'derive' | 'sign' | 'start' | 'verify' @@ -19,244 +19,85 @@ type Data = { signature?: string } -const nano25519_worker_init = ({ derive, sign, verify }: ReturnType) => { - let host: NodeMessagePort | null = null +const { derive, sign, verify } = nano25519_init(nano25519_wasm) - /** - * 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 listener (message: unknown): void { - NODE: if (host == null) return queueMicrotask(() => listener(message)) - if (message == null - || typeof message !== 'object' - || !('data' in message) - || message.data == null - || typeof message.data !== 'object' - || !('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 id: undefined | UUID - try { - const data: Data = message.data as object & { id: UUID, action: Action } - id = data.id - - switch (data.action) { - case 'start': { - result = 'listening' - break - } - case 'derive': { - const { privateKey } = data - const publicKey = derive(privateKey) - if (publicKey == null) { - throw new TypeError('Invalid public key from WASM derive()') - } - result = publicKey - break - } - case 'sign': { - const { message, secretKey } = data - const signature = sign(message, secretKey) - if (signature == null) { - throw new TypeError('Invalid signature from WASM sign()') - } - result = signature - break - } - case '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 - break - } - default: { - throw new TypeError(`Invalid action '${data.action}'`) - } - } - } catch (err: any) { - result = JSON.stringify(err?.message ?? err ?? 'unknown error in nano25519 worker listener') - } finally { - const data = { id, result } - BROWSER: postMessage(data) - NODE: host?.postMessage({ data }) - } - } - BROWSER: addEventListener('message', listener) - NODE: { - if (host == null) { - import('node:worker_threads') - .then(({ parentPort }): void => { - host = parentPort - host?.on('message', listener) - }) - } - } -} - -const nano25519_worker = `;(${nano25519_worker_init})((${nano25519_init})([${nano25519_wasm}]));` +let host: NodeMessagePort | null = null /** - * Host code for asynchronous Web Worker + * 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 */ -let isWorkerReady: boolean = false -let starting: Promise | undefined -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 { +function listener (message: unknown): void { + NODE: if (host == null) return queueMicrotask(() => listener(message)) + if (message == null + || typeof message !== 'object' + || !('data' in message) + || message.data == null + || typeof message.data !== 'object' + || !('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 id: undefined | UUID try { - BROWSER: { - if (url) URL.revokeObjectURL(url) - url = URL.createObjectURL(new Blob([nano25519_worker], { type: 'text/javascript' })) - worker = new Worker(url, { type: 'module' }) - worker.onmessage = report - worker.onerror = reset - } - NODE: { - worker = new NodeWorker(nano25519_worker, { - eval: true, - stderr: false, - stdout: false - }) - worker.on('message', report) - worker.on('error', reset) - } - console.log(`nano25519/async initialized`) - isWorkerReady = true - } catch (e: any) { - isWorkerReady = false - throw new Nano25519WorkerError(e) - } -} - -// Helper for environment-specific messaging to worker -function post (id: UUID, data: Record & Record<'action', Action>, transfer?: ArrayBuffer[]): void { - data.id = id - BROWSER: worker.postMessage(data, transfer) - NODE: worker.postMessage({ data }, transfer) -} - -// Parse and validate worker message -function report (msg: { data: Record }): void { - const { data } = msg - if (!('id' in data) || typeof data.id !== 'string') return - - const executor = tasks.get(data.id) - if (executor == null) return - const [ok, err] = executor - tasks.delete(data.id) + const data: Data = message.data as object & { id: UUID, action: Action } + id = data.id - const { result } = data - console.log('received result from worker') - if (typeof result !== 'boolean' && typeof result !== 'string' && !isBytes(result)) { - return err(`expected boolean, string, or bytes; received ${result?.constructor?.name ?? typeof result} '${result}`) - } - return ok(result) -} - -// Reconstruct worker when errors occur -function reset (): void { - console.warn(`nano25519 encountered an error. Reinitializing...`) - starting = undefined - for (const [_, err] of tasks.values()) { - err(new Nano25519WorkerError('worker reset, try again')) - } - tasks.clear() - worker.terminate() - init() -} - -// Check that the worker is running and listening before sending messages -async function start (): Promise { - return starting ??= new Promise((resolve, reject): void => { - console.log('starting worker') - if (!isWorkerReady) init() - const id = crypto.randomUUID() - tasks.set(id, [resolve, reject]) - post(id, { action: 'start' }) - }) -} - -// Send command and relevant data to nano25519 worker -async function dispatch (data: Record<'action', Action> & Record>): Promise { - const id = crypto.randomUUID() - const transfer: ArrayBuffer[] = [] - for (let k of Object.keys(data)) { - if (isBytes(data[k])) { - data[k] = data[k].slice().buffer - transfer.push(data[k]) - } - } - console.log('sending data to worker') - return new Promise((resolve, reject) => { - tasks.set(id, [resolve, reject]) - post(id, data, transfer) - }) -} - -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 { - const result = await start() - if (result === 'listening') { - console.log('worker listening') - } else if (typeof result === 'string') { - throw new Error(result) - } else { - throw new Error('unknown error') - } - } catch (e: any) { - starting = undefined - throw new Nano25519WorkerError(e) - } - - 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 + switch (data.action) { + case 'start': { + result = 'listening' + break } - break - } - case 'sign': { - if ((isBytes(result) && result.byteLength === 64) || (typeof result === 'string' && /^[0-9a-f]{128}$/i.test(result))) { - return result + case 'derive': { + const { privateKey } = data + const publicKey = derive(privateKey) + if (publicKey == null) { + throw new TypeError('Invalid public key from WASM derive()') + } + result = publicKey + break } - break - } - case 'verify': { - if (typeof result === 'boolean') { - return result + case 'sign': { + const { message, secretKey } = data + const signature = sign(message, secretKey) + if (signature == null) { + throw new TypeError('Invalid signature from WASM sign()') + } + result = signature + break + } + case '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 + break + } + default: { + throw new TypeError(`Invalid action '${data.action}'`) } - break } + } catch (err: any) { + result = JSON.stringify(err?.message ?? err ?? 'unknown error in nano25519 worker listener') + } finally { + const data = { id, result } + BROWSER: postMessage(data) + NODE: host?.postMessage({ data }) } - throw new Nano25519ResultError(data.action, result) } -class Nano25519WorkerError extends Error { - constructor (cause?: unknown) { - super(`async process error`, { cause }) - } -} -class Nano25519ResultError extends Error { - constructor (action: string, cause?: unknown) { - super(`${action} result invalid`, { cause }) +BROWSER: addEventListener('message', listener) +NODE: { + if (host == null) { + import('node:worker_threads') + .then(({ parentPort }): void => { + host = parentPort + host?.on('message', listener) + }) } } -- 2.52.0